MySQL 的 MVCC 是怎么配合 undo log 实现事务隔离的?
通过两个会话实验演示 InnoDB 行记录的 DB_TRX_ID、DB_ROLL_PTR 串起 undo log 版本链,并由 ReadView 判断快照读可见版本,从而在 REPEATABLE-READ 下复现旧值可读、最新提交值不可见的
学完这篇你能亲手在两个 MySQL 会话里复现 MVCC 的快照读效果,并说清 undo log 的版本链和 ReadView 到底谁决定了你能看到哪一版数据。
第一步:确认版本和隔离级别,把实验环境搭起来
这一步要拿到两个信息:MySQL 版本和当前隔离级别,然后建一张只有两个字段的表当实验对象。
登录 MySQL 8.0 命令行后执行:
SELECT VERSION();
SELECT @@transaction_isolation;
CREATE TABLE account (
id INT PRIMARY KEY,
balance INT NOT NULL
) ENGINE=InnoDB;
INSERT INTO account VALUES (1, 1000);
结果说明:@@transaction_isolation 默认是 REPEATABLE-READ(MySQL 8.0 的默认值),后面所有现象都基于这个默认值。
注意:MVCC 只对 InnoDB 生效,建表时必须写
ENGINE=InnoDB;MyISAM 没有 undo log,也没有事务。
第二步:认识每行记录自带的三个隐藏字段
这一步要理解 InnoDB 在行里额外存了什么,它是整条版本链的入口。
InnoDB 每行记录除了你定义的字段,还隐式加了:
DB_TRX_ID:最后修改这行的事务 ID,6 字节DB_ROLL_PTR:回滚指针,指向 undo log 里这条记录的上一版本,7 字节DB_ROW_ID:只在表没有主键、也没有唯一非空索引时才有
所以 balance=1000 这一行实际结构是“1000 + 一个事务 ID + 一个指向 undo 的指针”。每被改一次,旧值就被拷进 undo log,新行的 DB_ROLL_PTR 指向上一个版本,形成一条从新到旧的链表。
注意:
DB_TRX_ID和DB_ROLL_PTR不能直接SELECT,只能通过行为推断,别在客户端里试SELECT DB_TRX_ID FROM account。
第三步:开两个会话,把版本链造出来
这一步要让同一行数据产生多个历史版本,并观察事务 ID 的变化。
会话 A(窗口 1):
SET autocommit = 0;
SELECT balance FROM account WHERE id = 1; -- 1000
会话 B(窗口 2):
SET autocommit = 0;
UPDATE account SET balance = 900 WHERE id = 1;
COMMIT;
回到 A 再查一次:
SELECT balance FROM account WHERE id = 1; -- 仍然是 1000
此时磁盘上最新行已经是 900,undo log 里躺着 1000 的旧版本,但 A 看到的还是 1000。这就是快照读。
想看当前活跃事务的编号,在任一窗口执行:
SELECT trx_id, trx_state, trx_isolation_level, trx_started
FROM information_schema.INNODB_TRX;
结果里的 trx_id 就是写进 DB_TRX_ID 的那个值。再执行 SHOW ENGINE INNODB STATUS\G,找 TRANSACTIONS 段,能看到类似 Trx read view will not see trx with id >= 1234 的一行,这就是 A 手里那个 ReadView 的可见性边界。
第四步:弄懂 ReadView 的四个字段和判断规则
这一步要搞清“为什么 900 不可见”,判断过程就四步。
ReadView 在生成瞬间记录四样东西:
m_ids:当时所有活跃(未提交)事务的 ID 列表min_trx_id:m_ids里最小的那个max_trx_id:系统下一个要分配的事务 ID(不是m_ids的最大值)creator_trx_id:生成这个 ReadView 的事务自己的 ID
拿到一行数据后,用它的 DB_TRX_ID 依次判断:
- 等于
creator_trx_id→ 自己改的,可见 - 小于
min_trx_id→ 修改它的事务早在快照前就提交了,可见 - 大于等于
max_trx_id→ 快照之后才开启的事务,不可见 - 落在
min_trx_id和max_trx_id之间:在m_ids里说明还没提交,不可见;不在m_ids里说明已提交,可见
不可见时,就顺着 DB_ROLL_PTR 跳到 undo log 里的上一版本,拿上一版本的 DB_TRX_ID 再判一遍,直到找到可见版本或链表走完(走完说明这行在快照时还不存在,返回空)。
第五步:对比 RC 和 RR,差别只有一行代码
这一步要验证两种隔离级别唯一的实质区别:ReadView 什么时候生成。
-- 会话 A
SET SESSION transaction_isolation = 'READ-COMMITTED';
SET autocommit = 0;
SELECT balance FROM account WHERE id = 1;
会话 B 更新为 800 并提交后,A 再查会看到 800。因为 RC 下每次快照读都重新生成一个 ReadView,新快照自然包含了 B 的提交。
切回默认的 RR,整个事务只复用第一次 SELECT 时生成的那一个 ReadView,所以从头到尾都是 1000,这就是可重复读。
注意:RR 下 ReadView 不是
BEGIN或START TRANSACTION时就生成,而是第一次快照读时生成。如果事务里先做了一次UPDATE,那个 ReadView 会更晚才建立。
注意:
SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE、DELETE都是当前读,不走 ReadView,直接读最新提交版本并加锁。别用它们验证快照读。
第六步:弄清哪类 undo 会被留着,哪类会被清掉
这一步决定你会不会踩到长事务的坑。
undo log 分两类:
- insert undo:INSERT 产生的,只用于回滚,事务提交后立刻可以丢弃,因为提交前这行本来就没人看得见
- update undo:UPDATE/DELETE 产生的,既要回滚也要给 MVCC 提供历史版本,提交后不能马上删
update undo 由 purge 线程清理,清理条件是没有 ReadView 还需要它。想观察 undo 规模可以查:
SELECT NAME, COUNT FROM information_schema.INNODB_METRICS
WHERE NAME LIKE 'trx_undo%';
注意:只要有一个长事务还拿着老的 ReadView,比它更早的所有 update undo 都不能清,undo 表空间会持续膨胀。线上出现 undo 暴涨,第一件事是查
information_schema.INNODB_TRX里trx_started最早的那个事务。
小结
- InnoDB 每行带
DB_TRX_ID和DB_ROLL_PTR,后者串起 undo log 里的历史版本链 - ReadView 用
m_ids、min_trx_id、max_trx_id、creator_trx_id四个值做可见性判断,不可见就顺着指针往旧版本找 - RC 每次快照读都新建 ReadView,RR 整个事务复用第一次快照读建的那个,这是两者唯一实质差异
- 当前读(
FOR UPDATE、UPDATE、DELETE)绕开 MVCC 直接读最新版本 - update undo 要等没有 ReadView 引用它才能被 purge 清掉,长事务是 undo 膨胀的根因
转载请注明出处,版权归原作者所有。