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 隔离级别下,用了两个机制来"对付"幻读:

  1. 快照读(普通 SELECT):通过 MVCC 读历史快照,看不到其他事务新插入的数据
  2. 当前读(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 解决了幻读没有?”

准确答案:

  1. 纯快照读场景:通过 MVCC 解决了,看不到幻行
  2. 纯当前读场景:通过 Next-Key Lock 解决了,锁住间隙阻止插入
  3. 快照读 + 当前读混用:幻读仍可能发生,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 篇,往期回顾:

下篇预告:线程池参数配错致 OOM,上线 10 分钟告警,corePoolSize 到底该怎么算?


系列持续更新中。更多生产环境踩坑实录,关注公众号。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐