⭐ 推荐:社区规则条款 V1.0

MySQL 的 MVCC 是怎么配合 undo log 实现事务隔离的?

GJ
GJ 资深会员 👑黑卡会员
发布于 2026-10-27 15:49 ·8 浏览 ·0 回复
内容摘要

通过两个会话实验演示 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 依次判断:

  1. 等于 creator_trx_id → 自己改的,可见
  2. 小于 min_trx_id → 修改它的事务早在快照前就提交了,可见
  3. 大于等于 max_trx_id → 快照之后才开启的事务,不可见
  4. 落在 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 膨胀的根因
本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-2626.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 0

还没有回复,来抢沙发~