赞
踩
事务的第一个特性就是原子性,原子性就是要保证一个事务中的增删改操作要么都成功,要么都不做。这时就需要 undo log,在对数据库进行修改前,会先记录对应的 undo log,然后在事务失败或回滚的时候,就可以用这些 undo log 来将数据回滚到修改之前的样子。
下面先简单介绍下事务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行记录格式这篇文章中,我们了解到行记录中会有三个隐藏列:
每对一条记录做一次改动,就会产生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 |
- +----------+-------------+------+--------+-------+-------------+------------+---------------+------------+
插入一条数据对应的undo操作其实就是根据主键删除这条数据就行了。所以 insert 对应的 undo log 主要是把这条记录的主键记录上。
INSERT 产生的 undo log 类型为 TRX_UNDO_INSERT_REC,大致结构如下图所示:
比如我们开启了一个事务,向 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连接起来形成一个单链表,这块可以参考前面的行记录格式和数据页结构相关的文章。
删除一条数据大致可以分为两个阶段:
首先是用户线程执行删除时,会先将记录头信息中的 delete_mask 标记为 1,而不是直接从页中删除,因为可能其它并发的事务还需要读取这条数据。(后面讲MVCC的时候就知道为什么了)
提交事务后,后台有一个 purge 线程会将数据真正删除。
首先要知道,页中的数据是通过记录头信息中的 netx_record 连接起来的单向链表(假设这个链表称为数据链表)。页中还有另一个链表,称为垃圾链表,记录真正删除后,会从数据链表中移除,然后加入到垃圾链表的头部,以便重用空间。
所以阶段二就是将记录从数据链表移除,加入到垃圾链表的头部。
也就是说,删除操作在事务提交前,只会经历阶段一,就是将记录的 delete_mask 标记为 1。
DELETE 对应的 undo log 类型为 TRX_UNDO_DEL_MARK_REC,它的结构大致如下图所示,与 TRX_UNDO_INSERT_REC 类型相比,主要多了三个部分:
此时接着执行一条删除的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 中的事务编号也在递增。
在执行UPDATE语句时,InnoDB对更新主键和不更新主键这两种情况有截然不同的处理方案,对应中两种不同的 undo log 类型。
在不更新主键的情况下,又可以细分为被更新的列占用的存储空间不发生变化和发生变化的情况。
更新记录时,对于被更新的每个列来说,如果更新后的列和更新前的列占用的字节数都一样大,那么就可以进行就地更新,也就是直接在原记录的基础上修改对应列的值。
如果有任何一个被更新的列更新前和更新后占用的字节数大小不一致,那么就会先把这条旧的记录从聚簇索引页面中删除掉,然后再根据更新后列的值创建一条新的记录插入到页面中。注意这里的删除并不是将 delete_mask 标记为 1,而是真正的删除,从数据链表中移除加入到垃圾链表的头部。
如果新的记录占用的存储空间大小不超过旧记录占用的空间,就可以直接重用刚加入垃圾链表头部的那条旧记录所占用的空间,否就会在页面中新申请一段空间来使用。
不更新主键的这两种情况生成的 undo log 类型为 TRX_UNDO_UPD_EXIST_REC,大致结构如下图所示,与 TRX_UNDO_DEL_MARK_REC 相比主要是多了更新列的信息。
假设此时更新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。
要知道记录是按主键大小连成一个单向链表的,如果更新了某条记录的主键值,这条记录的位置也将发生改变,也许就被更新到其它页中了。
这种情况下的更新分为两步:
所以这种情况下,会产生两条 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。
前面在一个事务中增删改产生的一系列 undo log,都有 undo no 编号的。在回滚的时候,就可以应用这个事务中的 undo log,根据 undo no 从大到小开始进行撤销操作。
例如上面的例子如果最后回滚了:
可以看到,回滚时通过执行 undo log 撤销,就将数据还原为原来的样子了。
但需要注意的是,undo log 是逻辑日志,只是将数据库逻辑地恢复到原来的样子。所有修改都被逻辑地取消了,但是数据结构和页本身在回滚之后可能大不相同。因为同时可能很多并发事务在对数据库进行修改,因此不能将一个页回滚到事务开始的样子,因为这样会影响其他事务正在进行的工作。
前边介绍了几种类型的 undo log,它们其实被分为两个大类来存储:
类型为 TRX_UNDO_INSERT_REC 的 undo log 属于此大类,一般由 INSERT 语句产生,或者在 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 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 log 的写入效率,不同事务执行过程中产生的 undo log 会被写入到不同的 undo 页面链表中。也就是说一个事务最多可能单独分配4个链表,两个事务可能就8个链表。
但其实大部分事务都是一些短事务,产生的 undo log 很少,这些 undo log 只会占用一个页少量的存储空间,这样就会很浪费。于是 InnoDB 设计在事务提交后,在某些情况下可以重用这个事务的 undo 页面链表。
undo 链表可以被重用的条件:
对于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页面地址。
可以通过如下几个参数对回滚段做配置:
- mysql> SHOW VARIABLES LIKE 'innodb_undo%';
- +--------------------------+-------+
- | Variable_name | Value |
- +--------------------------+-------+
- | innodb_undo_directory | .\ |
- | innodb_undo_logs | 128 |
- | innodb_undo_tablespaces | 0 |
- +--------------------------+-------+
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事务实际是未完成的,所以这里恢复数据就会导致数据有问题。
所以这时 undo log 就派上用场了,redo log 恢复时,同样会对 undo 页重做,mtr_T2_1 这段 redo log 对数据页重做后,由于T2事务未提交,就会用 undo log 撤销这些操作。就解决了这个问题。
转载于:
https://juejin.cn/post/6977166688357711886#heading-0
赞
踩
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。