Skip to content

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_ID6 字节最后一次插入或更新这行的事务 ID,删除也视为一次带删除标记的更新
DB_ROLL_PTR7 字节回滚指针,指向 Undo Log 中这行上一个版本的记录
DB_ROW_ID6 字节表没有主键且没有合适的唯一索引时,用作自动生成的聚簇索引键

一行记录被事务 100、200、300 先后修改后,逻辑上形成一条版本链。假设此时有一个查询,它的 Read View 显示事务 300 还没提交:

当前查询的 Read View:m_ids = {300},m_up_limit_id = 300,m_low_limit_id = 301id = 42 · PAIDDB_TRX_ID = 300id = 42 · CREATEDDB_TRX_ID = 200id = 42 · INITDB_TRX_ID = 100roll_ptrroll_ptr聚簇索引 · 最新版本Undo LogUndo Log✗ 不可见300 在活跃事务列表中✓ 可见,返回此版本200 < m_up_limit_id不再访问已找到可见版本
图 1 · 最新版本在聚簇索引中,旧版本在 Undo Log 中;沿 roll_ptr 往回找,第一个对 Read View 可见的版本就是查询结果

最新版本总在索引页中,旧版本通过 Undo 记录按需「倒推」出来。这也意味着:版本链越长,读旧版本的代价越高。

三、Read View:判断版本是否可见 ​

事务做一致性读时会拿到一个 Read View,可以把它理解成「拍照那一刻,哪些事务已经提交了」。在 InnoDB 源码中,它主要记录了四个值(以下是实现细节,不是对外接口):

源码字段含义
m_creator_trx_id创建这个视图的事务自己的 ID
m_ids拍照时仍然活跃(未提交)的读写事务 ID 列表
m_up_limit_idm_ids 中最小的 ID,比它小的事务在拍照时都已结束
m_low_limit_id拍照时下一个将要分配的事务 ID,大于等于它的事务在拍照后才开始

拿到某个版本的 DB_TRX_ID 后,用这四个值判断它是否可见:

优先规则:DB_TRX_ID 等于当前事务自己的 ID(m_creator_trx_id)时,总是可见trx_id < m_up_limit_id拍照前已结束 → 可见m_up_limit_id ≤ trx_id < m_low_limit_id在 m_ids 中 → 不可见;否则 → 可见trx_id ≥ m_low_limit_id拍照后才开始 → 不可见m_up_limit_idm_low_limit_id
图 2 · Read View 把事务 ID 分成三段,中间一段再用活跃事务列表 m_ids 细分

不可见时,就顺着 DB_ROLL_PTR 找上一个版本,重复判断,直到找到可见版本;找到链尾都不可见,说明这行对当前事务「不存在」。

两个值得一提的细节:

  • 只读事务不分配事务 ID。 InnoDB 对没有写操作的事务不分配真正的事务 ID,所以它们不会出现在其他事务的 m_ids 中,这降低了创建视图的开销。
  • 二级索引没有隐藏字段。 二级索引记录被标记删除或被较新的事务修改时,InnoDB 无法只凭二级索引判断可见性,需要回到聚簇索引检查 DB_TRX_ID 并按需重建旧版本。这时覆盖索引优化不能使用,所以在更新频繁的表上,覆盖索引查询也可能要回表。

四、RR 与 RC:快照在什么时候建立 ​

官方文档的描述很明确:

  • REPEATABLE READ:同一事务中的所有一致性读,都使用第一次一致性读时建立的快照。
  • READ COMMITTED:事务中的每一次一致性读都建立并使用自己的新快照。

用一个时序表对比(balance 初始为 100):

时刻事务 A事务 B
T1BEGIN;
T2SELECT balance FROM account WHERE id = 1; → 100
T3UPDATE account SET balance = 80 WHERE id = 1; COMMIT;
T4SELECT balance FROM account WHERE id = 1; → RR:100,RC:80
T5SELECT 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 级别):

sql
-- 事务 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 READREAD 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 表空间持续膨胀;
  • 版本链变长,其他查询读旧版本越来越慢;
  • 被删除的行迟迟不能物理移除,索引扫描要跳过大量带删除标记的记录;
  • 如果长事务还持有锁,会引发大面积锁等待。

官方文档的建议是定期提交事务,包括只做一致性读的事务。

排查时可以看:

sql
-- 运行时间最长的事务
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\G

History 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 下的幻读争议、「查不到却改得到」的现象,以及长事务为什么会拖慢整个库。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权