MySQL: 可重复读隔离级别完全解决幻读了吗?

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
2
-- 假设在T1和T2时刻分别执行相同查询
SELECT * FROM t_test WHERE id > 100;

幻读的表现形式:

  • 前一次查询有 5 条记录,后一次变成了 6 条记录 ⟹ 幻读
  • 前一次查询有 5 条记录,后一次变成了 4 条记录 ⟹ 也是幻读

MySQL 如何避免幻读

快照读是如何避免幻读的?

快照读原理: InnoDB 通过 MVCC(多版本并发控制)实现可重复读隔离级别。

🔍 MVCC 工作流程图:

1
事务开始 ➡️ 创建ReadView ➡️ 后续查询使用相同ReadView ➡️ 保证数据一致性

关键机制:

  1. 事务启动后第一次查询创建一个 Read View
  2. 后续查询复用这个 Read View
  3. 通过 Read View 在 undo log 版本链中找到事务开始时的数据版本
  4. 即使其他事务插入新数据,也不会被看到

实验演示

表 t_stu 数据:

id name score
1 小林 50
2 小明 60
3 小红 70
4 小蓝 80

事务执行顺序:

1746620825818

快照读结论: 即使事务 B 中途插入了新记录,事务 A 的两次查询结果仍然一致,成功避免了幻读。

当前读是如何避免幻读的?

当前读的特点:总是读取数据的最新版本。

属于当前读的操作:

  • SELECT ... FOR UPDATE
  • UPDATE
  • INSERT
  • DELETE

当前读必须读取最新数据的原因:避免数据冲突(如更新已被删除的记录)。

为什么当前读可能导致幻读

如果不加任何限制,当前读会出现这种情况:

1746620834159

问题: 事务 A 的两次查询返回不同结果,出现了幻读。

InnoDB 的解决方案:间隙锁

为解决当前读的幻读问题,InnoDB 引入了间隙锁(Gap Lock)

1746620841733

next-key lock 工作原理:

1746620850469

  1. 事务 A 执行 SELECT ... FOR UPDATE 时加上 next-key lock (id 范围: (2, +∞])
  2. 事务 B 尝试插入记录时,发现区间被锁
  3. 事务 B 生成插入意向锁并等待
  4. 直到事务 A 提交,事务 B 才能插入

间隙锁总结: 通过锁定可能插入记录的间隙,阻止其他事务插入数据,从而避免幻读。

幻读被完全解决了吗?

结论: 可重复读隔离级别很大程度上避免了幻读,但仍有特殊场景会发生幻读。

第一个发生幻读现象的场景

场景描述: 快照读 + 更新操作导致的特殊幻读

实验表数据:

id name score
1 小林 50
2 小明 60
3 小红 70
4 小蓝 80

步骤 1: 事务 A 查询不存在的记录

1
2
3
4
5
6
# 事务 A
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from t_stu where id = 5;
Empty set (0.01 sec)

步骤 2: 事务 B 插入该记录并提交

1
2
3
4
5
6
7
8
9
# 事务 B
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into t_stu values(5, '小美', 18);
Query OK, 1 row affected (0.00 sec)

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

步骤 3: 事务 A 更新并查询该记录

1
2
3
4
5
6
7
8
9
10
11
12
# 事务 A
mysql> update t_stu set name = '小林 coding' where id = 5;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1 Changed: 1 Warnings: 0

mysql> select * from t_stu where id = 5;
+----+--------------+------+
| id | name | age |
+----+--------------+------+
| 5 | 小林coding | 18 |
+----+--------------+------+
1 row in set (0.00 sec)

时序图:

1746620883100

特殊幻读原因: 当事务 A 更新 id=5 的记录后,该记录的事务 ID 变成了事务 A 的 ID,因此在事务 A 中也变得可见,导致前后查询结果不一致。

第二个发生幻读现象的场景

场景描述: 混合使用快照读和当前读

步骤:

  1. T1 时刻:事务 A 执行快照读语句 SELECT * FROM t_test WHERE id > 100 得到 3 条记录
  2. T2 时刻:事务 B 插入一条 id=200 的记录并提交
  3. T3 时刻:事务 A 执行当前读语句 SELECT * FROM t_test WHERE id > 100 FOR UPDATE 得到 4 条记录

混合读取问题: 同一事务内混用快照读和当前读,会因为读取数据的机制不同而导致幻读。

避免幻读的最佳实践

推荐做法: 在事务开始后,立即执行当前读操作,锁定相关记录范围。

具体建议:

  1. 使用 SELECT ... FOR UPDATE 锁定目标数据范围
  2. 保持事务简短,避免长时间持有锁
  3. 避免在同一事务中混用快照读和当前读
  4. 如果不需要避免幻读,考虑使用"读已提交"隔离级别提高并发性

总结

幻读问题的解决方案对比

查询类型 解决机制 是否完全解决 限制条件
快照读 MVCC 机制 更新操作会使新记录在事务内可见
当前读 next-key 锁 需要在事务开始时就执行当前读

关键要点

  1. MySQL InnoDB 的可重复读隔离级别采用两种机制避免幻读:

    • 快照读:使用 MVCC 机制
    • 当前读:使用 next-key 锁(记录锁+间隙锁)
  2. 幻读仍可能在以下场景出现:

    • 事务内对其他事务新插入的记录执行更新操作
    • 同一事务内混用快照读和当前读
  3. 避免幻读的最佳实践:

    • 尽量在事务开始后立即使用 SELECT ... FOR UPDATE 锁定相关记录
    • 保持事务简短,减少锁冲突
    • 明确业务需求,选择合适的隔离级别
记住: MySQL的可重复读隔离级别并非完全解决了幻读,而是在大多数常见场景下避免了幻读。了解其工作原理和边界情况,才能设计出更可靠的数据库应用。