MySQL之MVCC与Undo Log
1、 Undo Log
1.1、undo log应用场景
undo log的应用主要有两方面:
- 事务rollback。MySQL崩溃恢复,事务死锁回滚等需要使用事务rollback功能的场景。MySQL突然crash后可以通过undo log 回滚未提交的事务和重新commit正在提交的事务。为了保证事务原子性,直观的想法是等到事务真正提交时,才能允许这个事务的任何修改落盘,也就是No-Steal策略。显而易见,这种做法一方面造成很大的内存空间压力,另一方面提交时的大量随机IO会极大的影响性能。因此,数据库实现中通常会在正常事务进行中,就不断的连续写入Undo Log,来记录本次修改之前的历史值。当Crash真正发生时,可以在Recovery过程中通过回放Undo Log将未提交事务的修改抹掉。另外,Undo Log日志本身也像其他的数据库数据一样,会写自己对应的Redo Log,通过Redo Log来保证自己的原子性。
- MVCC,满足事务的隔离性和读写并发(读不加锁,读写不冲突)
事务只能修改索引中的行记录,不能修改undo log,也不能对undo log加锁。
1.2、undo log格式
事务每次准备修改聚簇索引叶子结点中的行记录之前,需要构造undo record保存行记录的旧值,在undo record中存储需要更新的列的旧值,设置 undo_record.DATA_TRX_ID = 行记录原有的DATA_TRX_ID,undo_record.DATA_ROLL_PTR = 行记录原有的DATA_ROLL_PTR,然后更新行记录的DATA_TRX_ID为自己的trx_id(事务ID,事务开始的逻辑时间),更新行记录的DATA_ROLL_PTR为新构造的undo record,更新行记录的列值。最后将该undo record保存到该事务的undo log中。
1.2.1、Insert类型的undo record
不同于Update类型的Undo Record,Insert 类型的Undo Record仅仅是为了事务回滚准备的,并不在MVCC中承担作用,事务commit或rollback后就会被删除和清理。需要Insert 类型的Undo Record的原因是:通过对该事务undo log中所有insert undo record做逆向(Delete)操作即可完成回滚,不用为了查找哪些数据需要回滚而扫描大量的行记录和undo record。因此只需要记录对应行记录的主键,供回滚时查找行记录位置即可。
在聚簇索引中插入数据同时也会在二级索引中插入数据
其中Undo Number表示事务的第几条undo record,Table ID表示是哪张表的修改。下面一组Key Fields用于记录主键,长度不定,因为对应表的主键可能由多个field组成,通过这里记录的完整的主键信息,回滚的时候可以在聚簇索引中快速定位到对应的行记录。除此之外,在Undo Record的头尾还各留了两个字节用户记录其前序和后继Undo Record的位置。
insert类型的undo record没有DATA_ROLL_PTR和DATA_TRX_ID字段
1.2.2、Update类型的Undo Record
除了跟Insert Undo Record相同的头尾信息,以及主键Key Fileds之外,Update Undo Record增加了:
1、Transaction Id是上一次修改行记录的事务Id,注意不是本次修改行记录的事务Id,一个事务的undo record中存储的Transaction Id都不会是自己的Transaction Id。用作后续MVCC中的版本可见性判断
2、Rollptr指向该记录的上一个历史版本的位置,沿着Rollptr可以找到一个行记录的所有历史版本。
3、Update Fields中记录的是当前这个undo record的增量更新信息,包括所有被修改的Field的编号,长度和历史值。如果要恢复到某个历史版本,需要基于最新的行记录,根据Rollptr遍历undo record一个一个的恢复
总共有3种类型的update record:
-
TRX_UNDO_DEL_MARK_REC
标记删除行记录,未修改任何列值(Update Fields为空),这可能是由于普通的删除操作产生的,也可能是修改聚簇索引的主键导致的,修改聚簇索引的主键其实是两个操作:delete + insert ,产生两条undo record(但binlog只会有一条记录),一条是TRX_UNDO_DEL_MARK_REC类型的update record,一条是insert record,这两条undo record的记录的主键不一样,这其实保证了RollPtr指针连接的undo record链表都是针对相同主键行记录的历史版本。 -
TRX_UNDO_UPD_DEL_REC
更新一个已经被标记删除的行记录,列值可被修改。在innoDB中,删除某个行记录其实是软删(为了加快rollback,MVCC也需要读旧版本),如果在purge线程清理之前重新插入一个相同主键的行记录,可以重用这部分空间。 -
TRX_UNDO_UPD_EXIST_REC
更新一个未被标记删除的记录(普通更新)
由于二级索引没有undo log,二级索引行记录不能像聚簇索引一样进行in-place更新,每次聚簇索引行记录更新时,先软删对应的二级索引行记录,再插入新的行记录(因此对于一个主键,二级索引可能有多个deleted_bit为1的记录(这些可以认为是二级索引行记录的历史版本),但最多只有一个deleted_bit为0的记录),然后如果当前事务ID大于二级索引记录所在页的Page Header中的PAGE_MAX_TRX_ID,更新为当前事务ID,PAGE_MAX_TRX_ID会在MVCC中使用
1.2.3、一个事务的undo log
事务每修改一个行记录就会产生一个undo record,修改多个行记录时会产生一组undo record,这些undo record是放在一起作为这个事务的undo log,最终存到数据库的undo segment(回滚段)中的。一个事务如果需要回滚,会回滚其undo log中的所有undo record。
Undo Log Header中记录了产生这个Undo Log的事务的Trx ID;Trx No是事务提交时获得的编号,代表事务结束的逻辑时间,这个会用来判断这个undo log是否能Purge;Delete Mark标明该Undo Log中有没有TRX_UNDO_DEL_MARK_REC类型的Undo Record,避免Purge时不必要的扫描;Log Start Offset中记录Undo Log Header的结束位置,方便之后Header中增加内容时的兼容;之后是一些Flag信息;Next Undo Log及Prev Undo Log标记前后两个Undo Log,这个会在接下来介绍;最后通过History List Node将自己挂载到为Purge准备的History List中。
聚簇索引中的行记录先后被不同事务修改,会产生多个的历史版本(undo record),这些历史版本通过Rollptr穿成一个链表,供MVCC使用。这些undo record分别存储在自己事务的undo log中。示例中有三个事务操作了表t上,主键id是1的记录。首先事务I插入了这条记录并且设置filed a的值是A,之后事务J和事务K分别将这条id为1的记录中的filed a的值修改为了B和C。事务I,J,K分别有自己的Undo Log。沿着聚簇索引中的这条行记录的Rollptr指针可以依次找到这三个事务Undo Log中关于这条记录的历史增量修改信息。同时可以看出,Insert类型Undo Record中只记录了对应的主键值:id=1,而Update类型的Undo Record中除了记录主键外,还记录了对应的历史版本的生成事务Trx_id,以及被修改的field a的历史值。
通过这个示例可以发现,聚簇索引中的的RollPtr指向的record是事务K的undo record(记为K_r),K_r存储在事务K的undo log中,K_r的RollPtr指向的是事务J的undo record,事务J先于事务K修改id=1的行记录(也说明事务J先于事务K提交)。record.rollPtr指向的一定是record.trxId的undo record,这个undo record存储在record.trxId的undo log中
1.3、Undo Log for Rollback
InnoDB中的事务可能会由用户主动触发Rollback,也可能因为遇到死锁异常Rollback,或者发生Crash。发生crash,根据最新的redo log就可以把undo log恢复到之前的状态,然后对未提交的事务回滚。在Undo层面来看,这些回滚的操作是一致的,基本的过程就是从该事务的Undo Log中,从后向前依次读取Undo Record,并根据其中内容做逆向操作;主要分为下面这么几类:1、对于标记删除(delete操作),清理聚簇索引和二级索引的Delete Mark标记。2、对于in-place更新,将聚簇索引和二级索引中的行记录修改回之前的值。3、对于插入操作,删除聚集索引记录和二级索引记录。
2、ReadView
2.1、ReadView结构
// trx_sys->serialisation_list: 里面存放的是正在提交的事务,按照trx_no升序排列,写事务commit时会分配一个递增的编号trx_no作为事务的提交序号,代表事务结束的逻辑时间
class ReadView {
trx_id_t m_low_limit_id; // 已分配的事务id最大值+1,事务id>=m_low_limit_id的修改对于当前读不可见
trx_id_t m_up_limit_id ; // 创建ReadView时活跃事务(不包括自己的事务)中的最小事务id,事务id<m_up_limit_id的修改对于当前读可见,如果活跃链表为空,设置m_up_limit_id = m_low_limit_id
trx_id_t m_creator_trx_id; // 当前事务的id,同一个事务中的修改总是可见
ids_t m_ids; // 创建ReadView时活跃状态事务的事务id数组,m_up_limit_id <= 事务id < m_low_limit_id,且位于m_ids中的事务不可见
trx_id_t m_low_limit_no; // 取 trx_sys->serialisation_list中事务的最小的trx_no(如果serialisation_list为空,取系统当前的trx_no),trx_no小于m_low_limit_no的事务,对于这个ReadView来说已经提交了,其undo log已经不需要了
};
Repeatable Read下READ VIEW的原理:创建READ VIEW时,已经提交的事务都是可见的,已经开始但未提交和未开始的事务在事务结束前都是不可见的,但自己创建和修改过的数据是可见的。
一个事务最开始默认是只读事务,没有分配trx_id,不会进入活跃事务链表,只有出现DML或者select for update时才会转变为读写事务,分配一个递增的trx_id,加入活跃状态链表。另外max_trx_id会持久化存储,重启也不会重置为0
2.2、ReadView创建的时机
在可重复读下,如果启动事务使用的是start transaction with consistent snapshot,会立即创建ReadView。在其他隔离级别下,start transaction with consistent snapshot 等效于 start transaction。
如果启动事务使用的是start transaction:
- 事务隔离级别是 Repeatable Read时,事务第一次进行一致性非锁定读时会构建一个ReadView,直到事务结束一直使用这个ReadView。由于Repeatable Read下的ReadView会一直持有直到事务commit,会出现一个长的只读事务阻碍大量写事务的undo log的清理。
- 事务隔离级别是 Read Committed时,事务每进行一致性非锁定读时便会重新构建一个ReadView,因此事务中每次SELECT可以看到其它已commit事务所作的更改。
2.3、ReadView可见性判断算法
通过ReadView实现了类似快照的功能,快照其实是具有数据的一致性的。比如有一张个人存款表,一个用户发起转账操作,转账操作包括两个步骤,先将自己账户的余额减少,再将对方账户的余额增加,这两个步骤是在一个事务中。如果另一个事务B通过执行全表扫描来校验所有人的存款和,通过ReadView,不管事务B在什么时候执行,执行过程有多久,算出的存款和与以前相比都是一致的。
2.3.1、聚簇索引的行记录可见性判断算法
Read view创建之后,取出聚簇索引中符合where条件的行记录的DATA_TRX_ID字段(如果delete_bit为1,也需要取出来),利用DATA_TRX_ID和Read View进行可见性判断:
- 如果trx_id == m_creator_trx_id,该行记录可见;
- 如果 trx_id < m_up_limit_id,该行记录可见;
- 如果trx_id >= m_low_limit_id,该行记录不可见;
- 否则 trx_id 落在 [m_up_limit_id, m_low_limit_id) 范围内,需要在m_ids数组中二分查找,如果m_ids中存在trx_id,该行记录不可见。如果m_ids中不存在trx_id,该行记录可见。
对于可见的行记录,如果delete_bit为0,展示在最终结果中,如果delete_bit为1,不展示在最终结果中;
对于不可见的行记录,需要通过行记录的DATA_ROLL_PTR不断还原,每还原出历史一个版本,用该版本的DATA_TRX_ID通过ReadView进行上述可见性判断,如果该版本不可见,继续通过该版本的DATA_ROLL_PTR还原,最终还原出一个可见版本(由于insert类型的undo record在事务提交后就被清理了,这会导致一种情况:当前还原出来的历史版本不可见,但其DATA_ROLL_PTR为NULL,无法继续还原了,此时可以认为该行记录对于这个ReadView不存在,因为如果找到的undo record是insert类型,说明这条行记录是在这个ReadView创建后插入的,而且insert类型的undo record没有DATA_ROLL_PTR,一定是undo 链表的尾结点,无法继续还原),如果还原出来的可见版本的delete_bit为0,展示在最终结果中,如果delete_bit为1,不展示在最终结果中;
如下图所示,事务R开始需要查询表t上的id为1的记录,R开始时事务I已经提交,事务J还在运行,事务K还没开始,这些信息都被记录在了事务R的ReadView中。事务R从索引中找到对应的这条行记录[1, C],对应的trx_id是K,不可见。沿着Rollptr恢复出这个行记录的前一版本[1, B],对应的trx_id是J,仍然不可见。继续沿着Rollptr恢复出[1, A],trx_id是I,可见,返回这个历史版本。

2.3.2、二级索引的行记录可见性判断算法
由于InnoDB的二级索引的行记录中没有DATA_TRX_ID字段,只在PAGE HEADER中的PAGE_MAX_TRX_ID字段保存更新该页的最大的trx_id,当查询二级索引的时候,如果page的 PAGE_MAX_TRX_ID < m_up_limit_id,说明整个page中的行记录都是可见的,选择可见的行记录中delete_bit为0和符合where条件的行记录进行下一步操作。 如果page的 PAGE_MAX_TRX_ID >= m_up_limit_id,则无法判断page中的行记录是否可见,取出符合where条件的行记录(如果delete_bit为1,也需要取出来,对于一个主键,二级索引可能有多个deleted_bit为1的记录(这些可以认为是二级索引行记录的历史版本,如果二级索引所有历史版本都不满足where条件,那说明即便去聚簇索引也无法恢复出符合where条件的历史版本),但最多只有一个deleted_bit为0的记录),然后根据主键回表取出行记录的trx_id再进行上述判断,此时覆盖索引不能生效。
2.4、undo log的清理
2.4.1 undo log清理条件
如果事务A(事务Id为trx_id_a)的trx_no小于readView->m_low_limit_no,那事务A对于这个readView来说已经提交了,也就是事务A的trx_id_a通过该readView作可见性判断一定是可见的,这样如果某个行记录或者undo record中的trx_id是trx_id_a,那当前还原出来的版本就是可见的,不会再通过其RollPtr继续恢复历史版本了,而其RollPtr指向的undo record一定是事务A的undo log,说明事务A的undo log不会再被这个ReadView使用了。
因此,如果一个事务的trx_no小于当前所有活跃的Readview中的m_low_limit_no(其实只需要小于最早创建的ReadView的m_low_limit_no),说明这个事务在所有的读开始之前已经提交了,其修改的版本对所有读事务是可见的, 因此不再需要通过这个事务的undo log构建之前的版本,这个事务的Undo Log也就可以被清理了。如下图所示,由于ReadView List中最老的ReadView在创建时,Transaction J就已经Commit,因此Index中行记录的第一个Undo历史版本可以满足所有的读事务,不需要更老的Undo,因此整个Transaction J的Undo Log都可以清理了。
因此,一个事务的undo log被清理的条件是:
- 需要commit
- 对于最早创建的ReadView来说是已经提交的(感觉ReadView中其实不需要m_low_limit_no这个参数,直接用已提交事务的trx_id和最早ReadView进行可见性判断即可)
考虑这种情况:事务A有很多修改,在事务A提交前,事务B创建并且进行了一次非锁定读,此时事务B创建的ReadView会认为事务A是未提交的,之后事务A即便commit了,只要事务B不commit,那事务B的ReadView就不会释放,事务A的undo log就一直不能清理,所以会出现一个长的只读事务会阻碍大量写事务的undo log的清理。当然,这也会导致这个长事务的select越来越慢,因为需要回溯的undo log很多,特别是select count(*) 需要对每行判断可见性
2.4.2 清理流程
清理是通过后台的异步线程完成的,就是按照trx_no从小到大,依次遍历每个事务的Undo Log进行清理
清理工作会分为两个过程:
- Undo Record本身从旧到新的删除,称为Undo Truncate。
- Undo Record对应的聚簇索引和二级索引行记录的真正删除,称为Undo Purge。
2.4.3 Undo Purge
- 对于TRX_UNDO_DEL_MARK_REC类型的Undo Record,需要真正的删除聚簇索引和二级索引上被标记删除的行记录。如果聚簇索引行记录的deleted_bit为1,那其RollPtr指向的undo record的类型一定是TRX_UNDO_DEL_MARK_REC,如果这个undo record可以被清理,这说明这个行记录也没有存在的必要了,如果这个undo record还不能被清理,那这个deleted_bit为1的行记录也不能被清理,因为恢复历史版本需要基于最新的行记录。
- TRX_UNDO_UPD_EXIST_REC类型的Undo表示聚集索引发生in-place更新,虽然不涉及聚簇索引的删除,但需要做二级索引的删除,因为二级索引的更新总是标记删除 + 插入
- TRX_UNDO_UPD_DEL_REC类型表示insert时重用了之前软删的行记录的空间,这种类型的undo不需要清理聚簇索引和二级索引中的行记录
3、MVCC示例
3.1、读写事务无法保证可重复读
将行记录拆分成列的维度来看,id = 1的行记录中列name的值两次select发生了变化,但自己没有修过列name。

3.2 读写事务的幻读现象
3.3 MySQL官方的幻读示例
The snapshot of the database state applies to SELECT statements within a transaction, not necessarily to DML statements. If you insert or modify some rows and then commit that transaction, a DELETE or UPDATE statement issued from another concurrent REPEATABLE READ transaction could affect those just-committed rows, even though the session could not query them. If a transaction does update or delete rows committed by a different transaction, those changes do become visible to the current transaction. For example, you might encounter a situation like the following:
https://dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.html
-- 隔离级别:可重复读
-- 示例1
SELECT COUNT(c1) FROM t1 WHERE c1 = 'xyz';
-- Returns 0: no rows match. select时没有数据
DELETE FROM t1 WHERE c1 = 'xyz';
-- Deletes several rows recently committed by other transaction. 删除的时候却发现删除了数据
-- 示例2
SELECT COUNT(c2) FROM t1 WHERE c2 = 'abc';
-- Returns 0: no rows match. 第一次 select 时没有数据
UPDATE t1 SET c2 = 'cba' WHERE c2 = 'abc';
-- Affects 10 rows: another txn just committed 10 rows with 'abc' values. 更新的时候却发现更新了数据
SELECT COUNT(c2) FROM t1 WHERE c2 = 'cba';
-- Returns 10: this txn can now see the rows it just updated. 第二次select能看到之前自己更新的数据
3.4 MySQL update时,如果字段修改后的值和原来的值一样,MySQL真的会更新吗?
update 语句的执行流程是MySQL server层先通过 select ... for update (锁定读,加锁) 读出数据,然后判断当前数据和更新后的数据是否相同,如果不相同才真正调用innoDB的接口更新(innoDB引擎不会判断是否相同),会有binlog,否则不更新(不会有binlog)。
MySQL 5.6以后,MySQL server层读数据时会设置需要的字段,而binlog的格式会影响MySQL Server读出的字段,对于binlog_format=statement格式,MySQL Server层读出的行数据中只有主键,因此MySQL server层无法判断是否相同,都会更新,会产生binlog
mysql> select * from table_int3;
+----+-----+------+
| id | c1 | c2 |
+----+-----+------+
| 1 | 10 | 100 |
| 7 | 70 | 700 |
| 13 | 130 | 1300 |
| 20 | 200 | 2000 |
+----+-----+------+
4 rows in set (0.00 sec)
可以通过MVCC验证
| 事务A | 事务B |
|---|---|
| begin; select * from table_int3 where id = 7 ; // 返回 c1 = 70 |
binlog_format=statement |
| begin; update table_int3 set c1 = 71 where id = 7; commit; |
|
| update table_int3 set c1 = 71 where id = 7; // Query OK, 0 rows affected (0.00 sec) // Rows matched: 1 Changed: 0 Warnings: 0; |
|
| select * from table_int3 where id = 7 ; // 返回 c1 = 71 |
注意:即便是binlog_format=statement,如果表中有时间戳字段(timestamp, datetime)且设置为 on update current_timestamp 自动更新的,那第一步select时会读出需要修改的字段做判断,此时不会更新
如果是binlog_format=row 并且binlog_row_image=FULL,由于MySQL需要在binlog里面记录所有的字段,所以在读数据时会把所有字段都读出来,这时MySQL会发现修改后的值和当前值一样,因此不会更新,不会产生binlog。
但如果binlog_row_image = MINIMAL,则只会记录必要的信息,可能也会更新。
| 事务A | 事务B |
|---|---|
| begin; select * from table_int3 where id = 7 ; // 返回 c1 = 70 |
binlog_format=row, binlog_row_image=FULL |
| begin; update table_int3 set c1 = 71 where id = 7; commit; |
|
| update table_int3 set c1 = 71 where id = 7; // Query OK, 0 rows affected (0.00 sec) // Rows matched: 1 Changed: 0 Warnings: 0; |
|
| select * from table_int3 where id = 7 ; // 返回 c1 = 70 |
4、innoDB中为什么两个未提交的事务修改同一行数据时需要相互阻塞?,有什么优化方法可以避免吗
4.1 innoDB中为什么两个未提交的事务修改同一行数据时需要相互阻塞?
首先这违背了事务隔离级别中提到的丢失更新问题。
如果不相互阻塞。设事务A先修改,事务B后修改(事务B居然是在一个未提交事务修改后的值基础上进行修改的,从语义上说不通),且都未提交,此时行记录通过rollPtr构建的undo record链表关系是:行记录 -> trxB -> trxA。由于undo record中记录的是每个事务修改前看到的行记录中部分列的旧值:
1、如果事务A rollback,此时不能删除事务A的undo record(如果删除,事务B再回滚时行记录无法回到初始状态)。
2、如果事务A先commit,即便此时事务B没有提交,事务A的undo record可以删除。
3、如果事务A没有commit,那事务B也无法commit(如果此时允许事务B先commit,那最新的行记录中可能有事务A修改后的值,事务B commit时会把事务A修改后的值也一起commit了,如果事务A后面再rollback,那需要将行记录中事务A修改过而事务B未修改过的列的值恢复成事务A修改前的值,这会导致事务B发现自己之前commit过的行记录的值后面发生了变化)
参考:
http://mysql.taobao.org/monthly/2021/12/02/
http://mysql.taobao.org/monthly/2018/03/01/
http://mysql.taobao.org/monthly/2015/04/01/
https://jinglingwang.cn/archives/mysqlundolog
https://www.cnblogs.com/micrari/p/8144339.html

浙公网安备 33010602011771号