☰
MVCC 是怎么实现的?undo log 版本链与 ReadView 全解析
2026/11/4 2:39:19 网站建设 项目流程

先给结论

三样东西撑起了 MVCC:每行记录上两个隐藏字段(哪个事务改的、上一条版本在哪)、undo log 里按回滚指针串起来的版本链、以及读的那一刻生成的 ReadView。

查询顺着版本链往回走,找到第一个对当前事务可见的版本,返回。整个过程不加锁。

至于 RC 和 RR,它们的全部差别只有一处:ReadView 什么时候生成。

一个很容易被忽略的现象

一个典型的场景是同一段代码在两个库上跑出不同结果。

假设有这样一个逻辑:事务里先查一次账户余额,做点别的事,再查一次。中间另一个事务把余额改了并提交。

在读已提交(RC)下,第二次查到的是新值。在可重复读(RR)下,第二次查到的还是旧值,哪怕对方早就提交了。

很多团队上线前都会遇到这个问题的变体:对账脚本在一个事务里跑,中途业务库还在写入,脚本前后读到的数据对不上,于是怀疑数据库有 bug。其实这是隔离级别的定义在工作。

但要注意一点。如果第二次查询写成SELECT ... FOR UPDATE,那它读到的一定是最新值,不管你在哪个级别。这个区别后面要用到。

先把问题问对

面试官问 MVCC,实际在问:读一致性为什么可以不用加锁实现。

在这之前得先分清两种读。

快照读,就是普通的SELECT。它读的是某个时间点的一致性快照,靠 MVCC 实现,不加锁。

当前读,SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、以及所有的UPDATE、DELETE、INSERT。它们必须读到最新的已提交数据,靠锁实现。

MVCC 只管第一种。把这两件事混在一起,后面所有的结论都会乱。

三个组件,逐个拆开

隐藏字段:每行都记着"谁改的"和"上一版在哪"

InnoDB 的行记录上除了你定义的列,还有三个你看不见的字段:

  • DB_TRX_ID,6 字节,最后一次修改这行的事务 ID
  • DB_ROLL_PTR,7 字节,回滚指针,指向 undo log 里上一个版本的记录
  • DB_ROW_ID,6 字节,单调递增的行号,只在没有主键时才会成为聚簇索引的依据

前两个是 MVCC 的主角。事务 ID 是全局递增的,这个性质非常关键,后面判断可见性全靠比大小。

undo log 版本链

一行记录被更新之后,旧版本不会直接丢掉,而是被写进 undo log。新版本的DB_ROLL_PTR指向旧版本,旧版本自己的回滚指针又指向更旧的版本,于是形成一条链表。

链表头是最新的版本,尾巴是最老的。

最新行 (trx_id=300) │ DB_ROLL_PTR ▼ 版本 2 (trx_id=200) │ DB_ROLL_PTR ▼ 版本 1 (trx_id=100) │ ▼ (无更早版本,链尾)

一行记录被更新 1000 次,链上就有 1000 个版本。这就是为什么长事务危险:只要有一个老 ReadView 还在用,这条链就一个都删不掉。

这里有个容易被搞混的点:undo log 不只是回滚用的。事务回滚确实靠它,但 MVCC 的一致性读同样靠它。事务提交之后,undo log 不会立刻删,要等没有任何 ReadView 再需要它时,由后台的 purge 线程清理。

ReadView 与可见性判断

ReadView 是读操作生成的快照,它记的主要是三件事:生成时活跃(未提交)事务的 ID 列表、这个列表里最小的 ID、以及下一个将被分配的事务 ID。

拿到 ReadView 之后,判断一个版本能不能看,规则其实很朴素:

  1. 版本的DB_TRX_ID小于最小活跃 ID,说明改它的事务早就提交了,可见
  2. 版本的DB_TRX_ID大于等于下一个待分配 ID,说明是生成 ReadView 之后才开启的事务改的,不可见
  3. 落在中间,就看这个 ID 在不在活跃列表里:在,说明还没提交,不可见;不在,说明已经提交了,可见

不可见就顺着DB_ROLL_PTR往回走一版,重新判断,直到找到可见的或者走到链尾。

所以"读到一个旧值"不是因为数据库把旧值藏起来了,是因为当前版本对你不可见,你被顺着链推到了某个历史版本上。

RC 和 RR:只差生成时机

这一条建议直接背下来。

RC 下,每一条快照读都会生成一个新的 ReadView。所以别的事务一提交,你下一次查就能看到。

RR 下,只有事务里的第一条快照读会生成 ReadView,之后所有快照读复用同一个。所以别的事务提交了你也看不到,因为你手里的那张快照是那一刻拍的。

还有一个细节值得注意:RR 下 ReadView 是在第一条快照读时生成的,不是BEGIN的时候。事务开了但还没读过任何数据,ReadView 还没生成。这两个时刻不是一回事,很多人答成"事务开始就生成快照",严格讲是错的。

用两个会话复现一遍
-- MySQL 8.0,默认 RR。开两个会话 -- 会话 A SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; BEGIN; SELECT balance FROM t_account WHERE id = 1; -- 第一次快照读,生成 ReadView -- 看到 100 -- 会话 B BEGIN; UPDATE t_account SET balance = 200 WHERE id = 1; COMMIT; -- 回到会话 A SELECT balance FROM t_account WHERE id = 1; -- 仍是 100 SELECT balance FROM t_account WHERE id = 1 FOR UPDATE; -- 变成 200,这是当前读 COMMIT;

把会话 A 的隔离级别改成READ COMMITTED再跑一遍,第三条语句的结果会变成 200。

想看当前有哪些事务、它们的 ID 是多少,可以查:

SELECT trx_id, trx_state, trx_started, trx_isolation_level FROM information_schema.innodb_trx\G

长事务排查靠的就是这张表。跑超过几十秒的事务都值得看一眼。

面试官在等的那句

答完"隐藏字段、版本链、ReadView 三件套",已经是标准答案了。停在这儿,面试官会点点头然后换下一题。

真正拉开差距的是接上这一句:

RR 下的幻读,是两套机制一起解决的。快照读的幻读靠 MVCC,因为 ReadView 从头到尾没变,别的事务插入的新行对你不可见。但当前读的幻读 MVCC 管不了,SELECT ... FOR UPDATE必须读到最新,这时靠的是 next-key lock,也就是行锁加间隙锁,把可能被插入的区间锁住。

所以严格说,RR 下幻读是"基本解决",不是"彻底解决"。如果事务里先做快照读、后面又做了当前读,两次结果仍然可能对不上,这是可以复现的。

为什么这句话值钱?因为它说明你知道 MVCC 的能力边界在哪。绝大多数人只会说"RR 解决了幻读",而这句话在严格意义上是错的。

四个常见误区

误区一:以为 MVCC 意味着完全不用锁MVCC 只在快照读上省掉了锁。写操作、以及显式加锁的读,走的还是锁。MVCC 和锁是并存的两套东西。

误区二:认为 undo log 只服务于回滚它还支撑一致性读,而且正是因为这样,长事务才会导致 undo 膨胀。purge 线程要等到最老的 ReadView 消失才能清理。

误区三:把 ReadView 的生成时机说成事务开始时是事务第一条快照读的时候。这两个时刻可以差很久。

误区四:以为 RR 下所有读都能重复FOR UPDATE这类当前读读的是最新已提交数据,不保证可重复。想验证的话,跑一遍上面那段会话 A 的脚本就明白了。

速查表

概念作用常见考点
DB_TRX_ID记录最后修改的事务 ID全局递增,可见性靠比大小
DB_ROLL_PTR指向上一版本串成版本链
undo log存历史版本回滚 + 一致性读双用途
ReadView记录活跃事务快照RC 每次生成,RR 首次生成
purge 线程清理不再需要的版本被长事务阻塞会膨胀
next-key lock行锁加间隙锁当前读下防幻读

一个还没聊完的问题

purge 线程被长事务卡住之后,undo 表空间会一路涨,历史上不少团队是靠定期 kill 长事务来兜底的。

但 kill 掉一个跑了二十分钟的对账事务,业务上未必接受。你们是怎么处理这类冲突的?是限制事务时长、把大查询拆成小事务,还是别的办法?

评论区聊聊。

另外,完整的十二个模块(Java / C++ / Go / JVM / MySQL / Redis / MQ / 分布式 / 事务 / 网络 / 系统设计 / 项目深挖)我整理成了一份手册,每题都标了考察点和加分答法。【草丛后端架构】,持续分享后端架构与底层原理

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询