MySQL事务原子性之UndoLog,图文结合带你直击核心

Undo Log

事务的第一个特性就是原子性,原子性就是要保证一个事务中的增删改操作要么都成功,要么都不做。这时就需要 undo log,在对数据库进行修改前,会先记录对应的 undo log,然后在事务失败或回滚的时候,就可以用这些 undo log 来将数据回滚到修改之前的样子。

下面先简单介绍下事务ID和行记录中的隐藏列,因为后面的内容都与这两个东西有关系。

事务ID

事务执行过程中在对某个表执行增、删、改操作时,InnoDB就会给这个事务分配一个唯一的事务ID。如果一个事务中没有执行增删改操作,就不会分配事务ID。

InnoDB 在内存维护了一个全局变量来表示事务ID,每当要分配一个事务ID时,就获取这个变量值,然后把这个变量自增1。每当这个变量的值为256的倍数时,就会将该变量的值刷新到系统表空间的页号为5的页面中一个称之为Max Trx ID的属性处。当系统下一次重新启动时,会将Max Trx ID属性加载到内存中,并将该值加上256之后赋值给这个全局变量(这个过程跟主键row_id的分配是类似的)。

我们可以通过
information_schema.INNODB_TRX 来查询当前系统中运行的事务信息,这张表的第一个字段trx_id就是事务ID。

mysql> SELECT trx_id,trx_state,trx_started,trx_rows_locked,trx_isolation_level,trx_is_read_only FROM information_schema.INNODB_TRX;
+-----------+-----------+---------------------+-----------------+---------------------+------------------+
| trx_id    | trx_state | trx_started         | trx_rows_locked | trx_isolation_level | trx_is_read_only |
+-----------+-----------+---------------------+-----------------+---------------------+------------------+
| 164531720 | RUNNING   | 2021-05-14 16:38:59 |               1 | REPEATABLE READ     |                0 |
+-----------+-----------+---------------------+-----------------+---------------------+------------------+

行记录隐藏列

在介绍InnoDB行记录格式这篇文章中,我们了解到行记录中会有三个隐藏列:

  • DB_ROW_ID:如果没有为表显式的定义主键,并且表中也没有定义唯一索引,那么InnoDB会自动为表添加一个row_id的隐藏列作为主键。
  • DB_TRX_ID:事务中对某条记录做增删改时,就会将这个事务的事务ID写入trx_id中。
  • DB_ROLL_PTR:回滚指针,本质上就是指向 undo log 的指针。

MySQL事务原子性之UndoLog,图文结合带你直击核心

Undo Log 类型

每对一条记录做一次改动,就会产生1条或者2条 undo log。一个事务中可能会有多个增删改SQL语句,一个SQL语句可能会产生多条 undo log,一个事务中的这些 undo log 会被从 0 开始递增编号,这个编号称为 undo no。

undo log 主要是记录对数据库增删改的撤销日志,下面就分别来看下增删改操作的 undo log 格式是怎样的。

还是以之前的 account 表为例来做一些演示。

CREATE TABLE `account` (
  `id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `card` varchar(60) NOT NULL COMMENT '卡号',
  `balance` int(11) NOT NULL DEFAULT '0' COMMENT '余额',
  PRIMARY KEY (`id`),
  UNIQUE KEY `account_u1` (`card`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户表';

我们可以通过
information_schema.INNODB_SYS_TABLES 查询得到这张表的表空间ID为 13881。

mysql> SELECT * FROM information_schema.INNODB_SYS_TABLES WHERE name = 'test/account';
+----------+-------------+------+--------+-------+-------------+------------+---------------+------------+
| TABLE_ID | NAME        | FLAG | N_COLS | SPACE | FILE_FORMAT | ROW_FORMAT | ZIP_PAGE_SIZE | SPACE_TYPE |
+----------+-------------+------+--------+-------+-------------+------------+---------------+------------+
|    13881 | test/account |   33 |      6 | 13935 | Barracuda   | Dynamic    |             0 | Single     |
+----------+-------------+------+--------+-------+-------------+------------+---------------+------------+

insert undo

插入一条数据对应的undo操作其实就是根据主键删除这条数据就行了。所以 insert 对应的 undo log 主要是把这条记录的主键记录上。

INSERT 产生的 undo log 类型为 TRX_UNDO_INSERT_REC,大致结构如下图所示:

  • start、end:指向记录开始和结束的位置。
  • undo type:undo log 的类型,也就是 TRX_UNDO_INSERT_REC。
  • undo no:在当前事务中 undo log 的编号。
  • table id:表空间ID。
  • 主键列信息:这一块就需要记录INSERT这行数据的主键ID信息,或者唯一列信息。

MySQL事务原子性之UndoLog,图文结合带你直击核心

比如我们开启了一个事务,向 account 中插入两条数据:

BEGIN;

INSERT INTO account(id,card,balance) VALUES (1, 'AA', 0),(2, 'BB', 0);

假设这个事务的事务ID为100,这条INSERT语句会插入两条数据,就会产生两个 undo log。插入记录的时候,会在行记录的隐藏列事务ID中写入当前事务ID,并产生 undo log,记录中的回滚指针会保存 undo log 的地址。而同一个页中的多条记录会通过next_record连接起来形成一个单链表,这块可以参考前面的行记录格式和数据页结构相关的文章。

MySQL事务原子性之UndoLog,图文结合带你直击核心

delete undo

删除一条数据大致可以分为两个阶段:

  • 阶段一

首先是用户线程执行删除时,会先将记录头信息中的 delete_mask 标记为 1,而不是直接从页中删除,因为可能其它并发的事务还需要读取这条数据。(后面讲MVCC的时候就知道为什么了)

  • 阶段二

提交事务后,后台有一个 purge 线程会将数据真正删除。

首先要知道,页中的数据是通过记录头信息中的 netx_record 连接起来的单向链表(假设这个链表称为数据链表)。页中还有另一个链表,称为垃圾链表,记录真正删除后,会从数据链表中移除,然后加入到垃圾链表的头部,以便重用空间。

所以阶段二就是将记录从数据链表移除,加入到垃圾链表的头部。

也就是说,删除操作在事务提交前,只会经历阶段一,就是将记录的 delete_mask 标记为 1。

DELETE 对应的 undo log 类型为 TRX_UNDO_DEL_MARK_REC,它的结构大致如下图所示,与 TRX_UNDO_INSERT_REC 类型相比,主要多了三个部分:

  • old trx_id:这个属性会保存记录中的隐藏列trx_id,这个属性在MVCC并发读的时候就会起作用了。
  • old roll_pointer:这个属性保存记录中的隐藏列roll_pointer,这样就可以通过这个属性找到之前的 undo log。
  • 索引列信息:这部分主要是在第二阶段事务提交后用来真正删除记录的。

MySQL事务原子性之UndoLog,图文结合带你直击核心

此时接着执行一条删除的SQL语句,将id=2的这条数据删除:

BEGIN;

INSERT INTO account(id,card,balance) VALUES (1, 'AA', 0),(2, 'BB', 0);

DELETE FROM account WHERE id = 2;

因为是在同一个事务中,所以记录中的隐藏列trx_id没变,记录头中的delete_mask则标记为1了。然后生成了一个新的 undo log,并保存了记录中原本的trx_id和roll_pointer,所以这个新的 undo log 就指向了旧的 undo log,而记录中的 roll_pointer 只能指向这个新的 undo log。注意 undo log 中的事务编号也在递增。

MySQL事务原子性之UndoLog,图文结合带你直击核心

update undo

在执行UPDATE语句时,InnoDB对更新主键和不更新主键这两种情况有截然不同的处理方案,对应中两种不同的 undo log 类型。

不更新主键

在不更新主键的情况下,又可以细分为被更新的列占用的存储空间不发生变化和发生变化的情况。

  • 存储空间未发生变化

更新记录时,对于被更新的每个列来说,如果更新后的列和更新前的列占用的字节数都一样大,那么就可以进行就地更新,也就是直接在原记录的基础上修改对应列的值。

  • 存储空间发生变化

如果有任何一个被更新的列更新前和更新后占用的字节数大小不一致,那么就会先把这条旧的记录从聚簇索引页面中删除掉,然后再根据更新后列的值创建一条新的记录插入到页面中。注意这里的删除并不是将 delete_mask 标记为 1,而是真正的删除,从数据链表中移除加入到垃圾链表的头部。

如果新的记录占用的存储空间大小不超过旧记录占用的空间,就可以直接重用刚加入垃圾链表头部的那条旧记录所占用的空间,否就会在页面中新申请一段空间来使用。

不更新主键的这两种情况生成的 undo log 类型为 TRX_UNDO_UPD_EXIST_REC,大致结构如下图所示,与 TRX_UNDO_DEL_MARK_REC 相比主要是多了更新列的信息。

MySQL事务原子性之UndoLog,图文结合带你直击核心

假设此时更新id=1的这条数据,各列占用的字节大小都未变化:

BEGIN;

INSERT INTO account(id,card,balance) VALUES (1, 'AA', 0),(2, 'BB', 0);

DELETE FROM account WHERE id = 2;

UPDATE account SET card = 'CC' WHERE id = 1;

这条记录就会执行旧地更新,同样会产生一条新的 undo log,并指向原来的 undo log。

MySQL事务原子性之UndoLog,图文结合带你直击核心

更新主键

要知道记录是按主键大小连成一个单向链表的,如果更新了某条记录的主键值,这条记录的位置也将发生改变,也许就被更新到其它页中了。

这种情况下的更新分为两步:

  • 首先将原记录做标记删除,就是将 delete_mask 改为 1,还没有真正删除。
  • 然后再根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中。

所以这种情况下,会产生两条 undo log:

  • 第一步标记删除时会创建一条 TRX_UNDO_DEL_MARK_REC 类型的 undo log。
  • 第二步插入记录时会创建一条 TRX_UNDO_INSERT_REC 类型的 undo log。

这两种类型的结构前面已经说过了。

此时再将id=1的主键更新:

BEGIN;

INSERT INTO account(id,card,balance) VALUES (1, 'AA', 0),(2, 'BB', 0);

DELETE FROM account WHERE id = 2;

UPDATE account SET card = 'CC' WHERE id = 1;

UPDATE account SET id = 3 WHERE id = 1;

更新主键后,原本的记录就被标记删除了,然后新增了一个 TRX_UNDO_DEL_MARK_REC 的 undo log。接着插入了一条新的id=3的记录,并创建了一个新的 TRX_UNDO_INSERT_REC 类型的 undo log。

MySQL事务原子性之UndoLog,图文结合带你直击核心

undo log 回滚

前面在一个事务中增删改产生的一系列 undo log,都有 undo no 编号的。在回滚的时候,就可以应用这个事务中的 undo log,根据 undo no 从大到小开始进行撤销操作。

例如上面的例子如果最后回滚了:

  • 就会先执行第 5 号 undo log,删除 id=3 这条数据;
  • 接着第4号 undo log,取消标记删除,将 id=1 这条数据的 delete_mask 改为 0;
  • 接着第3号 undo log,将更新的列card='CC’还原为原来的card=‘AA’;
  • 接着第2号 undo log,取消标记删除,将 id=2 这条数据的 delete_mask 改为 0;
  • 接着第1号 undo log,删除 id=2 这条数据;
  • 接着第0号 undo log,删除 id=1 这条数据;

可以看到,回滚时通过执行 undo log 撤销,就将数据还原为原来的样子了。

但需要注意的是,undo log 是逻辑日志,只是将数据库逻辑地恢复到原来的样子。所有修改都被逻辑地取消了,但是数据结构和页本身在回滚之后可能大不相同。因为同时可能很多并发事务在对数据库进行修改,因此不能将一个页回滚到事务开始的样子,因为这样会影响其他事务正在进行的工作。

Undo Log 存储

undo log 分类

前边介绍了几种类型的 undo log,它们其实被分为两个大类来存储:

  • TRX_UNDO_INSERT

类型为 TRX_UNDO_INSERT_REC 的 undo log 属于此大类,一般由 INSERT 语句产生,或者在 UPDATE 更新主键的时候也会产生。

  • TRX_UNDO_UPDATE

除了类型为 TRX_UNDO_INSERT_REC 的 undo log,其他类型的 undo log 都属于这个大类,比如 TRX_UNDO_DEL_MARK_REC 、 TRX_UNDO_UPD_EXIST_REC ,一般由 DELETE、UPDATE 语句产生。

之所以要分成两个大类,是因为不同大类的 undo log 不能混着存储,因为类型为TRX_UNDO_INSERT_REC的 undo log 在事务提交后可以直接删除掉,而其他类型的 undo log 还需要提供MVCC功能,不能直接删除。

undo 页面链表

undo log 是存放在FIL_PAGE_UNDO_LOG类型的页中,一个事务中可能会产生很多 undo log,也许就需要申请多个undo页,所以 InnoDB 将其设计为一个链表的结构,将一个事务中的多个undo页连接起来。

但是前面说了 undo log 分为两大类,不能混着存储,所以如果事务中产生了这两大类型的 undo log,会创建两个链表,一个用来存储 TRX_UNDO_INSERT 类别的 undo log,一个用来存储 TRX_UNDO_UPDATE 类别的 undo log。

如果事务中还修改了临时表,InnoDB规定对普通表和临时表修改产生的 undo log 要分开存储,所以在一个事务中最多可能会有4个 undo 页面链表。

需要注意的是这些链表并不是事务一开始就分配好的,而是在需要某个类型的链表的时候才会去分配。

重用 undo 页

如果有多个并发事务执行,为了提高 undo log 的写入效率,不同事务执行过程中产生的 undo log 会被写入到不同的 undo 页面链表中。也就是说一个事务最多可能单独分配4个链表,两个事务可能就8个链表。

但其实大部分事务都是一些短事务,产生的 undo log 很少,这些 undo log 只会占用一个页少量的存储空间,这样就会很浪费。于是 InnoDB 设计在事务提交后,在某些情况下可以重用这个事务的 undo 页面链表。

undo 链表可以被重用的条件:

  • 在 undo 页面链表中只包含一个 undo 页面时,该链表才可以被下一个事务所重用。因为如果一个事务产生了很多 undo log,这个链表就可能有多个页面,而新事务可能只使用这个链表很少的一部分空间,这样就会造成浪费。
  • 然后该 undo 页面已经使用的空间小于整个页面空间的 3/4时才可以被重用。

对于TRX_UNDO_INSERT类型的 insert undo 页面链表,这些 undo log 在事务提交之后就没用了,可以被清除掉。所以在某个事务提交后,重用这个链表时,可以直接覆盖掉之前的 undo log。

对于TRX_UNDO_UPDATE类型的 update undo 页面链表,这些 undo log 在事务提交后,不能立即删除掉,因为要用于MVCC。所以重用这个链表时,只能在后面追加 undo log,也就是一个页中可能写入多组 undo log。

回滚段

redo log 是存放在重做日志文件中的,而 undo log 默认是存放在系统表空间中的一个特殊段(segment)中,这个段称为回滚段(Rollback Segment),链表中的页面都是从这个回滚段里边申请的。

为了更好地管理系统中的 undo 页面链表,InnoDB 设计了一个 Rollback Segment Header 的页面,每个Rollback Segment Header页面都对应着一个Rollback Segment。一个 Rollback Segment Header 页面中包含1024个undo slot,每个 undo slot 存放了 undo 链表头部的 undo 页的页号。

一个 Rollback Segment Header 只有 1024 个 undo slot,假设一个事务中只分配了1个undo链表,那最多也只能支持1024个并发事务同时执行,在现今高并发情况下,这显然是不够的。

所以InnoDB定义了128个回滚段,也就有128个 Rollback Segment Header,就有128*1024=131072个undo slot,也就是说最多同时支持131072个并发事务执行。

在系统表空间的第5号页面中存储了这128个Rollback Segment Header页面地址。

可以通过如下几个参数对回滚段做配置:

  • innodb_undo_directory:undo log 默认存放在系统表空间中,也可以配置为独立表空间。可以通过这个参数设置独立表空间的目录,默认是数据目录。
  • innodb_undo_logs:设置回滚段的数量,默认是128。但需要注意的是,针对临时表的回滚段数量固定为32个,那么针对普通表的回滚段数量就是这个参数值减去32,如果设置小于32的值,就只有1个针对普通表的回滚段。
  • innodb_undo_tablespaces:设置undo表空间文件的数量,这样回滚段可以较为平均地分布到多个文件中。该参数默认为0,表示不创建undo独立表空间。
mysql> SHOW VARIABLES LIKE 'innodb_undo%';
+--------------------------+-------+
| Variable_name            | Value |
+--------------------------+-------+
| innodb_undo_directory    | .\    |
| innodb_undo_logs         | 128   |
| innodb_undo_tablespaces  | 0     |
+--------------------------+-------+

恢复 undo

undo log 写入 undo 页后,这个页就变成脏页了,也会加入 Flush 链表中,然后在某个时机刷到磁盘中。

事务提交时会将 undo log 放入一个链表中,是否可以最终删除 undo log 及 undo log 所在页,是由后台的一个 purge 线程来完成的。

最后也是最为重要的一点是,undo log 写入 undo 页的时候也会产生 redo log,因为 undo log 也需要持久性的保护。

这里其实要说的的是前面 redo log 未解决的一个问题。

还是这张T1、T2并发事务地图,在图中箭头处,如果T1事务执行完成提交事务,此时 redo log 就会刷盘。而T2事务还未执行完成,但它的 mtr_T2_1 已经刷入磁盘了。如果此时数据库宕机了,T2事务实际上是执行失败的。在重启数据库后,就会读取 mtr_T2_1 来恢复数据,而T2事务实际是未完成的,所以这里恢复数据就会导致数据有问题。

MySQL事务原子性之UndoLog,图文结合带你直击核心

所以这时 undo log 就派上用场了,redo log 恢复时,同样会对 undo 页重做,mtr_T2_1 这段 redo log 对数据页重做后,由于T2事务未提交,就会用 undo log 撤销这些操作。就解决了这个问题。

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值