文章

InnoDB Undo Log

undo log 是什么

数据库事务需要保证原子性,也就是说事务中的操作要么全部完成,要么什么也不做。但是事务执行过程中可能遇到各种错误,这会导致事务提前结束,此时可能已经对记录做了一些修改,为了保证事务的原子性,需要把记录改回到事务执行前的状态,这个过程就叫回滚(rollback)。

想要回滚记录,就需要在修改记录时记下一些东西,如:

  • INSERT:插入一条记录时,需要把这条记录的主键记下来

  • DELETE:删除一条记录时,需要把这条记录的内容记下来

  • UPDATE:修改一条记录时,需要把这条记录的旧值记下来

InnoDB 把上面这些为了回滚需要记录的东西称为 undo log

事务id

事务id的分配时机

如果某个事务执行过程中对某个表执行了增删改操作,那么 InnoDB 就会给它分配一个唯一的事务id,分配时机如下:

  • 对于只读事务:第一次对某个用户创建的临时表执行增删改操作时分配事务id

  • 对于读写事务:第一次对某个表(包括用户创建的临时表)执行增删改操作时分配事务id

事务id的生成方式

事务id和row_id的生成方式差不多:

  • 服务器会在内存中维护一个全局变量,每当需要分配一个事务id时,就会直接取该变量的值当作事务id,然后把该变量加1。

  • 每当该变量值为256的倍数时,就会将该变量值刷新到系统表空间页号为5的页的一个叫 Max Trx ID 的属性。

  • 当系统重启后,会将 Max Trx ID 属性值加上 256,然后赋值给内存中那个用来分配事务id的全局变量。

trx_id 隐藏列

我们知道 InnoDB 的记录行格式除了会保存完整的用户数据外,还会自动添加 row_id(表没有主键且没有不为NULL的UNIQUE列才添加此列)、trx_id、roll_pointer 隐藏列:

这里的 trx_id 就是最近一个对该聚簇索引记录做修改的事务id。

undo log 格式

InnoDB 在增删改一条记录时,都要先把对应的 undo log 记录下来。一般对一条记录做一次改动,对应1条 undo log,但是也有对应2条 undo log 的情况。一个事务执行过程中可能增删改若干条记录,也就对应了许多条 undo log,这些 undo log 有自己的编号,即 undo no,每个事务对应的 undo no 都是从 0 开始的。这些 undo log 会保存到类型为 FIl_PAGE_UNDO_LOG 的页中,这些页可以从系统表空间分配,也可以从专门的 undo 表空间分配。

下面的内容以这张表为例:

CREATE TABLE undo_demo (
  id INT NOT NULL,
  name VARCHAR(64),
  hobby VARCHAR(64),
  PRIMARY KEY(`id`),
  KEY idx_name(`name`)
) Engine=InnoDB DEFAULT CHARSET=utf8;

可通过 information_shema 的 innodb_sys_tables 查询 undo_demo 表的 table id:

mysql> SELECT * FROM information_schema.innodb_sys_tables WHERE NAME like '%undo_demo';                                                                                                                             
+----------+----------------+------+--------+-------+-------------+------------+---------------+------------+                                                                                                       
| TABLE_ID | NAME           | FLAG | N_COLS | SPACE | FILE_FORMAT | ROW_FORMAT | ZIP_PAGE_SIZE | SPACE_TYPE |                                                                                                       
+----------+----------------+------+--------+-------+-------------+------------+---------------+------------+                                                                                                       
|       55 | test/undo_demo |   33 |      6 |    42 | Barracuda   | Dynamic    |             0 | Single     |                                                                                                       
+----------+----------------+------+--------+-------+-------------+------------+---------------+------------+ 

INSERT操作对应的 undo log

如果希望回滚插入操作,只要把插入的这条记录删除就行了,这意味着 undo log 只用记录这条记录的主键信息即可,为此 InnoDB 设计了一个 TRX_UNDO_INSERT_REC 类型的 undo log:

1. undo no 在一个事务中是从 0  开始递增的

2. InnoDB 插入一条记录时,实际上需要往主键索引和所有二级索引中都插入一条记录,但是 undo log 中只需要记录聚簇索引的插入情况就行。

下面举一个例子说明,在一个事务中插入2条记录:

INSERT INTO undo_demo(id, name, hobby) values(1, 'Tom', 'sleeping'), (2, 'Jerry', 'eating');

1. 由于 undo no 在一个事务中是从 0 开始递增的,因此记录1的 undo no 值为0,记录2的 undo no 值为1。

2. undo_demo 表的主键只有一个列,即 id,其类型为 INT,固定占 4 个字节,因此记录1和记录2主键各列信息中的 len 都为 4。

roll_pointer 隐藏列

记录的 roll_pointer 隐藏列占 7 个字节,它本质上是一个指针,指向记录对应的 undo log 地址。比如我们上面插入的2条记录,每条记录都有其对应的 undo log:

DELETE操作对应的 undo log

插入到数据页中的记录会根据记录头中的 next_record 属性组成一个单向链表,称这个链表为正常记录链表。同时,数据页中被删除的记录也会 next_record 属性组成一个单向链表,由于这个链表占用的空间可以被重新利用,因此称这个链表为垃圾链表。数据页的 Page Header 中有一个叫 PAGE_FREE 的属性,它指向垃圾链表的头节点。

我们看一个数据页的例子:

这是一个简化的示意图,只标示了记录的 delete_mask 属性。下面我们把正常记录链表中的最后一条记录删除掉,这个过程要包含两个阶段:

1. delete mark 阶段:将记录的 delete_mask 属性置为 1(也会修改记录的 trx_id 和 roll_pointer 属性值)

可以看到最后一条记录经过 delete mark 阶段后,其 delete_mask 属性值为 1,但是未移动到垃圾链表,此时该记录处于一个中间状态,InnoDB 设计这个中间状态的目的是服务 MVCC 这个功能。

2. purge 阶段:当删除该记录的语句所在的事务提交后,会有一个专门的线程来把这条记录删除掉,也就是把这条中间状态的记录从正常记录链表移动到垃圾链表的头节点,同时更新数据页中维护的一些统计信息,如页中用户记录数量PAGE_N_RECS、上次插入记录的位置PAGE_LAST_INSERT、垃圾链表头节点指针PAGE_FREE、页面中可重用的字节数PAGE_GARBAGE以及页目录信息等。等这个阶段执行完,这条记录就被真正删除了。要注意的是,被删除的记录会放到垃圾链表的头节点处:

从上面可以看出,由于已提交事务所做的修改不需要回滚,因此在删除语句所在的事务被提交之前,只会经历 delete mark 阶段。针对删除操作,InnoDB 设计了一个类型为 TRX_UNDO_DEL_MARK_REC 的 undo log:

1. 关于索引列各列信息,只要是在索引中出现的列,它的信息就会被记录到这个部分。

2. 由于 delete mark 阶段会修改记录的 trx_id 和 roll_pointer,因此 undo log 中使用 old trx_id 和 old roll_pointer 保存了修改前的 trx_id 和 roll_pointer,通过 old roll_pointer 可以找到记录修改前的 undo log,这样一个记录的多条 undo log 可以形成一个版本链。例如,在一个事务中,插入了一条记录,接着又删除了这条记录,其 undo log 版本链示意图如下:

接着上面在 undo_demo 中插入了2条记录的例子,我们再把插入的第1条记录删除:

DELETE FROM undo_demo WHERE id = 1;

其 undo log 版本链示意图如下:

undo_demo 中有2个索引:

1. 聚簇索引:

  • 只包含 id 列,id 在记录的第 1 个位置,因此 pos 为 1

  • id 列类型为 INT,因此 len 为 4

  • 第一条记录的 id 列值为 1

2. 二级索引idx_name:

  • 只包含 name 列,name 在记录的 4 个位置,前面分别是 id、trx_id 、roll_pointer 列,因此 pos 为 3

  • name 列类型为 VARCHAR(64),在 utf8 字符编码中,字符串 Tom 占 3 个字节,因此 len 为 3

  • name 列值为 Tom

因此索引列各列信息为:<0, 4, 1>, <3, 3, Tom>。

UPDATE操作对应的 undo log

对于 UPDATE 操作,分更新主键和不更新主键两种情况。

更新主键

针对 UPDATE 语句中更新主键值的情况,InnoDB 分了两步来做:

1. 对旧记录执行 delete mark 操作(会记录一条 TRX_UNDO_DEL_MARK_REC 类型的 undo log)

2. 将更新后的记录插入聚簇索引(会记录一条 TRX_UNDO_INSERT_REC 类型的 undo log)

可以看到,UPDATE 更新主键操作,会记录 2 条 undo log。

不更新主键

针对 UPDATE 语句中不更新主键值的情况,又可以分为两种情况:

(1) 就地更新

更新记录时,被更新的每个列在更新前后占用的空间是一致的,那么就可以就地更新

(2) 非就地更新

更新记录时,如果有任何一个被更新的列在更新前后占用的空间发生变化,导致无法就地更新,那么就需要把旧记录先从聚簇索引删除掉,再把更新后的记录插入聚簇索引中。

注意:这里的删除是真正地删除操作,并且不是 purge 使用专门线程来异步删除,而是会在用户线程中同步执行该删除操作。

针对 UPDATE 操作不更新主键的情况,InnoDB 设计了一种 TRX_UNDO_UPD_EXIST_REC 类型的 undo log:

其中大部分属性 TRX_UNDO_DEL_MARK_REC 中的相同,不同的是多了 n_updated 和 被更新列更新前信息。

需要注意的是,索引列各列信息这个部分只有在更新操作更新的列包含索引列时才存在。

接着上面使用的 undo_demo 的例子,对插入的第2条记录做一些修改:

UPDATE undo_demo SET hobby = 'swimming' WHERE id = 2;

其 undo log 版本链示意图如下:

由于该 UPDATE 语句未更新索引列的值,因此不需要索引列各列信息这个部分。

了解了几种 undo log 类型,接下来我们看看这些 undo log 会写入到哪以及如何写入。

FIL_PAGE_UNDO_LOG 页

我们知道表空间是由许许多多的页构成的,例如存储聚簇索引和二级索引的 FIL_PAGE_INDEX 页,至于 undo log 则是存储在 FIL_PAGE_UNDO_LOG 页中,其结构如下:

Undo Page Header 是 FIL_PAGE_UNDO_LOG 独有的,它的几个属性含义如下:

属性

说明

TRX_UNDO_PAGE_TYPE

当前页用来存放哪种大类的 undo log,有 TRX_UNDO_INSERTTRX_UNDO_UPDATE 两种取值,TRX_UNDO_INSERT_REC 归属于 TRX_UNDO_INSERT,除此之外,其他类型都属于 TRX_UNDO_UPDATE,如 TRX_UNDO_DEL_MARK_REC、TRX_UNDO_UPD_EXIST_REC。

TRX_UNDO_PAGE_START

当前页中第一条undo log 开始时在页中的偏移量

TRX_UNDO_PAGE_FREE

当前页中最后一条undo log 结束时在页中的偏移量

TRX_UNDO_PAGE_NODE

表示一个 List Node 结构

Undo 页面链表

单个事务中的 Undo 页面链表

一个事务中可能对多条记录进行修改,对应有多条 undo log,只用一个页面的话可能放不下这些 undo log,因此这些 undo log 会存放到由 TRX_UNDO_PAGE_NODE 组成的 Undo 页面链表中:

链表的第一个页面称为 first undo page,其他页面称为 normal undo page,而 first undo page 会额外记录一些管理信息。

在一个事务的执行过程中,可能混合执行 INSERT、UPDATE 和 DELETE 操作,也就是说会同时用到 TRX_UNDO_INSERT 和 TRX_UNDO_UPDATE 两种大类的页面,因此可能需要 2 个 Undo 页面链表:insert undo 链表和 update undo 链表。此外,InnoDB 规定对普通表和临时表的修改产生的 undo log 要分开来记录,所以一个事务中最多可能用到 4 个 Undo 页面链表:

对于 Undo 页面链表的分配,不会一开始就分配好,而是要用时再分配:

1. 刚开启事务时,一个 Undo 页面链表也不分配

2. 事务执行过程中向普通表插入记录或者执行了更新记录主键的操作,为其分配一个普通表的 insert undo 链表

3. 事务执行过程中删除或更新了普通表中的记录,为其分配一个普通表的 update undo 链表

4. 事务执行过程中向临时表插入记录或者执行了更新记录主键的操作,为其分配一个临时表的 insert undo 链表

5. 事务执行过程中删除或更新了临时表中的记录,为其分配一个临时表的 update undo 链表

多个事务中的 Undo 页面链表

为了提高 undo log 的写入效率,不同事务产生的 undo log 会写入不同的 Undo 页面链表中。

假如有两个事务,事务A对普通表做了 DELETE 操作,对临时表做了 INSERT、UPDATE 操作,事务B对普通表做了 INSERT、DELETE、UPDATE 操作,则

一共会用到 5 个 Undo 页面链表:事务A执行时会用到普通表的 update undo 链表、临时表的 insert undo 链表和临时表的 update undo 链表,事务B执行时会用到普通表的 insert undo 链表和普通表的 update undo 链表。

Undo Log 写入过程

段(Segment)是一个逻辑概念,由若干个区(Extent)和若干个零散页组成,目的是让同种功能的页尽量存储在一起,常见的段有叶子结点段和非叶子结点段。每一个段都对应一个 INODE Entry 结构,其中存储了描述这个段的信息,如段id、段内各种链表的基节点、零散页页号等。InnoDB 设计了一个 Segment Header 的结构,包含 Space ID of the INODE Entry、Page Number of the INODE Entry 和Byte Offset of the INODE Entry 这 3 个属性,用来定位某个段对应的 INODE Entry 结构。

Undo Log Segment Header

InnoDB 规定每一个 Undo 页面链表都对应一个段,称之为 Undo Log Segment。InnoDB 在 first undo page 中设计了一个叫 Undo Log Segment Header 的部分,里面包含了这个段的 Segment Header 信息以及一些其他的段信息:

Undo Log Segment Header 中各个属性含义如下:

属性

含义

TRX_UNDO_STATE

当前 Undo 页面链表的状态

TRX_UNDO_LAST_LOG

当前 Undo 页面链表最后一个 Undo Log Header 的位置

TRX_UNDO_FSEG_HEADER

当前 Undo 页面链表对应段的 Segment Header 信息(通过该信息可以找到 INODE Entry)

TRX_UNDO_PAGE_LIST

当前 Undo 页面链表的基节点(注意:基节点不同于头节点)

TRX_UNDO_STATE 的取值有:

1. TRX_UNDO_ACTIVE:活跃状态,表示一个活跃的事务正在往这个段中写入 undo log

2. TRX_UNDO_CACHED:被缓存状态,处于该状态的 Undo 页面链表可以被其他事务重用

3. TRX_UNDO_TO_FREE:对于 insert undo 链表,如果事务提交后不能被重用,则处于这种状态

4. TRX_UNDO_TO_PURGE:对于 update undo 链表,如果事务提交后不能被重用,则处于这种状态

5. TRX_UNDO_PREPARED:Undo 页面链表中包含处于 PREPARE 阶段的事务产生的 undo log

Undo Log Header

InnoDB 把同一个事务向同一个 Undo 页面链表写入的 undo log 算作一组,在写入一组 undo log 之前会使用 Undo Log Header 结构记录一下这个组的一些属性,因此 first undo page 在写入 File Header、Undo Page Header、Undo Segment Header 之后,还有写入一个叫 Undo Log Header 的部分:

其中 Undo Log Header 结构如下:

重用 Undo 页面

我们已经知道一个事务最多可以被分配 4 个单独的 Undo 页面链表,但是有时候一个事务可能只产生了少量的 undo log,这会造成一定的空间浪费,因此 InnoDB 在一个 Undo 页面链表只包含一个页并且该页已用空间不到 3/4 时会重用该链表。

要注意的是,重用 insert undo 链表和 update undo 链表的规则是不同的:

  • insert undo 链表:由于 insert undo 链表中只存储 TRX_UNDO_INSERT_REC 类型的 undo log,这种 undo log 在事务提交之后就没用了,因此重用 insert undo 链表时,可以直接写入新的一组 undo log,把之前事务写入的一组 undo log 覆盖掉。

  • update undo 链表:在一个事务提交后,由于 MVCC,update undo 链表中的 undo log 不能立即删除,因此重用 update undo 链表只能往后追加写入新的一组 undo log。

回滚段

我们知道一个事务执行过程中最多可以分配 4 个 Undo 页面链表,在同一时刻可能有多个事务在执行,因此在同一时刻系统里有很多 Undo 页面链表存在,为了管理这些链表,InnoDB 设计了一个叫 Rollback Segment Header 的页,其中存放了各个 Undo 页面链表的 first undo page 的页号,这些页号被称为 undo slot

每个 Rollback Segment Header 页面都对应一个段,称为回滚段(Rollback Segment),其页面结构如下:

从回滚段中申请 Undo 页面链表

当某个回滚段未管理任何 Undo 页面链表时,对应 Rollback Segment Header 页中的 undo slot 初始值都为 FIL_NULL,表示该 slot 不指向任何页。

当回滚段需要管理某个事务新分配的 Undo 页面链表时,就从第一个 undo slot 开始,检查其值是否为 FIL_NULL,如果是 FIL_NULL,则在表空间中新建一个段(Undo Log Segment),然后从段中申请一个页作为 Undo 页面链表的 first undo page,再把新申请的页的地址赋值到当前 undo slot 中;如果不是 FIL_NULL,则跳到下一个 undo slot,判断其值是否为 FIL_NULL,重复这个过程。

当一个事务提交时,该事务占用的 undo slot 有几种处理方式:

1. 如果该 undo slot 对应的 Undo 页面链表符合被重用的条件,则该 undo slot 处于被缓存的状态,Undo 页面链表的 TRX_UNDO_STATE 属性会被设置为 TRX_UNDO_CACHED。同时被缓存的 undo slot 被加入一个链表,如果对应的 Undo 页面链表是 insert undo 链表,则会加入 insert undo cached 链表,如果是 update undo 链表,则会加入 update undo cached 链表。

2. 如果该 undo slot 不能被重用:

  • 如果是 insert undo 链表,则该 Undo 页面链表的 TRX_UNDO_STATE 属性会被设置为 TRX_UNDO_TO_FREE,对应段会被立即释放掉,undo slot 值会被设置为 FIL_NULL。

  • 如果是 update undo 链表,则该 Undo 页面链表的 TRX_UNDO_STATE 属性会被设置为 TRX_UNDO_TO_PURGE,对应段不会被立即释放掉,,undo slot 值会被设置为 FIL_NULL,然后将本次事务写入的一组 undo log 放到 History 链表中。

多个回滚段

我们知道一个回滚段有 1024 个 undo slot,而一个事务最多可以分配 4 个 Undo 页面链表,也就是说最多支持 1024 个读写事务同时执行(只读事务不用分配 Undo 页面链表),这个数量肯定是不够的,因此 InnoDB 一共定义了 128 个回滚段,这样最多可以支持 128 * 1024 个读写事务同时执行,这就肯定够用了。

InnoDB 在系统表空间的第 6 个页面(页号为 5)中专门规划了一块区域来存储这 128 个回滚段对应的 Rollback Segment Header 页面地址,这块区域包含 128 个 8 字节的小格子,每个小格子有两个属性:Space ID 和 Page Number。

需要用到 Space ID,说明回滚段可以分布在不同的表空间中。

回滚段的分类

这 128 个回滚段编号从 0 到 127,根据编号分为两类:

1. 第 0 号和第 33 ~ 127 号属于一类,一个事务执行过程中对普通表做了改动需要分配 Undo 页面链表时,从这一类段中分配 undo slot。(第 0 号回滚段必须在系统表空间中,第 33 ~ 127 号回滚段可以在系统表空间中,也可以在配置的 undo 表空间中)

2. 第 1 ~ 32 号属于一类,一个事务执行过程中对临时表做了改动需要分配 Undo 页面链表时,从这一类段中分配 undo slot。(这些回滚段必须在临时表空间中,默认为 ibtmp1 文件)

Undo 页面,即 FIL_PAGE_UNDO_LOG 也是一个页面,修改它之后也要记录 redo log,这样系统奔溃后才能恢复到奔溃前的状态。但是系统奔溃后重启是不用恢复对临时表的改动的,因此并不需要为临时表的改动记录 redo log,因此针对普通表和临时表划分了不同种类的回滚段。

为事务分配 Undo 页面链表的过程

下面以对普通表的改动为例,描述为事务分配 Undo 页面链表的过程:

1. 分配回滚段:事务在执行过程中,对普通表的记录做首次改动前,会到系统表空间页号为 5 的页分配一个回滚段(其实是 Rollback Segment Header)。分配回滚段使用 round-robin 算法,即第 0 号、第 33 号、第 34 号、...、第 127 号、第 0 号、第 33 号、...,如此循环分配。

2. 分配 undo slot:

  1. 首先检查这个回滚段是否有缓存的 undo slot,如果是 INSERT 操作,检查 insert undo cached 链表,否则检查 update undo cached 链表。

  2. 如果没有缓存的 undo slot,就到 Rollback Segment Header 页中找一个可用的 undo slot 给当前事务,同时重新分配一个 Undo Log Segment,并在 Undo Log Segment 中申请一个页面作为 Undo 页面链表的 first undo page,当前分配的 undo slot 也会被赋值为该 first undo page 的地址。

3. 在分配的 Undo 页面链表中写入 undo log

配置回滚段

配置回滚段的数量

可以通过 innodb_rollback_segments 启动参数配置回滚段的数量,配置范围为 1 ~ 128(默认为 128),但是临时表回滚段数量保持为 32 个,其规则如下:

  • 如果 innodb_rollback_segments 配置值在 1 ~ 33 中,那么只会有1个普通表的回滚段,但是仍然有32个临时表的回滚段。 

  • 如果 innodb_rollback_segments 配置值大于 33,假设为 n,则普通表有 n - 32 个回滚段,临时表的回滚段仍然是32个。 

配置 undo 表空间

默认情况下,针对普通表设立的回滚段(第 0 号以及第 33~127 号回滚段)都是被分配到系统表空间的。 但是第 33 ~ 127 号回滚段可以通过配置被平均分布到 undo 表空间中(在系统初始化前):

  • 通过 innodb_undo_directory 指定 undo 表空间所在目录,如不指定,默认为数据目录

  • 通过 innodb_undo_tablespaces 指定 undo 表空间的数量,默认为 0,表示不创建 undo 表空间

举个例子:如果 innodb_rollback_segments 配置值为 35,innodb_undo_tablespaces 配置值为 2,则有第 0 号、第 33 号和第 34 号这3 个针对普通表的回滚段,其中第 33 号和第 34 号回滚段会被分别存放到一个 undo 表空间中。

License:  CC BY 4.0