幻读与 Next-Key Lock:可重复读隔离级别如何解决幻读

幻读与 Next-Key Lock:可重复读隔离级别如何解决幻读 在上一篇中我们全面梳理了 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 LockNext-Key Lock 锁定的是一个左开右闭的区间(a, b]包含对该区间内已有记录的记录锁防修改对这些记录之间间隙的间隙锁防插入它相当于在索引上“画地为牢”把范围查询锁住的每一段间隙都保护起来。示例假设表中有索引记录10,20,30。执行SELECT*FROMtWHEREidBETWEEN15AND25FORUPDATE;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 准备测试表USElibrary_db;CREATETABLEphantom_test(idINTPRIMARYKEY,nameVARCHAR(20))ENGINEInnoDB;INSERTINTOphantom_testVALUES(10,A),(20,B),(30,C);4.2 READ COMMITTED 下的幻读会话 ASETSESSIONTRANSACTIONISOLATIONLEVELREADCOMMITTED;STARTTRANSACTION;-- 第一次查询SELECT*FROMphantom_testWHEREidBETWEEN15AND25;-- 返回Empty set无记录会话 BINSERTINTOphantom_testVALUES(18,phantom);COMMIT;会话 A继续同一事务-- 第二次查询SELECT*FROMphantom_testWHEREidBETWEEN15AND25;-- 返回id18幻读COMMIT;因为 RC 下没有间隙锁B 插入成功A 看到了新行。4.3 REPEATABLE READ 下 Next-Key Lock 的阻止会话 ASETSESSIONTRANSACTIONISOLATIONLEVELREPEATABLEREAD;STARTTRANSACTION;SELECT*FROMphantom_testWHEREidBETWEEN15AND25FORUPDATE;-- 加锁区间10,20] 和 (20,30]会话 BINSERTINTOphantom_testVALUES(18,blocked);-- 会阻塞因为 18 落在 (10,20) 间隙中此时会话 B 会等待锁直到会话 A 提交或回滚。如果等待超时会报错ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction会话 ACOMMIT;会话 B 会在 A 提交后立即插入成功。这完美展示了 Next-Key Lock 对幻读的阻止效果。4.4 查看锁信息在阻塞期间可以在第三个会话中查看锁情况SELECTlock_type,lock_mode,lock_data,lock_statusFROMperformance_schema.data_locksWHEREobject_namephantom_test;你会看到X,GAP或X等锁模式以及被锁定的索引记录和间隙。4.5 清理DROPTABLEphantom_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 RRRC 无间隙锁存在幻读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 LockingMySQL 8.0 Reference Manual - Phantom RowsMySQL 8.0 Reference Manual - Gap Locks