结论先行: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 去判断数据的可见性。回到上面的例子中:
- 事务A在第2步执行
SELECT时,创建了一个 Read View。 - 事务B在第3步插入的新数据,其行版本上的事务ID(
DB_TRX_ID)对于事务A的 Read View 来说,是“在 Read View 创建之后才出现的”。 - 因此,在第5步,当事务A再次执行
SELECT时,它仍然使用旧的 Read View。根据可见性判断算法,新插入的'新员工'那一行数据对事务A是不可见的。 - 所以,事务A第二次查询的结果依然是 5 条记录。
结论:对于快照读,MVCC 完美地避免了幻读的发生。
- 事务A在第2步执行
场景二:当前读 (Current Read)
什么是当前读? 指的是那些需要读取数据库最新版本数据,并且需要加锁的操作。典型的当前读包括:
SELECT ... LOCK IN SHARE MODE(共享锁)SELECT ... FOR UPDATE(排他锁)UPDATEDELETEINSERT
如何防止幻读? 使用 Next-Key Lock (临键锁)。
工作原理: Next-Key Lock 是 Record Lock (行锁) 和 Gap Lock (间隙锁) 的结合体。
- 行锁:锁定某条已经存在的记录。
- 间隙锁:锁定一个范围,但不包括记录本身。它锁住的是一个“间隙”,防止其他事务在这个间隙中进行
INSERT操作。
当我们的事务执行一个当前读时,InnoDB 不仅会对查询到的已有记录加上行锁(Record Lock),还会对这些记录所在范围的“间隙”加上间隙锁(Gap Lock)。
再次回到上面的例子,如果事务A执行的是当前读:
BEGIN;SELECT * FROM employees WHERE department_id = 10 FOR UPDATE;- 此时,InnoDB 不仅会锁住
department_id = 10的那 5 条记录,还会锁定相关的间隙。例如,如果department_id是索引,它可能会锁住(上一个部门ID, 10)这个区间,以及(10, 下一个部门ID)这个区间的一部分。 - 现在,当事务B试图执行
INSERT INTO employees (name, department_id) VALUES ('新员工', 10);时,它需要在这个被锁定的间隙中插入数据。 - 由于间隙已经被事务A的 Gap Lock 锁住,事务B的
INSERT操作会被阻塞,直到事务A提交或回滚释放了锁。 - 这样一来,事务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 默认隔离级别的原因。