MySQL RR 隔离级幻读真相:3 段 SQL 看懂快照读与当前读的差别
MySQL RR 隔离级幻读真相:3 段 SQL 看懂快照读与当前读的差别
TL;DR:RR 在纯快照读和纯当前读场景下都解决了幻读,但快照读+当前读混用时幻读仍会发生。文末附完整可复现 SQL(MySQL 8.0+ 验证通过)。
事故背景
面试官说 RR 解决了幻读。我说没有。他说你回去再看看。回去一查,我们俩都没全对,答案不是"解决了"或"没解决",而是"取决于你怎么读"。
上周去面一家电商公司的 Java 后端,聊到 MySQL 隔离级别,面试官问:
“MySQL 的可重复读(RR)隔离级别,解决了幻读没有?”
同事脱口而出:“没有,幻读在 RR 下仍然存在。”
面试官笑了一下:“你回去再查查。InnoDB 在 RR 下通过 Next-Key Lock 解决了幻读。”
同事又说:“但是有些场景还是能看到幻读……”
他摆摆手:“你回去再看看,下一位。”
回去翻了一晚上资料,本地跑了几十遍 SQL。结论是:我们俩都没全对。
一、先搞清楚:什么是幻读
幻读和不可重复读容易搞混,先区分:
| 不可重复读 | 幻读 | |
|---|---|---|
| 区别 | 同一事务内,两次读同一行,结果不同(别人改了) | 同一事务内,两次范围查询,结果集不同(别人插了/删了) |
| 根因 | 其他事务修改了已有行 | 其他事务插入或删除了行,导致范围查询"多出来"或"少掉"行 |
简单说:
- 不可重复读是"你读的那行被改了"
- 幻读是"你查的范围里多了或少了行"
举个幻读的例子:
事务 A:SELECT * FROM users WHERE age > 20; -- 查到 3 条
事务 B:INSERT INTO users VALUES(6, 'Tom', 25); COMMIT;
事务 A:SELECT * FROM users WHERE age > 20; -- 查到 4 条 ← 幻行!
事务 A 第二次查多了一条 Tom,这条记录就是"幻行"。
二、InnoDB 在 RR 下到底做了什么
InnoDB 在 RR 隔离级别下,用了两个机制来"对付"幻读:
- 快照读(普通 SELECT):通过 MVCC 读历史快照,看不到其他事务新插入的数据
- 当前读(SELECT … FOR UPDATE / INSERT / UPDATE / DELETE):通过 Next-Key Lock 锁住间隙,阻止其他事务往范围内插入
这俩机制各管一摊,但它们之间有个缝隙,正是幻读溜进来的地方。
2.1 快照读:大多数场景下看不到幻读
-- 事务 A
BEGIN;
SELECT * FROM t WHERE id > 3; -- 假设查到 id=4,5 两条
-- 事务 B(另一个客户端)
BEGIN;
INSERT INTO t VALUES(6, 6); COMMIT;
-- 事务 A 再次查询(快照读)
SELECT * FROM t WHERE id > 3; -- 还是 id=4,5,看不到 id=6
事务 A 走的是快照读,读的是事务第一次快照读时定下的快照版本,事务 B 新插入的 id=6 对它不可见。这种情况下,幻读被"解决"了。
面试官说的"RR 解决了幻读",指的就是这个场景。
2.2 当前读:Next-Key Lock 锁住间隙
-- 事务 A
BEGIN;
SELECT * FROM t WHERE id > 3 FOR UPDATE; -- 当前读,加 Next-Key Lock
事务 A 会对 id>3 的范围加 Next-Key Lock,包括 (5, +∞) 的间隙。事务 B 想 INSERT id=6 会被阻塞,直到事务 A 提交。这种情况下,幻读也被"解决"了。
到这里面试官是对的。但接下来是翻车的地方。
三、翻车场景:快照读之后遇到当前读
面试官漏了一个场景:同一事务内,先快照读,再当前读。
3.1 建表和数据
-- 建表(本文基于 MySQL 8.4 验证,8.0+ 均可复现)
CREATE TABLE t_phantom (
id INT,
age INT,
PRIMARY KEY (id)
);
INSERT INTO t_phantom VALUES (1, 10), (2, 20), (3, 30), (4, 40), (5, 50);
3.2 复现步骤
时间线 会话 1 会话 2
T1 BEGIN;
T2 SELECT * FROM t_phantom WHERE id > 3;
-- 快照读,查到 id=4,5
T3 BEGIN;
T4 INSERT INTO t_phantom VALUES(6, 60);
T5 COMMIT; -- 事务 B 提交了
T6 SELECT * FROM t_phantom WHERE id > 3;
-- 快照读,还是 id=4,5(MVCC,看不到新数据)
T7 UPDATE t_phantom SET age = 999 WHERE id = 6;
-- 更新成功!Affected rows: 1
T8 SELECT * FROM t_phantom WHERE id > 3;
-- 快照读,这次查到 id=4,5,6 ← 幻行出现了!
重点看 T7 和 T8:
T6 快照读还看不到 id=6(正常)。但 T7 执行 UPDATE 时,InnoDB 走的是当前读,它"看到"了 id=6 这条记录(已被事务 B 提交),并且成功更新了它。
更新之后,这条记录就变成了"事务 A 修改过的记录"。MVCC 的可见性规则有一条:当前事务自己修改的记录,对当前事务可见。
于是 T8 再快照读时,id=6 突然出现了。
这就是幻读,它不是在 SELECT 时出现的,而是在 UPDATE 之后"被激活"的。
3.3 为什么会这样
MVCC 的可见性规则有一条关键逻辑:
如果一条记录被当前事务修改过,那么这条记录对当前事务可见,不管它最初是什么时候被插入的。
事务 A 在 T7 UPDATE 了 id=6,这条记录就变成了"事务 A 修改过的记录"。从这一刻起,事务 A 的快照读也能看到它了。
面试官说"RR 解决了幻读",对了一半,纯快照读场景下确实解决了,但快照读+当前读混合场景下,幻读会卷土重来。
四、还有一种翻车:先快照读,再 FOR UPDATE
同样的翻车逻辑,换个触发方式:
时间线 会话 1 会话 2
T1 BEGIN;
T2 SELECT * FROM t_phantom WHERE id > 3;
-- 快照读,查到 id=4,5
T3 BEGIN;
T4 INSERT INTO t_phantom VALUES(6, 60);
T5 COMMIT;
T6 SELECT * FROM t_phantom WHERE id > 3 FOR UPDATE;
-- 当前读!查到 id=4,5,6 ← 幻行直接出现了!
T2 是快照读看不到 id=6,T6 换成当前读(FOR UPDATE),直接看到了 id=6。同一事务内两次查询结果不同,幻读。
所以"RR 解决了幻读"这个结论,准确说法是:
RR 在纯快照读场景下通过 MVCC 解决了幻读,在纯当前读场景下通过 Next-Key Lock 解决了幻读。但在快照读和当前读混用的场景下,幻读仍然可能发生。
五、怎么避免这种幻读
方案 1:全部用当前读(加锁读)
如果你需要保证同一事务内多次范围查询结果一致,不要混用快照读和当前读,统一用 FOR UPDATE:
-- ❌ 危险:混用快照读和当前读
SELECT * FROM t WHERE id > 3; -- 快照读
UPDATE t SET age = 999 WHERE id = 6; -- 当前读(触发幻读)
SELECT * FROM t WHERE id > 3; -- 幻行出现
-- ✅ 安全:统一当前读
SELECT * FROM t WHERE id > 3 FOR UPDATE; -- 第一次就加锁
UPDATE t SET age = 999 WHERE id = 6;
SELECT * FROM t WHERE id > 3 FOR UPDATE; -- 结果一致
第一次 FOR UPDATE 就锁住了间隙,事务 B 根本插不进来,幻读从源头消除。
代价:并发性能下降,所有读都要加锁。
方案 2:用 Serializable 隔离级别
SET SESSION transaction_isolation = 'SERIALIZABLE';
Serializable 下,所有普通 SELECT 会自动转为加共享锁的当前读(相当于 LOCK IN SHARE MODE),不存在快照读和当前读的混用问题。
代价:性能最差,生产环境几乎不用。
方案 3:业务上避免"先读后改"的模式
// ❌ 危险:先快照读查范围,再根据范围更新
List<Long> ids = mapper.selectIdsByRange(minId); // 快照读
for (Long id : ids) {
mapper.updateAge(id, 999); // 当前读,可能触发幻读
}
// ✅ 安全:直接用一条 UPDATE 语句完成
mapper.updateAgeByRange(minId, 999); // 当前读,不需要先 SELECT
如果业务只需要更新满足条件的行,直接用一条 UPDATE WHERE,不需要先 SELECT 再逐条更新。这样就不存在快照读和当前读混用的问题。
方案 4:接受幻读,在业务层处理
大部分业务场景下,幻读不会造成实际问题,你查到多了一条数据,通常不影响业务逻辑。只有在以下场景才需要特别处理:
- 对账、库存校验等需要精确计数的场景
- 先查再改且依赖查询结果完整性的场景
如果你的业务不在这两类里,不用纠结幻读。
六、总结
回到那个面试问题:“RR 解决了幻读没有?”
准确答案:
- 纯快照读场景:通过 MVCC 解决了,看不到幻行
- 纯当前读场景:通过 Next-Key Lock 解决了,锁住间隙阻止插入
- 快照读 + 当前读混用:幻读仍可能发生,UPDATE/FOR UPDATE 会"激活"幻行
下次再有人问你这个问题,别只说"解决了"或"没解决",反问他一个场景:“先快照读再 UPDATE,你看幻读出来了没。”
附录:本地复现完整 SQL
-- 1. 建表
CREATE TABLE t_phantom (
id INT,
age INT,
PRIMARY KEY (id)
);
INSERT INTO t_phantom VALUES (1, 10), (2, 20), (3, 30), (4, 40), (5, 50);
-- 2. 会话 1 执行
BEGIN;
SELECT * FROM t_phantom WHERE id > 3; -- 快照读:id=4,5
-- 3. 会话 2 执行(另一个客户端)
BEGIN;
INSERT INTO t_phantom VALUES(6, 60);
COMMIT;
-- 4. 回到会话 1
SELECT * FROM t_phantom WHERE id > 3; -- 快照读:还是 id=4,5
UPDATE t_phantom SET age = 999 WHERE id = 6; -- 当前读:更新成功!
SELECT * FROM t_phantom WHERE id > 3; -- 快照读:id=4,5,6 ← 幻行!
COMMIT;
建议本地跑一遍(本文基于 MySQL 8.4 验证,8.0+ 均可复现)。
复现要点:关键是 T7 的 UPDATE,它走当前读"看到"了 id=6 并更新成功,之后这条记录变成了"本事务修改过的记录",快照读也能看到了。如果跳过 UPDATE 直接 SELECT,不会出现幻行。
你在面试中被问过 MySQL 幻读吗?你是怎么回答的?评论区聊聊。
如果觉得有帮助,点赞 + 收藏,下次面试前直接翻出来复习。
系列导航
本文是「Java 生产环境踩坑实录」系列第 6 篇,往期回顾:
- 第 1 篇:Redis 分布式锁的正确姿势:你写的可能是"假锁"
- 第 2 篇:用了 3 年 Spring Boot 才发现:@Transactional 的 7 个坑
- 第 3 篇:ThreadLocal 内存泄漏:你的应用正在悄悄 OOM
- 第 4 篇:MySQL 间隙锁是怎么"悄悄"制造死锁的
- 第 5 篇:唯一索引并发插入为什么也会死锁
- 第 6 篇:MySQL RR 隔离级幻读真相(本文)
下篇预告:线程池参数配错致 OOM,上线 10 分钟告警,corePoolSize 到底该怎么算?
系列持续更新中。更多生产环境踩坑实录,关注公众号。
更多推荐




所有评论(0)