MySQL RR 中依然存在幻读的场景
1 MySQL RR 中依然存在幻读的场景
MySQL 的 REPEATABLE READ
(RR) 隔离级别通过临键锁 (Next-Key Lock) 机制,在很大程度上解决了幻读问题。不过在某些特定场景下,“幻读”现象依然会发生。
要理解这一点,首先必须区分两种读取方式:
-
快照读 (Snapshot Read):普通的
SELECT
语句就是快照读。它不加锁,读取的是事务开始时生成的 Read View (一致性视图),保证了在一个事务中多次读取的结果是一致的。 -
当前读 (Current Read):会加锁的读取操作,读取的是数据库中最新的已提交版本。以下语句都属于当前读:
-
SELECT ... FOR UPDATE
-
SELECT ... FOR SHARE
(在 MySQL 8.0 中是LOCK IN SHARE MODE
) -
INSERT
,UPDATE
,DELETE
(这些操作在执行前都需要先“读”到数据,所以也是当前读)
-
在 RR 隔离级别下,幻读依然存在的典型场景,就是“快照读”和“当前读”混合使用的时候。
1.1 核心场景:快照读无法“看”见,但当前读却能“摸”到
可以通过一个经典的例子来复现这个场景。
准备数据:
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(20)
);
INSERT INTO users VALUES (10, 'Alice'), (30, 'Cathy');
事务A (你的会话) 和 事务B (另一个会-话) 按以下步骤操作:
步骤 | 事务 A (你的会话) | 事务 B (另一个会话) | 说明 |
---|---|---|---|
T1 | BEGIN; SELECT * FROM users WHERE id > 5; 结果: (10, ‘Alice’), (30, ‘Cathy’) |
事务A开启,执行一次快照读,生成了 Read View。此时只能看到 id=10 和 id=30 的数据。 | |
T2 | BEGIN; INSERT INTO users VALUES (20, 'Bob'); COMMIT; |
事务B插入了一条新数据 (20, 'Bob') 并提交。这条数据对于事务A的快照读来说是“不可见”的。 |
|
T3 | SELECT * FROM users WHERE id > 5; 结果: (10, ‘Alice’), (30, ‘Cathy’) |
事务A再次执行快照读。由于 Read View 不变,结果与 T1 完全一致。到目前为止,没有幻读。 | |
T4 | UPDATE users SET name = 'Bob_Updated' WHERE id = 20; 结果: Query OK, 1 row affected. |
幻读出现! 事务A尝试对它“看不见”的 id=20 的记录执行 UPDATE (这是一个当前读)。它竟然成功锁定了并更新了这条记录! |
|
T5 | SELECT * FROM users WHERE id > 5; 结果: (10, ‘Alice’), (20, ‘Bob_Updated’), (30, ‘Cathy’) |
在执行了当前读(UPDATE)之后,事务A的 Read View 被更新了。此时再执行快照读,就能看到 id=20 这条“幻影”记录了。 |
1.2 为什么会这样?
-
快照读的“不变性”:在 T3 步骤,事务 A 的
SELECT
遵循了 RR 隔离级别的承诺,它读取的是事务开始时的快照,所以看不到事务 B 提交的新数据。 -
当前读的“真实性”:在 T4 步骤,
UPDATE
是一个当前读操作。为了保证数据的一致性和完整性,它必须读取并锁定数据库中最新的、已提交的数据。此时,事务 B 插入的id=20
已经真实存在于数据库中,所以UPDATE
语句可以找到并锁定它。
结论:
MySQL 的 RR 隔离级别通过临键锁,确实阻止了在一个事务中,两次执行相同的当前读(如 SELECT ... FOR UPDATE
)之间,出现新的记录。
但是,它无法阻止一个快照读和一个当前读之间的数据不一致。当你的事务逻辑中混合了这两种读取方式时,就可能遇到这种“幻读”现象:你明明用 SELECT
看不到某条记录,但用 UPDATE
或 DELETE
却能操作它。
这并非是 MySQL 的 Bug,而是 MVCC (多版本并发控制) 和锁机制共同作用下的设计结果,是在性能和一致性之间的一种权衡。如果想完全杜绝任何形式的幻读,就需要使用 SERIALIZABLE
(可串行化) 隔离级别,但这会极大地降低数据库的并发性能。