事务需要保证原子性。 但是偏偏有时候事务在执行到一半时会出现一些情况,比如下面这些情况:
这两种情况都会导致事务执行到一半就结束,但是事务在执行过程中可能已经修改了很多东西。为了保证事务的原子性,我们需要改回原来的样子,这个过程就称为回滚(rollback)。
这就造成了一个假象:这个事务看起来什么都没做,所以符合原子性要求(有时候仅需要对部分语句进行回滚,有时候需要对整个事务进行回滚)。
回滚的实现方式大致如下:
这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记录,所以在执行查询操作时,并不需要记录相应的 undo 日志。MySQL 把这些为了回滚而记录的东西称为撤销日志 (undo log)。
为了实现事务的原子性,InnoDB 存储引擎在实际进行记录的增删改操作时,都需要先把对应的 undo 日志记下来。一般每对一条记录进行一次改动,就对应着一条 undo 日志。但在某些更新记录的操作中,也可能会对应着 2 条 undo 日志。一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记录很多条对应的 undo 日志。这些 undo 日志会从 0 开始编号,也就是说根据生成的顺序分别称为第 0 号 undo 日志、第 1 号 undo 日志······第 n 号 undo 日志等。这个编号也称为 undo no。
这些 undo 日志被记录到类型为 FlL_PAGE_UNDO_LOG(对应的十六进制是 0x0002)的页面中。这些页面可以从系统表空间中分配,也可以从一种专门存放 undo 日志的表空间(undo tablespace)中分配。
在插入一条数据时,会向聚簇索引和所有二级索引都插入一条记录,但是 undo 日志只会记录一条针对聚簇索引的日志。聚簇索引记录和二级索引记录是一一对应的,回滚 INSERT 操作时,根据这条记录的主键信息进行对应的删除操作,把聚簇索引和二级索引中相应的记录都删掉。DELETE 和 UPDATE 操作同理,都是针对聚簇索引记录的改动来记录 undo 日志。
在阶段 2 执行完后,这条记录就算是真正地被删除掉了。这条己删除记录占用的存储空间也就可以重新利用了。
在执行一条删除语句的过程中,在删除语句所在的事务提交之前,只会经历 delete mark 阶段。而一旦事务提交,我们也就不需要再回滚这个事务了。 所以在设计 undo 日志时,只需要考虑对删除操作在阶段 1 所做的影响进行回滚就好了。下图是类型为 TRX_UNDO_DEL_MARK_REC 的 undo 日志的完整结构。
在更新记录时,对于被更新的每个列来说,如果更新后的列与更新前的列占用的存储空间一样大,那么可以进行就地更新,也就是直接在原记录的基础上修改对应列的值。但是,只要有任何一个被更新的列在更新前比更新后占用的存储空间大,或者在更新前比更新后占用的存储空间小,就不能进行就地更新。
在不更新主键的情况下,如果有任何一个被更新的列在更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧记录从聚簇索引页面中删除,然后再根据更新后列的值创建一条新的记录并插入到页面中。
这里所说的删除并不是delete mark操作,而是真正地删除掉,也就是把这条记录从正常记录链表中移除并加入到垃圾链表中,并且修改页面中相应的统计信息(比如 PAGE_FREE、PAGE_GARBAGE 等信息)。不过,这里执行真正删除操作的线程并不是在 DELETE 语句
中进行 purge 操作时使用的专门的线程,而是由用户线程同步执行真正的删除操作。在真正删除之后,紧接着就要根据各个列更新后的值来创建一条新记录,然后把这条新记录插入到页面中。
如果新创建的记录占用的存储空间不超过旧记录占用的空间,那么可以直接重用加入到垃圾链表中的旧记录所占用的存储空间,否则需要在页面中新申请一块空间供新记录使用。如果本页面内已经没有可用的空间,就需要进行页面分裂操作,然后再插入新记录。
上述两种情况的 undo log 类型为 TRX_UNDO_UPD_EXIST_REC,如下图所示:
<pos,old_len,old_value>
列表中的 pos、old_len 和 old_value 分别表示被更新列在记录中的位置、更新前该列占用的存储空间大小、更新前该列的真实值。若 UPDATE 语句更新了主键,则分为两步进行处理:
一个表可以拥有一个聚簇索引以及多个二级索引,对于二级索引记录来说,INSERT 操作和 DELETE 操作与在聚簇索引中执行时产生的影响差不多,但是 UPDATE 操作稍微有点儿不同。如果我们的 UPDATE 语句中没有涉及二级索引的列,那么就不需要对二级索引执行任何操作。相反,如果在 UPDATE 语句中涉及了二级索引的列,由于这个语句涉及了 key1 列,而 key1 列又包含在二级索引 idx_key1 中,所以这相当于更新了二级索引的键值。更新了二级索引记录的键值,就意味着要进行下面这两个操作。
我们以事务对普通表的记录进行改动为例,来梳理一下事务执行过程中分配 Undo 页面链表时的完整过程。
对临时表的记录进行改动时,步骤与上面一样。不过需要再强调一次,如果一个事务在执行过程中既对普通表的记录进行了改动,又对临时表的记录进行了改动,那么需要为这个事务分配 2 个回滚段。并发执行的不同事务其实也可以被分配相同的回滚段,只要分配不同的 undo slot 就可以了。
在服务器因为崩溃而恢复的过程中,首先需要按照 redo log 将各个页面的数据恢复到崩溃之前的状态,这样可以保证已经提交的事务的持久性。但是这里仍然存在一个问题,就是那些没有提交的事务写的 redo 日志可能也已经刷盘,那么这些未提交的事务修改过的页面在 MySQL 服务器重启时可能也被恢复了。
为了保证事务的原子性,有必要在服务器重启时将这些未提交的事务回滚掉。那么,怎么找到这些未提交的事务呢?这个工作又落到了 undo 日志头上。
我们可以通过系统表空间的第 5 号页面定位到 128 个回滚段的位置,在每一个回滚段 的 1024 个 undo slot 中找到那些值不为 FIL_NULL 的 undo slot,每一个 undo slot 对应着个 Undo 页面链表。然后从 Undo 页面链表第一个页面的 Undo Segment Header 中找到 TRX_UNDO_STATE 属性,该属性标识当前 Undo 页面链表所处的状态。如果该属性的值为 TRX_UNDO_ACTIVE
,则意味着有一个活跃的事务正在向这个 Undo 页面链表中写入 undo 日志。然后再在 Undo Segment Header 中找到 TRX_UNDO_LAST_LOG 属性,通过该属性可以找到本 Undo 页面链表最后一个 Undo Log Header 的位置。从该 Undo Log Header 中可以找到对应事务的事务 id 以及一些其他信息,则该事务 id 对应的事务就是未提交的事务。通过 undo 日志中记录的信息将该事务对页面所做的更改全部回滚掉,这样就保证了事务的原子性。
为了保证事务的原子性,设计 InnoDB 的大叔引入了 undo 日志。undo 日志记载了回滚一个操作所需的必要内容。
在事务对表中的记录进行改动时,才会为这个事务分配一个唯一的trx_id。事务 id 值是一个递增的数字。先被分配 id 的事务得到的是较小的事务 id,后被分配 id 的事务得到的是较大的事务 id。未被分配事务 id 的事务的事务 id 默认是 0。聚簇索引记录中有一个 trx_id隐藏列,它代表对这个聚簇索引记录进行改动的语句所在的事务对应的事务 id。
InnoDB 针对不同的场景设计了不同类型的 undo 日志,比如 TRX_UNDO_INSERT_REC、TRX_UNDO_DEL_MARK_REC、TRX_UNDO_UPD_EXIST_REC 等。类型为 FIL_PAGE_UNDO_LOG 的页面是专门用来存储 undo 日志的,我们简称为 Undo 页面。
在一个事务执行过程中,最多分配 4 个 Undo 页面链表,分别是:
只有在真正用到这些链表的时候才去创建它们。
每个 Undo 页面链表都对应一个 Undo Log Segment。Undo 页面链表的第一个页面中有一个名为 Undo Log Segment Header 的部分,专门用来存储关于这个段的一些信息。
同一个事务向一个 Undo 页面链表中写入的 undo 日志算是一个组,每个组都以一个
Undo Log Header 部分开头。
一个 Undo 页面链表如果可以被重用,需要符合下面的条件:
每一个 Rollback Segment Header 页面都对应着一个回滚段,每个回滚段包含 1024 个 undo slot,一个 undo slot 代表一个 Undo 页面链表的第一个页面的页号。目前,InnoDB 最多支持 128 个回滚段,其中第 0 号、第 33~127 号回滚段是针对普通表设计的,第 1~32 号回滚段是针对临时表设计的。
我们可以选择将 undo 日志记录到专门的 undo 表空间中,在 undo 表空间中的文件大到一定程度时,可以自动将该 undo 表空间截断为小文件。