MySQL 可重复读隔离级别,完全解决幻读了吗?
核心结论: MySQL 的可重复读隔离级别很大程度上避免了幻读,但并没有完全解决。
目录
我在上一篇文章提到,MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但它只是很大程度上避免了幻读现象(并不是完全解决)。InnoDB 提供了两种不同的机制来解决幻读:
防止幻读的两种机制:
- 针对快照读:使用 MVCC 机制
- 针对当前读:使用 next-key lock (记录锁+间隙锁)
这两个解决方案虽然能处理大多数场景,但仍有特殊情况会出现幻读。本文将深入分析这个问题。
什么是幻读?
幻读定义:当同一事务内,相同的查询在不同时间点返回不同的结果集时产生的现象。
MySQL 官方文档解释:
“The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times. For example, if a SELECT is executed twice, but returns a row the second time that was not returned the first time, the row is a “phantom” row.”翻译:当同一个查询在不同的时间产生不同的结果集时,事务中就会出现所谓的幻象问题。例如,如果 SELECT 执行了两次,但第二次返回了第一次没有返回的行,则该行是"幻像"行。
幻读的简单判断标准
如果一个事务在 T1 和 T2 两个时刻执行完全相同的查询,结果不同就是幻读:
1 | -- 假设在T1和T2时刻分别执行相同查询 |
幻读的表现形式:
- 前一次查询有 5 条记录,后一次变成了 6 条记录 ⟹ 幻读
- 前一次查询有 5 条记录,后一次变成了 4 条记录 ⟹ 也是幻读
MySQL 如何避免幻读
快照读是如何避免幻读的?
快照读原理: InnoDB 通过 MVCC(多版本并发控制)实现可重复读隔离级别。
🔍 MVCC 工作流程图:
1 | 事务开始 ➡️ 创建ReadView ➡️ 后续查询使用相同ReadView ➡️ 保证数据一致性 |
关键机制:
- 事务启动后第一次查询创建一个 Read View
- 后续查询复用这个 Read View
- 通过 Read View 在 undo log 版本链中找到事务开始时的数据版本
- 即使其他事务插入新数据,也不会被看到
实验演示
表 t_stu 数据:
id | name | score |
---|---|---|
1 | 小林 | 50 |
2 | 小明 | 60 |
3 | 小红 | 70 |
4 | 小蓝 | 80 |
事务执行顺序:
快照读结论: 即使事务 B 中途插入了新记录,事务 A 的两次查询结果仍然一致,成功避免了幻读。
当前读是如何避免幻读的?
当前读的特点:总是读取数据的最新版本。
属于当前读的操作:
SELECT ... FOR UPDATE
UPDATE
INSERT
DELETE
当前读必须读取最新数据的原因:避免数据冲突(如更新已被删除的记录)。
为什么当前读可能导致幻读
如果不加任何限制,当前读会出现这种情况:
问题: 事务 A 的两次查询返回不同结果,出现了幻读。
InnoDB 的解决方案:间隙锁
为解决当前读的幻读问题,InnoDB 引入了间隙锁(Gap Lock):
next-key lock 工作原理:
- 事务 A 执行
SELECT ... FOR UPDATE
时加上 next-key lock (id 范围: (2, +∞]) - 事务 B 尝试插入记录时,发现区间被锁
- 事务 B 生成插入意向锁并等待
- 直到事务 A 提交,事务 B 才能插入
间隙锁总结: 通过锁定可能插入记录的间隙,阻止其他事务插入数据,从而避免幻读。
幻读被完全解决了吗?
第一个发生幻读现象的场景
场景描述: 快照读 + 更新操作导致的特殊幻读
实验表数据:
id | name | score |
---|---|---|
1 | 小林 | 50 |
2 | 小明 | 60 |
3 | 小红 | 70 |
4 | 小蓝 | 80 |
步骤 1: 事务 A 查询不存在的记录
1 | # 事务 A |
步骤 2: 事务 B 插入该记录并提交
1 | # 事务 B |
步骤 3: 事务 A 更新并查询该记录
1 | # 事务 A |
时序图:
特殊幻读原因: 当事务 A 更新 id=5 的记录后,该记录的事务 ID 变成了事务 A 的 ID,因此在事务 A 中也变得可见,导致前后查询结果不一致。
第二个发生幻读现象的场景
场景描述: 混合使用快照读和当前读
步骤:
- T1 时刻:事务 A 执行快照读语句
SELECT * FROM t_test WHERE id > 100
得到 3 条记录 - T2 时刻:事务 B 插入一条 id=200 的记录并提交
- T3 时刻:事务 A 执行当前读语句
SELECT * FROM t_test WHERE id > 100 FOR UPDATE
得到 4 条记录
混合读取问题: 同一事务内混用快照读和当前读,会因为读取数据的机制不同而导致幻读。
避免幻读的最佳实践
具体建议:
- 使用
SELECT ... FOR UPDATE
锁定目标数据范围 - 保持事务简短,避免长时间持有锁
- 避免在同一事务中混用快照读和当前读
- 如果不需要避免幻读,考虑使用"读已提交"隔离级别提高并发性
总结
幻读问题的解决方案对比
查询类型 | 解决机制 | 是否完全解决 | 限制条件 |
---|---|---|---|
快照读 | MVCC 机制 | 否 | 更新操作会使新记录在事务内可见 |
当前读 | next-key 锁 | 否 | 需要在事务开始时就执行当前读 |
关键要点
-
MySQL InnoDB 的可重复读隔离级别采用两种机制避免幻读:
- 快照读:使用 MVCC 机制
- 当前读:使用 next-key 锁(记录锁+间隙锁)
-
幻读仍可能在以下场景出现:
- 事务内对其他事务新插入的记录执行更新操作
- 同一事务内混用快照读和当前读
-
避免幻读的最佳实践:
- 尽量在事务开始后立即使用
SELECT ... FOR UPDATE
锁定相关记录 - 保持事务简短,减少锁冲突
- 明确业务需求,选择合适的隔离级别
- 尽量在事务开始后立即使用