MySQL InnoDB 在 REPEATABLE READ (RR) 隔离级别下,通过 MVCC 和 间隙锁(Next-Key Lock) 在很大程度上避免了幻读,但并非在所有场景下都能 100% 解决。
幻读的核心问题:一个事务在重新执行同一个查询时,看到了第一次查询时没有的新的行(这些行是由其他已提交事务插入的)。
在 RR 级别下,单纯的快照读 (SELECT) 是不会发生幻读的,因为它始终读取事务开始时的快照。幻读通常发生在当前读与快照读混合使用的场景中。
无法解决幻读的典型场景举例
让我们来看一个最经典的例子。假设我们有一张表 users:
| id (主键) | name | age |
|---|---|---|
| 1 | Alice | 20 |
| 5 | Bob | 25 |
| 10 | Carol | 30 |
事务执行序列如下:
| 时间点 | 事务A (Trx ID=50) | 事务B (Trx ID=60) |
|---|---|---|
| T1 | START TRANSACTION; |
|
| T2 | SELECT * FROM users WHERE age > 20; (快照读) |
|
结果: id=5, Bob, 25 id=10, Carol, 30 |
||
| T3 | START TRANSACTION; |
|
| T4 | INSERT INTO users (id, name, age) VALUES (7, 'Dave', 28); COMMIT; (提交!) |
|
| T5 | UPDATE users SET name = 'Hi' WHERE age > 20; |
|
| InnoDB 报告: “2 rows affected” | ||
| T6 | SELECT * FROM users WHERE age > 20; (快照读) |
|
结果: id=5, Hi, 25 id=10, Hi, 30 id=7, Hi, 28 👈 幻读出现了! |
||
| T7 | COMMIT; |
详细过程分析
-
T2 (事务A - 快照读):
- 事务A 执行第一次查询
WHERE age > 20。 - InnoDB 为其生成一个 Read View。此时,它只能看到在它开始之前已提交的数据,即 id=5 和 id=10 这两行。
- 结果符合预期,没有幻读。
- 事务A 执行第一次查询
-
T4 (事务B - 插入并提交):
- 事务B 插入了一条新记录
(7, 'Dave', 28),它满足age > 20的条件,并且成功提交。这条新数据成为数据库中的最新状态。
- 事务B 插入了一条新记录
-
T5 (事务A - 当前读):
- 这是最关键的一步。事务A 执行了一个
UPDATE语句。 UPDATE、DELETE、INSERT都属于当前读。它们不是快照读!- 当执行
UPDATE ... WHERE age > 20时,InnoDB 必须找到所有当前最新版本中满足age > 20的记录以便更新。它会读取最新的已提交数据。 - 因此,它看到了事务B 刚刚提交的
id=7这条新记录。 - InnoDB 会为所有它找到的记录(id=5, 10, 7)加上锁(间隙锁会锁住范围,防止其他事务再插入,但已插入的且已提交的它管不了),然后进行更新。
- 所以,
UPDATE操作成功更新了3行数据(包括事务B插入的那一行)。UPDATE操作本身看不到幻读,因为它总是处理最新数据。
- 这是最关键的一步。事务A 执行了一个
-
T6 (事务A - 快照读):
- 事务A 再次执行相同的
SELECT查询。 - 在 RR 级别下,它依然复用 T2 时刻生成的旧 Read View。
- 但是,
id=7这行记录已经被当前事务A自己的UPDATE语句修改了!记住 MVCC 的可见性规则第一条:如果数据版本的 DB_TRX_ID 等于当前事务的 ID,则当前事务总是可见的。 - 虽然新插入的
id=7最初是由事务B (Trx-ID=60) 创建的,但它现在已经被事务A (Trx-ID=50) 修改了。它的DB_TRX_ID变成了 50。 - 因此,对于事务A 来说,这行数据是“我自己修改的”,所以对我可见。
- 最终,查询结果变成了三条记录。事务A 看到了一个“幻影行”。
- 事务A 再次执行相同的
为什么说这是幻读?
- 第一次读(T2):事务A 看到了 2 行数据。
- 中间操作:它试图修改所有满足条件的行。
- 第二次读(T6):它发现自己修改了 3 行,并且查询结果也变成了 3 行。
这个“多出来的一行”就是幻读。事务A 的逻辑前提被打破了:它本以为自己在操作 2 行数据,但实际上操作了 3 行。
总结:RR 级别下幻读发生的条件
- 并发事务:有两个并发的事务在操作。
- 其他事务插入并提交:另一个事务插入了新的行并成功提交。
- 当前事务进行当前读:当前事务使用了当前读(如
UPDATE,DELETE,SELECT ... FOR UPDATE)来操作一个范围。当前读会看到其他事务已提交的新数据。 - 当前事务再次快照读:当前事务随后再次进行快照读时,会因为自己修改了这些新数据(使得其版本号变为自己的事务ID)而看到它们,从而导致幻读。
如何绝对避免幻读?
如果整个事务中的所有操作都使用当前读(例如全部使用 SELECT ... FOR UPDATE),那么由于间隙锁(Gap Lock)和临键锁(Next-Key Lock)的存在,其他事务无法在查询范围内插入新数据,从而可以彻底避免幻读。但这会严重牺牲并发性能。
因此,MySQL InnoDB 的 RR 级别提供的防幻读保障是:纯快照读不会幻读,但当前读可能会引入幻读。如果要实现最高级别的隔离性(序列化),需要应用程序谨慎地使用当前读来手动加锁。
