先给结论
三样东西撑起了 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 字节,最后一次修改这行的事务 IDDB_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 之后,判断一个版本能不能看,规则其实很朴素:
- 版本的
DB_TRX_ID小于最小活跃 ID,说明改它的事务早就提交了,可见 - 版本的
DB_TRX_ID大于等于下一个待分配 ID,说明是生成 ReadView 之后才开启的事务改的,不可见 - 落在中间,就看这个 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 / 分布式 / 事务 / 网络 / 系统设计 / 项目深挖)我整理成了一份手册,每题都标了考察点和加分答法。【草丛后端架构】,持续分享后端架构与底层原理