MySQL 8.0/8.4 MVCC:RC、RR 与幻读的边界
同一个事务里,普通 SELECT 查不到的行,UPDATE 却可能更新成功。理解 MySQL 8.0/8.4 的 InnoDB,关键是先问:这条语句读取哪个版本,又锁住了什么?本文限定 RC(READ COMMITTED)与 RR(REPEATABLE READ),不把结论推广到其他引擎。
MVCC 不是事务开始时复制整张表
InnoDB 在聚簇记录中保存最近修改事务的标识 DB_TRX_ID,通过 DB_ROLL_PTR 指向 undo 记录,需要时重建旧版本。Read View 可以理解为一次一致性读的可见性边界:创建时已提交的修改可见,当时仍活跃的其他事务及之后事务的修改不可见;自己的先前修改是例外。
因此,不能只比较“行事务号是否小于当前事务号”,也不能认为其他事务一提交,旧快照就能看见它。版本不可见时继续沿 undo 查找;若找不到可见版本,这行在本次读中就不存在。长事务持续使用旧快照,会延迟相关 undo 的清理,增加历史版本保留成本。参见官方 多版本机制。
RC 与 RR:变化的是快照建立时机
| 维度 | RC | RR(InnoDB 默认) |
|---|---|---|
| 普通一致性读 | 每次建立新快照 | 通常首次一致性读建立,后续复用 |
| 别人提交后的再次普通读 | 可见新提交 | 仍按原快照判断 |
| 自己已经执行的修改 | 可见 | 可见,不能视为全事务结果冻结 |
| 范围加锁读 | 通常只锁记录,间隙锁有外键及重复键检查等例外 | 通常以 next-key 锁保护扫描范围 |
单独执行 START TRANSACTION 通常还没有建立 RR 快照;需要立即建立时,可在 RR 下使用 START TRANSACTION WITH CONSISTENT SNAPSHOT。RC 不会因此获得跨语句固定快照。另一个常见误差是忘记显式事务:自动提交下,两次独立 SELECT 通常属于两个事务。
这两个版本的核心规则一致,分别见官方 8.0 一致性读和 8.4 隔离级别。
双会话:同样的 SQL,不同的可见性
以下仅在独立测试库中准备表,再按时序操作两个连接。所有行数均为文档规则推导,本文未连接数据库执行,不能当作实测输出。
CREATE TABLE mvcc_demo (
id INT PRIMARY KEY,
score INT NOT NULL,
KEY idx_score (score)
) ENGINE=InnoDB;
INSERT INTO mvcc_demo VALUES (10, 10), (30, 30);先在 A 执行:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT id FROM mvcc_demo WHERE score >= 10 ORDER BY id;
-- 预期:10、30;首次一致性读建立快照再在 B 执行并提交:
START TRANSACTION;
INSERT INTO mvcc_demo VALUES (20, 20);
COMMIT;此时 A 重复原 SELECT,预期仍为 10、30,随后 ROLLBACK 结束事务。要对照 RC,应在空闲连接把级别改为 READ COMMITTED,并在新一轮开始前将测试数据恢复为仅有 10、30。重复上述顺序,A 第二次查询预期包含 20,因为它重新建立了快照。
RR 的这个结果说明快照屏蔽了新行,并不意味着 B 的插入被阻止。普通一致性读不为这些记录加行锁,但仍涉及元数据锁,不能笼统称为“完全无锁”。
快照读与当前读不能混成一个视图
普通 SELECT 在上述级别通常是快照读。SELECT ... FOR UPDATE、FOR SHARE 是加锁读;UPDATE、DELETE 的目标记录处理也不能照搬旧快照。工程中常将这些访问当前状态的操作称为“当前读”。它不是读取其他事务尚未提交的数据;发生锁冲突时可能等待,并按相应规则处理当前记录。
接着前面 RR 时序,在 A 回滚之前执行:
SELECT id FROM mvcc_demo WHERE score >= 10 ORDER BY id FOR UPDATE;
-- 预期包含已由 B 提交的 20
SELECT id FROM mvcc_demo WHERE score >= 10 ORDER BY id;
-- 若 A 没有修改记录,普通读预期仍为 10、30
ROLLBACK;加锁读不会把旧 Read View 自动刷新。两次查询读法不同,不能把结果差异直接说成“相同快照查询发生幻读”。需要读后修改时,应在显式事务中直接加锁读取,并在事务结束前完成修改。官方 加锁读说明了其锁定与等待行为。
为什么不能宣称 RR 绝无幻读
更容易误判的是自己修改了新行。另起一轮:A 在 RR 中先按主键查询 id=20,结果为空;B 随后插入该行并提交。然后 A 执行:
UPDATE mvcc_demo SET score = score + 1 WHERE id = 20;
-- 预期修改 1 行,即使先前快照查不到它
SELECT id, score FROM mvcc_demo WHERE id = 20;
-- 预期读到 (20, 21):自己的修改可见
ROLLBACK;“RR 下查不到就一定更新不到”是此处的预期失败断言,不是 SQL 语法错误。先前不存在于快照的记录,经过本事务修改后可以被自己读到;同一查询的结果因此可能改变。官方 一致性读说明明确指出,本事务修改与旧版本混合后,甚至可能呈现数据库从未整体存在过的状态。
严格讨论幻读时,应注明查询谓词、读法和自身写入。RR 对重复快照读的稳定性、范围锁对并发插入的阻止,是两种机制;都不能推出任意混合读写都有串行化语义。
范围保护要在业务判断之前建立
若业务要求“这个区间没有记录,接下来也不允许别人插入”,应从第一次判断就使用 RR 范围加锁读,而不是先普通查询再补锁。next-key 锁结合记录与间隙保护,能阻止其他事务往受保护范围插入;已有唯一键的完整等值命中通常只需记录锁。具体锁范围取决于索引、条件和实际访问路径,不能把 SQL 的逻辑条件直接当作物理锁边界,参见官方 幻影行与 next-key locking。
RC 的范围加锁读通常无法提供同样的间隙保护。涉及唯一性时,优先让唯一约束兜底;涉及扣减时,用带余额条件的原子 UPDATE 并检查影响行数。MVCC 负责版本可见性,业务不变量仍需要约束、锁或正确的写入协议。
相关阅读
保留站内阅读入口:事务隔离级别资料、MySQL 索引资料、Spring 事务提交边界。这些链接用于延伸阅读,本文技术依据均为上文所列 MySQL 官方手册。
