在上一篇中,我们全面梳理了 InnoDB 的锁分类,认识了记录锁、间隙锁、临键锁和插入意向锁。其中临键锁(Next-Key Lock)被反复提及------它是 InnoDB 在 REPEATABLE READ 隔离级别下默认的行锁形式,也是防止幻读的核心武器。
本文将聚焦于幻读这一特殊的并发问题,深入剖析:
- 幻读的严格定义与场景再现
- Next-Key Lock 的工作原理
- 为什么 RR 级别能通过它防止大部分幻读
- 幻读与 Serializable 隔离级别的对比
- 实战:演示幻读的发生与 Next-Key Lock 的阻止效果
读完本文,你将能清晰解释"InnoDB 默认隔离级别为什么能防幻读",并理解其实现机制。
1. 再识幻读:同一个事务,不同的结果集
回顾一下三类并发问题:
- 脏读:读到未提交的数据。
- 不可重复读:同一行被修改并提交,两次读的值不同。
- 幻读 :同一范围查询,两次返回的行数不同。
幻读的"幻"在于,第二次查询时莫名其妙多出了几行(或少了),就像出现了幻觉。它通常由其他事务的 INSERT 或 DELETE 引起,不是修改已有行,而是改变结果集的大小。
典型场景:
- 事务 T1 查询"所有余额大于 500 的账户",返回 3 行。
- 事务 T2 插入一行余额为 600 的新账户,并提交。
- 事务 T1 再次执行同一查询,返回 4 行。
如果 T1 基于第一次查询的结果做了汇总计算(比如 SUM),就会发现两次总和不一致。这种不一致可能导致业务逻辑出错。
2. Next-Key Lock:行锁 + 间隙锁的合体
2.1 行锁为什么不够?
普通的记录锁 只锁定已存在的行 。如果 T1 对 balance > 500 的所有现有行加了行锁,T2 仍然可以插入一条新的 balance = 600 的记录------因为这条记录还不存在,没有任何锁阻止它。于是幻读依然发生。
要阻止插入,必须锁住行与行之间的"间隙"。这就是间隙锁(Gap Lock)的作用。
2.2 Next-Key Lock = Record Lock + Gap Lock
Next-Key Lock 锁定的是一个左开右闭 的区间 (a, b],包含:
- 对该区间内已有记录的记录锁(防修改)
- 对这些记录之间间隙的间隙锁(防插入)
它相当于在索引上"画地为牢",把范围查询锁住的每一段间隙都保护起来。
示例 :假设表中有索引记录 10, 20, 30。执行:
sql
SELECT * FROM t WHERE id BETWEEN 15 AND 25 FOR UPDATE;
InnoDB 会加以下 Next-Key Locks:
(10, 20]区间(覆盖了 15~20)(20, 30]区间(覆盖了 20~25)
此外,还会加上间隙锁 (20, 25) 以及可能的一些额外锁,实际上会锁住 (10, 20] 和 (20, 30],有效阻止其他事务在 (10, 30) 间插入新行,以及修改 20 和 30。
2.3 如何防止幻读?
在上述例子中,如果 T2 试图插入 id = 18 或 id = 25,插入意向锁会试图在 (10, 20) 或 (20, 30) 间隙上加锁,但这些间隙已被 T1 的 Next-Key Lock 保护,插入意向锁会被阻塞。因此 T2 无法在 T1 的两次查询之间插入新行,幻读就此被阻止。
3. 隔离级别与 Next-Key Lock 的关系
- READ COMMITTED:不使用间隙锁,只使用记录锁。因此无法防止幻读。
- REPEATABLE READ:默认使用 Next-Key Lock,防止幻读。
- SERIALIZABLE :最严格,所有读操作都隐式加共享锁(类似
SELECT ... FOR SHARE),所有读写完全串行化,自然也防幻读。
InnoDB 的 RR 通过 Next-Key Lock 在大部分场景下阻止了幻读,但并非绝对。某些边缘情况(如先快照读后当前读,或者不同索引条件)仍可能出现类似幻读的现象。这点在后续 MVCC 篇会结合 ReadView 深入探讨。
Serializable 与 RR 的区别:
- Serializable 强制所有读取都加锁,导致读读也可能阻塞(如果使用 FOR UPDATE 的语义),并发度极低。
- RR 下的普通
SELECT是无锁的快照读(通过 MVCC),只有显式加锁的SELECT ... FOR UPDATE/SHARE或 DML 操作才会使用 Next-Key Lock。因此并发度远高于 Serializable。
4. 实战:演示幻读与 Next-Key Lock 的阻止
我们来实际观察 RC 下的幻读现象,以及 RR 下 Next-Key Lock 如何防幻读。
4.1 准备测试表
sql
USE library_db;
CREATE TABLE phantom_test (
id INT PRIMARY KEY,
name VARCHAR(20)
) ENGINE=InnoDB;
INSERT INTO phantom_test VALUES (10, 'A'), (20, 'B'), (30, 'C');
4.2 READ COMMITTED 下的幻读
会话 A:
sql
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- 第一次查询
SELECT * FROM phantom_test WHERE id BETWEEN 15 AND 25;
-- 返回:Empty set(无记录)
会话 B:
sql
INSERT INTO phantom_test VALUES (18, 'phantom');
COMMIT;
会话 A(继续同一事务):
sql
-- 第二次查询
SELECT * FROM phantom_test WHERE id BETWEEN 15 AND 25;
-- 返回:id=18(幻读!)
COMMIT;
因为 RC 下没有间隙锁,B 插入成功,A 看到了新行。
4.3 REPEATABLE READ 下 Next-Key Lock 的阻止
会话 A:
sql
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM phantom_test WHERE id BETWEEN 15 AND 25 FOR UPDATE;
-- 加锁区间(10,20] 和 (20,30]
会话 B:
sql
INSERT INTO phantom_test VALUES (18, 'blocked');
-- 会阻塞!因为 18 落在 (10,20) 间隙中
此时会话 B 会等待锁,直到会话 A 提交或回滚。如果等待超时,会报错:
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
会话 A:
sql
COMMIT;
会话 B 会在 A 提交后立即插入成功。这完美展示了 Next-Key Lock 对幻读的阻止效果。
4.4 查看锁信息
在阻塞期间,可以在第三个会话中查看锁情况:
sql
SELECT
lock_type, lock_mode, lock_data, lock_status
FROM performance_schema.data_locks
WHERE object_name = 'phantom_test';
你会看到 X,GAP 或 X 等锁模式,以及被锁定的索引记录和间隙。
4.5 清理
sql
DROP TABLE phantom_test;
5. Next-Key Lock 的局限与代价
虽然 Next-Key Lock 很有力,但并非完美:
- 锁范围可能扩大:如果 WHERE 条件无法使用精确索引,导致扫描全表,Next-Key Lock 会锁住整个索引的所有间隙和记录,接近于表锁。
- 影响并发插入:被锁住的间隙内,其他事务的 INSERT 会被阻塞,可能导致写入性能下降。
- 死锁风险增加:多个事务各自持有一些间隙锁,又等待对方释放,形成死锁。
因此,在业务层设计查询时,要尽量确保 WHERE 条件能走合适的索引,避免大范围扫描导致的锁膨胀。
6. 小结
本文围绕幻读与 Next-Key Lock 进行了深入探讨:
- 幻读:同一事务内两次范围查询结果集的行数变化,由其他事务插入/删除引起。
- Next-Key Lock = 记录锁 + 间隙锁,锁定左开右闭区间,是 InnoDB 在 RR 级别防止幻读的核心机制。
- RC vs RR:RC 无间隙锁,存在幻读;RR 使用 Next-Key Lock 阻止插入,实现防幻读。
- Serializable:通过强制读锁将并发度降到最低,完全防幻读,但代价巨大。
- 实战:亲手在 RC 下再现幻读,在 RR 下用 Next-Key Lock 成功阻止。
通过本文,你应该对"InnoDB 如何解决幻读"有了直观且深入的理解。下一篇我们将进入另一个并发难题------死锁的产生、检测与避免,学习如何从日志中诊断死锁,以及如何通过设计规范避免它。
思考题:
- 为什么在 RC 隔离级别下
UPDATE也可能引发幻读?举个例子。 - 如果
phantom_test表上没有索引,SELECT ... FOR UPDATE会加什么锁? - 尝试修改事务隔离级别为
SERIALIZABLE,执行相同的测试,观察锁等待现象。
参考资料
- MySQL 8.0 Reference Manual - InnoDB Next-Key Locking
- MySQL 8.0 Reference Manual - Phantom Rows
- MySQL 8.0 Reference Manual - Gap Locks