RR隔离级别下幻读未被完全消除,是因为Next-Key Lock仅在当前读时生效,而快照读不加锁且不受其保护;其加锁范围动态依赖索引类型、查询条件及B+树遍历路径,并非固定锁定某几行。

RR 隔离级别下幻读没被完全消灭,是因为 Next-Key Lock 不覆盖所有写操作场景
MySQL 的 RR 级别确实能防止“由并发 INSERT 引起的典型幻读”,但不是靠 MVCC,而是靠 Next-Key Lock 对索引区间加锁。问题在于:它只在**当前读**(SELECT ... FOR UPDATE、UPDATE、DELETE)时生效;而普通 SELECT 是快照读,不加锁,也不受 Next-Key Lock 保护。
常见错误现象:SELECT COUNT(*) FROM t WHERE status = 'pending' 返回 5 行 → 应用判断后执行 INSERT INTO t ... → 另一事务刚好插入一条新 pending 记录并提交 → 当前事务再查仍是 5 行(MVCC 快照未更新),但业务逻辑已按“当时只有 5 条”做了决策,结果出现语义幻读。
- 这种“先查后插/删”的应用层逻辑,Next-Key Lock 完全不干预
- RR 下的快照读看到的是事务启动时刻的版本,与锁无关
- 只有显式加锁的当前读才触发 Next-Key Lock,且仅作用于索引扫描路径上的节点
Next-Key Lock 的实际加锁范围取决于索引类型和查询条件
它不是固定锁某几行,而是动态根据 B+ 树遍历过程决定锁哪些(gap_start, record] 区间。关键看是否命中索引、是否唯一、是否存在对应记录。
以主键 id 为例,表中现有 id 值为 10、20、30:
-
SELECT * FROM t WHERE id = 20 FOR UPDATE→ 加REC_NOT_GAP行锁(只锁id=20这一行) -
SELECT * FROM t WHERE id = 25 FOR UPDATE→ 加GAP锁(锁住间隙(20, 30)) -
SELECT * FROM t WHERE id BETWEEN 20 AND 30 FOR UPDATE→ 加两个 Next-Key 锁:(10, 20]和(20, 30] - 若用非索引字段(如
name = 'Alice')→ 退化为全表扫描 + 表级意向锁,Next-Key Lock 失效
非唯一索引上等值查询会锁更大范围,容易误伤并发插入
普通二级索引不唯一,InnoDB 无法确定“只有一条匹配”,所以会向右扫描直到找到第一个不满足条件的索引项,把整个扫描区间都锁住。
例如索引 b 上有值 10、10、15、20,执行 SELECT * FROM t WHERE b = 10 FOR UPDATE:
- 会定位到第一个
b=10,然后继续向右扫描,直到碰到b=15 - 最终加 Next-Key 锁:
(-∞, 10](第一个 10 前的间隙 + 第一个 10)和(10, 15](覆盖第二个 10) - 这意味着
b=12或b=14的插入都会被阻塞,哪怕业务上它们完全无关 - 如果该查询还涉及回表(比如没走覆盖索引),还会对对应的主键加行锁,进一步扩大影响面
验证锁行为别靠猜,直接查 performance_schema.data_locks
锁的实际范围和类型,不同 MySQL 版本(尤其是 8.0.17 后)有细微差异,文档和博客总结可能过时。最可靠的方式是实时观察:
在事务未提交时执行:
SELECT LOCK_TRX_ID, LOCK_MODE, LOCK_TYPE, LOCK_DATA FROM performance_schema.data_locks WHERE OBJECT_SCHEMA = 'your_db' AND OBJECT_NAME = 'your_table';
LOCK_DATA 字段显示具体锁定的值或间隙(如 10、10, 15、supremum pseudo-record);LOCK_TYPE 显示是 RECORD、GAP 还是 NEXT-KEY;LOCK_MODE 中的 X,GAP 或 X,REC_NOT_GAP 直接说明锁性质。
注意:这个视图只反映当前活跃事务持有的锁,一旦 COMMIT 或 ROLLBACK,记录就消失 —— 所以务必在事务内查。
真正容易被忽略的是:Next-Key Lock 的“隐式性”和“组合性”。它不声明自己要锁什么,而是随索引扫描路径自然生成;你改一个查询条件、加一个索引、甚至只是升级小版本,锁范围都可能变化。线上遇到诡异阻塞或幻读,第一反应不该是调业务逻辑,而是先抓锁快照看实际加了什么。


















