InnoDB MVCC:Read View、快照读与 RR / RC 的真实差异
MVCC 不是「不加锁」,也不是一个神秘的版本号。它让普通查询读到一个合适的历史版本,写操作则照样加锁。把快照读和当前读分开,这个问题就理清了一半。
「MVCC 怎么实现」「RR 解决幻读了吗」「要不要把隔离级别改成 RC」是连在一起的三个问题。知道「隐藏列 + Undo Log + Read View」只能解释第一个,后两个还需要弄清什么时候读快照、什么时候读最新值,以及隔离级别对加锁的影响。
本文以 MySQL 8.4 的 InnoDB 为准。Undo Log 本身的写入与回滚机制见 Redo Log 与 Undo Log。
一、先说结论
- MVCC 由三部分组成:行记录上的隐藏字段(最后修改它的事务 ID、指向旧版本的回滚指针)、Undo Log 串起来的版本链,以及决定「哪个版本对我可见」的 Read View。
- 快照读与当前读是两套机制。 普通
SELECT是一致性非锁定读,走 MVCC;SELECT ... FOR UPDATE / FOR SHARE、UPDATE、DELETE读取最新已提交版本并加锁,不走快照。 - RR 与 RC 在 MVCC 上的差别是快照的建立时机:RR 在事务内第一次一致性读时建立快照并一直复用;RC 每条一致性读语句都建立新快照。
- 在锁上的差别更影响线上表现:RR 对范围条件使用间隙锁 / Next-Key Lock(锁住哪些记录见 InnoDB 行锁锁的是什么);RC 基本不加间隙锁,并会提前释放不匹配行上的锁。
- 「RR 解决了幻读吗」要带条件回答:纯快照读看不到新插入的行;加锁读通过 Next-Key Lock 阻止插入;两者混用时仍能观察到新行。
二、版本链:每一行都记着「谁改的、旧值在哪」
InnoDB 给聚簇索引中的每行记录加了几个隐藏字段:
| 字段 | 大小 | 含义 |
|---|---|---|
DB_TRX_ID | 6 字节 | 最后一次插入或更新这行的事务 ID,删除也视为一次带删除标记的更新 |
DB_ROLL_PTR | 7 字节 | 回滚指针,指向 Undo Log 中这行上一个版本的记录 |
DB_ROW_ID | 6 字节 | 表没有主键且没有合适的唯一索引时,用作自动生成的聚簇索引键 |
一行记录被事务 100、200、300 先后修改后,逻辑上形成一条版本链。假设此时有一个查询,它的 Read View 显示事务 300 还没提交:
最新版本总在索引页中,旧版本通过 Undo 记录按需「倒推」出来。这也意味着:版本链越长,读旧版本的代价越高。
三、Read View:判断版本是否可见
事务做一致性读时会拿到一个 Read View,可以把它理解成「拍照那一刻,哪些事务已经提交了」。在 InnoDB 源码中,它主要记录了四个值(以下是实现细节,不是对外接口):
| 源码字段 | 含义 |
|---|---|
m_creator_trx_id | 创建这个视图的事务自己的 ID |
m_ids | 拍照时仍然活跃(未提交)的读写事务 ID 列表 |
m_up_limit_id | m_ids 中最小的 ID,比它小的事务在拍照时都已结束 |
m_low_limit_id | 拍照时下一个将要分配的事务 ID,大于等于它的事务在拍照后才开始 |
拿到某个版本的 DB_TRX_ID 后,用这四个值判断它是否可见:
不可见时,就顺着 DB_ROLL_PTR 找上一个版本,重复判断,直到找到可见版本;找到链尾都不可见,说明这行对当前事务「不存在」。
两个值得一提的细节:
- 只读事务不分配事务 ID。 InnoDB 对没有写操作的事务不分配真正的事务 ID,所以它们不会出现在其他事务的
m_ids中,这降低了创建视图的开销。 - 二级索引没有隐藏字段。 二级索引记录被标记删除或被较新的事务修改时,InnoDB 无法只凭二级索引判断可见性,需要回到聚簇索引检查
DB_TRX_ID并按需重建旧版本。这时覆盖索引优化不能使用,所以在更新频繁的表上,覆盖索引查询也可能要回表。
四、RR 与 RC:快照在什么时候建立
官方文档的描述很明确:
- REPEATABLE READ:同一事务中的所有一致性读,都使用第一次一致性读时建立的快照。
- READ COMMITTED:事务中的每一次一致性读都建立并使用自己的新快照。
用一个时序表对比(balance 初始为 100):
| 时刻 | 事务 A | 事务 B |
|---|---|---|
| T1 | BEGIN; | |
| T2 | SELECT balance FROM account WHERE id = 1; → 100 | |
| T3 | UPDATE account SET balance = 80 WHERE id = 1; COMMIT; | |
| T4 | SELECT balance FROM account WHERE id = 1; → RR:100,RC:80 | |
| T5 | SELECT balance FROM account WHERE id = 1 FOR UPDATE; → RR、RC 都是 80 |
T4 体现了两种隔离级别的差别。T5 是当前读,无论什么隔离级别都读最新已提交版本并加锁。
有一个常被忽略的细节:RR 的快照建立在第一次一致性读,而不是 BEGIN 时。如果 A 在 T1 之后没有执行 T2,直接在 T4 第一次查询,那么即使是 RR 也会读到 80。需要在事务开始时立即建立快照,可以用 START TRANSACTION WITH CONSISTENT SNAPSHOT。
五、快照读与当前读混用:看起来像「MVCC 失效」
MySQL 官方文档给过一个很有代表性的例子,改写成业务场景如下(RR 级别):
-- 事务 A
BEGIN;
SELECT COUNT(*) FROM coupon WHERE status = 'NEW'; -- 快照读:0 行
-- 此时事务 B 插入了 10 条 status = 'NEW' 的记录并提交
UPDATE coupon SET status = 'EXPIRED' WHERE status = 'NEW';
-- 当前读:影响 10 行!UPDATE 看到的是最新已提交数据
SELECT COUNT(*) FROM coupon WHERE status = 'EXPIRED'; -- 快照读:10 行
-- 这些行的最新版本是 A 自己改的,按「自己的修改可见」规则变得可见
COMMIT;从事务 A 的视角看,第一次查询没有数据,更新却改到了 10 行,之后还能查出来。这不是 MVCC 失效,而是快照读和当前读本来就读不同的东西。
工程上的启示:「先查一下有没有,再决定怎么改」这种写法,在 RR 下查询结果不能作为更新的依据。 需要以最新数据为准时,要么直接用加锁读(SELECT ... FOR UPDATE),要么把判断条件写进 UPDATE 的 WHERE 中并检查影响行数。
六、幻读到底解决了没有
分三种情况回答:
| 读的方式 | RR 下能否看到其他事务新插入并提交的行 | 依靠的机制 |
|---|---|---|
| 事务内只做一致性快照读 | 看不到 | 复用同一个 Read View |
| 事务内做加锁的范围读 | 看不到,因为别人插不进来 | Next-Key Lock 锁住扫描范围与间隙,阻止插入 |
| 先快照读,再对范围做当前读或更新 | 能看到 | 当前读本来就读最新数据,见上一节 |
所以「RR 完全解决了幻读」和「RR 没有解决幻读」都不准确。更准确的说法是:InnoDB 在 RR 下通过快照读和 Next-Key Lock,分别解决了两类读的幻读,但两类读混用时仍会观察到新行。
在 RC 下,InnoDB 只锁索引记录、不锁间隙(外键检查和唯一键检查除外),其他会话可以向锁定记录旁边插入新行,因此加锁读也会出现幻读。
七、RC 与 RR 的锁差异,才是选型的重点
MVCC 层面的差别只是「快照多久刷新一次」,真正影响线上并发的是加锁行为:
| 行为 | REPEATABLE READ | READ COMMITTED |
|---|---|---|
| 唯一索引等值加锁读 | 只锁找到的记录 | 只锁找到的记录 |
| 范围条件加锁读 | 锁扫描范围内的记录与间隙(Next-Key Lock) | 只锁记录,不锁间隙 |
UPDATE / DELETE 扫描到但不匹配的行 | 锁一直持有到事务结束 | 判断完 WHERE 后释放 |
UPDATE 遇到已被锁住的行 | 等待锁 | 先做「半一致性读」,读最新已提交版本判断是否匹配,不匹配就跳过 |
| Binlog 格式 | 都支持 | 只支持基于行的格式(MIXED 会自动按行记录) |
官方文档举的例子:在没有索引的列上执行 UPDATE ... WHERE b = 3,RR 会锁住扫描到的所有行直到提交;RC 在判断完不匹配后就释放了,显著降低锁冲突和死锁概率。
选 RC 还是 RR
MySQL 8.4 的默认隔离级别仍是 RR。可以这样权衡:
- 倾向 RC:写并发高、死锁频繁、范围更新多;业务不依赖事务内的可重复读;Binlog 已经是
ROW格式。 - 保留 RR:有报表、对账等需要事务内一致快照的逻辑;依赖间隙锁防止范围内插入(例如「检查不存在再插入」的逻辑没有唯一约束兜底)。
改隔离级别不是改一个参数就完事。要先梳理依赖可重复读的代码,并在压测中对比死锁数、锁等待时间和业务正确性。「大厂都用 RC」不是理由,它们通常同时具备行格式 Binlog、唯一约束兜底和完善的对账。
八、长事务:MVCC 最常见的线上问题
Update Undo Log 只有在没有任何活跃事务的快照还需要它时才能被 purge 清理。一个开了几个小时的事务,会让这几个小时内产生的旧版本全部无法清理:
- Undo 表空间持续膨胀;
- 版本链变长,其他查询读旧版本越来越慢;
- 被删除的行迟迟不能物理移除,索引扫描要跳过大量带删除标记的记录;
- 如果长事务还持有锁,会引发大面积锁等待。
官方文档的建议是定期提交事务,包括只做一致性读的事务。
排查时可以看:
-- 运行时间最长的事务
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started
LIMIT 10;
-- 未清理的历史版本数量(History list length)
SHOW ENGINE INNODB STATUS\GHistory list length 持续上涨,且 innodb_trx 里有开始时间很早的事务,基本就能确定是长事务阻塞了 purge。
实测:会话 A 用 START TRANSACTION WITH CONSISTENT SNAPSHOT 开启一个只读事务并保持,会话 B 每轮执行 2,000 个自动提交的单行 UPDATE,同时读取 information_schema.INNODB_METRICS 中的 trx_rseg_history_len:
| 阶段 | History list length |
|---|---|
| 开始前 | 0 |
| 长事务持有中,第 1 轮后 | 2,000 |
| 长事务持有中,第 5 轮后 | 10,000 |
| 长事务提交后约 1 秒 | 0 |
它按提交的更新事务计数,而不是按行:同样改 2,000 行,一个大事务只增加 1,2,000 个小事务增加 2,000。A 只做了一次查询、没有任何写入,照样挡住了全部 purge。
常见根因:
- 应用在事务中调用了 RPC、HTTP 或等待消息;
- 连接池中的连接开启事务后没有提交就被归还;
- 手动在客户端工具里执行了
BEGIN后忘记提交; - 大批量数据迁移放在一个事务中完成。
应用侧可以用事务超时(如 Spring 的 @Transactional(timeout = ...))和连接池的泄漏检测兜底,同时对 innodb_trx 中的长事务设置告警。
九、常见误区
- 「MVCC 让读写完全不冲突」:只有快照读不加锁;当前读和写仍然互相阻塞。
- 「RR 的快照在
BEGIN时建立」:在第一次一致性读时建立,除非使用WITH CONSISTENT SNAPSHOT。 - 「在 RR 下,查不到的行就一定改不到」:
UPDATE是当前读,能改到其他事务刚提交的行。 - 「覆盖索引一定不回表」:二级索引记录被较新事务修改或标记删除时,需要回聚簇索引判断可见性。
- 「只读事务开久一点没关系」:只读事务也会持有快照,阻止 purge。
小结
理解 MVCC 抓住三层:版本链保存旧值,Read View 判断可见性,隔离级别决定快照刷新时机和加锁范围。再补上一句「快照读与当前读是两回事」,就能解释 RR 下的幻读争议、「查不到却改得到」的现象,以及长事务为什么会拖慢整个库。
配套实验
- codesphere-labs/storage/mysql-mvcc-isolation:RR 与 RC 两会话时序、快照建立时机、快照读与当前读混用、幻读的三种读法、半一致性读与长事务(验证记录)
参考资料