Skip to content

结论先行:MySQL 的 RR 级别,在很大程度上解决了幻读问题,但并非100%彻底解决。 它通过两种核心技术的结合来做到这一点:MVCC (多版本并发控制)Next-Key Locking (临键锁)

我们分步来解析。


1. 什么是幻读?(标准定义)

首先,我们要准确理解幻读。

幻读关注的是行数的增减。在一个事务中,前后两次执行相同的范围查询,但第二次查询返回了第一次查询时不存在的、由其他事务新插入并提交的行。这些“凭空出现”的行就像“幻影”一样,因此被称为幻读。

举个经典例子:

假设我们有一个 employees 表,我们要查询 department_id = 10 的所有员工。

事务A (我的事务)事务B (别人的事务)
1. 启动事务
BEGIN;
2. 第一次查询
SELECT * FROM employees WHERE department_id = 10;
(结果返回 5 条记录)
3. 启动事务并插入新数据
BEGIN;
INSERT INTO employees (name, department_id) VALUES ('新员工', 10);
4. 提交事务
COMMIT;
5. 第二次查询
SELECT * FROM employees WHERE department_id = 10;
(幻读发生:结果返回了 6 条记录,包括了'新员工')
6. 提交/回滚
COMMIT;

根据 SQL 标准,可重复读(RR)级别是允许出现幻读的。但 MySQL 的 InnoDB 引擎对此做了强化。


2. MySQL 如何防止幻读?(两种场景,两种武器)

MySQL 将读操作分为两类,并用不同的机制来应对幻读。

场景一:快照读 (Snapshot Read)

  • 什么是快照读? 就是我们最常用的、不加任何锁的 SELECT 查询。例如:SELECT * FROM employees WHERE ...

  • 如何防止幻读? 使用 MVCC (多版本并发控制)

  • 工作原理: 在 RR 级别下,当事务A第一次执行 SELECT 时,会生成一个Read View (一致性视图)。这个 Read View 会被整个事务所持有和复用。

    在这个 Read View 中,记录了那一刻系统中所有活跃(未提交)的事务ID。后续所有 SELECT 操作都会使用这个固定的 Read View 去判断数据的可见性。

    回到上面的例子中:

    1. 事务A在第2步执行 SELECT 时,创建了一个 Read View。
    2. 事务B在第3步插入的新数据,其行版本上的事务ID(DB_TRX_ID)对于事务A的 Read View 来说,是“在 Read View 创建之后才出现的”。
    3. 因此,在第5步,当事务A再次执行 SELECT 时,它仍然使用旧的 Read View。根据可见性判断算法,新插入的'新员工'那一行数据对事务A是不可见的。
    4. 所以,事务A第二次查询的结果依然是 5 条记录

    结论:对于快照读,MVCC 完美地避免了幻读的发生。

场景二:当前读 (Current Read)

  • 什么是当前读? 指的是那些需要读取数据库最新版本数据,并且需要加锁的操作。典型的当前读包括:

    • SELECT ... LOCK IN SHARE MODE (共享锁)
    • SELECT ... FOR UPDATE (排他锁)
    • UPDATE
    • DELETE
    • INSERT
  • 如何防止幻读? 使用 Next-Key Lock (临键锁)

  • 工作原理: Next-Key Lock 是 Record Lock (行锁)Gap Lock (间隙锁) 的结合体。

    • 行锁:锁定某条已经存在的记录。
    • 间隙锁:锁定一个范围,但不包括记录本身。它锁住的是一个“间隙”,防止其他事务在这个间隙中进行 INSERT 操作。

    当我们的事务执行一个当前读时,InnoDB 不仅会对查询到的已有记录加上行锁(Record Lock),还会对这些记录所在范围的“间隙”加上间隙锁(Gap Lock)。

    再次回到上面的例子,如果事务A执行的是当前读:

    1. BEGIN;
    2. SELECT * FROM employees WHERE department_id = 10 FOR UPDATE;
    3. 此时,InnoDB 不仅会锁住 department_id = 10 的那 5 条记录,还会锁定相关的间隙。例如,如果 department_id 是索引,它可能会锁住 (上一个部门ID, 10) 这个区间,以及 (10, 下一个部门ID) 这个区间的一部分。
    4. 现在,当事务B试图执行 INSERT INTO employees (name, department_id) VALUES ('新员工', 10); 时,它需要在这个被锁定的间隙中插入数据。
    5. 由于间隙已经被事务A的 Gap Lock 锁住,事务B的 INSERT 操作会被阻塞,直到事务A提交或回滚释放了锁。
    6. 这样一来,事务A就永远不会在它的生命周期内看到有新的 department_id = 10 的员工被插入。

    结论:对于当前读,Next-Key Lock 通过锁定范围,有效地阻止了其他事务的插入操作,从而避免了幻读。


3. 幻读被彻底解决了吗?一个特例

尽管 MySQL 已经做了这么多努力,但在一种特殊情况下,你仍然会“感觉”到幻读的存在。这种情况发生在一个事务中混合使用了快照读和当前读

场景复现:

事务A事务B
1. 启动事务,进行快照读
BEGIN;
SELECT COUNT(*) FROM employees WHERE department_id = 10;
(结果是 5)
2. 事务B插入并提交
BEGIN;
INSERT INTO employees (name, department_id) VALUES ('新员工', 10);
COMMIT;
3. 在事务A内部,对同一范围进行更新(当前读)
UPDATE employees SET name = '老员工' WHERE department_id = 10;
(你会惊奇地发现,Query OK, 6 rows affected
4. 再次进行快照读
SELECT COUNT(*) FROM employees WHERE department_id = 10;
(结果仍然是 5!)
COMMIT;

分析:

  • 第1步是快照读,基于旧的 Read View,看到的是 5 条记录。
  • 第3步是当前读,UPDATE 必须读取最新的数据来加锁,所以它看到了事务B提交的'新员工',并把它也更新了。因此影响了 6 行。
  • 第4步又是快照读,依然使用最初的那个 Read View,所以它还是只能看到 5 条记录。

在这个事务A看来,自己明明只看到了5条数据,却更新了6条。这条看不见的“新员工”就像一个真正的“幻影”。这就是在 RR 级别下,幻读遗留的一个“尾巴”。

总结

读类型使用技术效果
快照读 (Plain SELECT)MVCC能防止幻读。通过固定的 Read View,保证事务内多次查询结果一致。
当前读 (SELECT...FOR UPDATE, UPDATE, DELETE)Next-Key Lock能防止幻读。通过间隙锁锁定范围,阻止其他事务插入数据。
混合读写MVCC + Next-Key Lock可能出现数据不一致的“幻觉”。快照读看到的是旧版本,当前读操作的是新版本,导致结果看似矛盾。

总而言之,MySQL 的 InnoDB 引擎在 RR 隔离级别下,通过 MVCC 和 Next-Key Lock 的组合拳,已经解决了绝大部分的幻读问题,其隔离性已经非常接近于标准的**序列化(Serializable)**级别了,这也是为什么它能成为 MySQL 默认隔离级别的原因。

Released under the MIT License.