在 MySQL 中,InnoDB 存储引擎使用 MVCC(多版本并发控制)机制配合事务隔离级别来提升并发性能并保证数据一致性。虽然 可重复读(Repeatable Read) 是 MySQL 默认的隔离级别,并通过 MVCC 避免了大多数幻读情况,但 它并没有完全解决幻读问题。
本笔记将通过两个典型场景分析 幻读在可重复读隔离级别下仍可能发生的原因和原理。
(关于MVCC的知识参考上一篇博客:MVCC多版本并发控制-CSDN博客)
背景知识
-
快照读(Snapshot Read):
-
普通
SELECT
语句; -
使用 MVCC 和 Read View;
-
不加锁,只能看到当前事务开始前已提交的数据;
-
-
当前读(Current Read):
-
包括
SELECT ... FOR UPDATE
、UPDATE
、DELETE
; -
不使用 Read View;
-
会访问最新版本数据,并对数据加锁;
-
-
Read View:
-
可重复读下,在事务首次执行快照读时创建;
-
整个事务中固定不变,用于保证读取一致性;
-
示例问题一:更新一条自己原本看不到的记录
问题描述:
比如说当前的表中只有 id 为 1~4 的记录,没有 id=5。
事务 A 执行
SELECT * FROM test WHERE id = 5
,没有查到数据(快照读);事务 B 插入一条 id=5 的记录并提交;
事务 A 执行
UPDATE test SET name='Alice' WHERE id=5
;事务 A 再次
SELECT * FROM test WHERE id=5
,发现 id=5 存在;❓ 为什么事务 A 原本看不到 id=5,却又能更新它?这是不是幻读?
解答:
这个问题的核心在于 事务 A 使用了两种读操作:快照读 和 当前读:
-
第一次
SELECT
是快照读,使用 Read View,看不到事务 B 新插入的记录(B 的事务 ID 大于 max_trx_id); -
UPDATE
属于 当前读,不使用 Read View,直接读取最新提交版本,因此看到了 B 插入的记录; -
最后的
SELECT
可能是当前读(或 InnoDB 缓存了结果),所以看到了 id=5;
因为当前读访问的是最新数据,因此能看到原本快照读看不到的记录,幻读就这样发生了。
示例问题二:当前读中看到多出的一条记录
问题描述:
T1 时刻,事务 A 执行快照读
SELECT * FROM orders WHERE amount > 100
,查到三条记录;T2 时刻,事务 B 插入一条符合条件的新记录并提交;
T3 时刻,事务 A 执行
SELECT * FROM orders WHERE amount > 100 FOR UPDATE
,查到四条记录;❓ 为什么事务 A 在 T3 时刻能看到 B 插入的新数据?Read View 不是应该屏蔽它吗?
✅ 解答:
这个场景中:
-
T1 是快照读,使用事务 A 的 Read View,看不到 B 的插入;
-
T3 是 当前读(FOR UPDATE),跳过 Read View,读取最新版本数据;
-
因此事务 A 看到了 B 提交的新记录,记录数从 3 变成 4,形成幻读。
这正是 InnoDB 可重复读 + MVCC 机制的边界:快照读有一致性,但当前读会看到最新提交数据,可能引发幻读。
为什么会这样?——核心原理回顾
操作类型 | 是否使用 Read View | 看到的版本 | 是否加锁 | 幻读风险 |
---|---|---|---|---|
SELECT | ✅ 是 | 启动事务时的数据 | ❌ 否 | ❌ 不会幻读 |
SELECT FOR UPDATE | ❌ 否 | 最新已提交数据 | ✅ 加锁 | ✅ 有可能 |
UPDATE / DELETE | ❌ 否 | 最新已提交数据 | ✅ 加锁 | ✅ 有可能 |
InnoDB 是怎么处理当前读中的幻读的?
在可重复读隔离级别下,InnoDB 为防止当前读中插入导致的幻读,使用以下锁机制:
-
间隙锁(Gap Lock):锁住索引区间,防止在查询范围内插入新记录;
-
Next-Key Lock:行锁 + 间隙锁,防止更新范围内的数据变动;
但这些锁只有在使用 SELECT ... FOR UPDATE
或 UPDATE
等当前读操作时才会生效。
总结
-
MySQL 的 可重复读隔离级别通过 MVCC 避免了快照读中的幻读;
-
但 当前读不使用 MVCC,而是读取最新提交数据,因此仍可能发生幻读;
-
特别是在事务中混合使用快照读和当前读时,幻读现象更容易出现;
-
InnoDB 通过锁机制(如间隙锁)防止插入型幻读,但并非所有场景都能避免;
-
正确使用锁语句(如
SELECT ... FOR UPDATE
)和理解快照视图与当前读的区别,是防止幻读的关键。
补充答疑:
1.如果一个事务中全部使用快照读或者全部使用当前读是否就不会发生幻读,即不两者混合使用
结论:
即使一个事务中不混用快照读和当前读,只使用快照读或只使用当前读,也不能绝对保证不会发生幻读。但行为和风险确实不同,下面我们分开讨论。
一、全部使用快照读(普通 SELECT)时
-
所有读取都通过 Read View 实现;
-
在 可重复读隔离级别下,Read View 在事务第一次读时创建,之后读取都用这份一致性视图;
-
所以你看到的数据永远是事务开始时的数据快照;
-
不会看到其他事务新增、删除、修改的记录;
-
幻读自然就不会发生(因为你永远看不到“多了一条”);
结论:
只使用快照读是最稳定的方式,不会发生幻读,但也无法感知到其他事务提交的更新。
二、全部使用当前读(如 SELECT FOR UPDATE、UPDATE)时
-
不使用 Read View;
-
每次都读取最新的版本数据;
-
并且会对读取的数据或范围加锁(行锁、间隙锁);
-
插入型幻读就可能会被锁机制(Next-Key Lock)阻止;
-
但如果你没有正确使用索引(导致范围锁无效),或者查询条件不精确,还是可能有插入插空导致幻读。
结论:
只使用当前读并正确加锁,通常能防止幻读,但这依赖于使用了正确的锁机制,并非绝对安全。
三、混用快照读与当前读风险最大
-
快照读不会加锁,看不到其他事务的更新;
-
当前读会访问最新数据;
-
容易出现这种现象:
-
开始时看不到某条记录(快照读);
-
后来却能读到/修改到它(当前读);
-
从而构成了“幻读”。
-
结论:
混用两者很容易引起对数据一致性的困惑,是幻读出现的典型来源。
事务读方式 | 是否可能幻读 | 说明 |
---|---|---|
只用快照读 | ❌ 不会幻读 | 所有数据来自 Read View,完全一致性视图 |
只用当前读 | ⚠️ 可能幻读(取决于锁) | 需依赖锁(如间隙锁、Next-Key Lock)是否到位 |
混用两者 | ✅ 极易幻读 | 快照视图和实时数据混用,视图失去一致性 |
2.上面提到了关于锁的使用是否到位会影响是否产生幻读,那么也就是间隙锁是否生效有哪些条件?
间隙锁不是“总是加”的,确实取决于多个因素
MySQL 中 当前读是否加间隙锁,确实 不是固定的行为,它取决于以下几个关键因素:
1. 事务隔离级别
-
在 可重复读(Repeatable Read) 隔离级别下(现在MySQL默认),间隙锁是默认启用的;
-
在 读已提交(Read Committed) 隔离级别下,默认不开启间隙锁(只加行锁);
-
所以:如果你使用的是 Read Committed,当前读不会加间隙锁 → 可能发生幻读;
2. 语句类型
只有以下类型的语句才属于“当前读”,InnoDB 才会尝试加锁:
SELECT ... FOR UPDATE
SELECT ... LOCK IN SHARE MODE
UPDATE
DELETE
普通 SELECT 是快照读,不加任何锁,自然不加间隙锁。
3. 是否命中了索引
这是最常被忽略的一点,也是“锁是否到位”的关键!
如果你对没有使用索引的列进行范围查询,即使使用
SELECT ... FOR UPDATE
,也无法正确加间隙锁!
例如:
SELECT * FROM users WHERE name > 'Jack' FOR UPDATE;
如果 name
字段 没有索引,InnoDB 会进行 全表扫描,而不是通过 B+ 树索引来定位范围,自然也就无法加正确的间隙锁。
结果就是——你以为加锁了,其实没锁住该锁的范围,导致幻读风险依旧存在!
4. 是否使用唯一索引、等值查询
如果你是唯一索引的等值查询:
SELECT * FROM users WHERE id = 100 FOR UPDATE;
-
InnoDB 会只加行锁,不加间隙锁;
-
并不会锁住 “100 之前” 或 “100 之后” 的空隙;
-
所以不能防止在该主键前/后插入新记录;
-
幻读风险仍可能存在。
只有范围查询(如 >
, <
, BETWEEN
)才会触发 Next-Key Lock(行锁+间隙锁)。
ps:核心知识点以及图片依然参考:MySQL 可重复读隔离级别,完全解决幻读了吗? | 小林coding