Skip to content

InnoDB 行锁锁的是什么:索引记录、间隙与扫描范围 ​

一条只修改 1 行的 UPDATE,在只有单列索引的订单表上锁住了 4,005 条索引记录,同一商户的其他订单全部改不了。加上联合索引后,同一条语句只剩 3 个锁。InnoDB 锁的是扫描过的索引,不是最终修改的那几行。

本文用 performance_schema.data_locks 直接读出每条语句持有的锁,把记录锁、间隙锁、Next-Key Lock 的边界,以及 RR 与 RC 的差异讲清楚。实验环境是 MySQL 8.4.11,默认隔离级别 REPEATABLE READ。

一、先说结论 ​

  • 锁加在索引记录上。语句通过哪个索引扫描,就锁这个索引上被访问到的记录;通过二级索引修改时,对应的主键记录也会被锁住。
  • RR 下的范围扫描使用 Next-Key Lock,也就是「记录锁 + 它前面的间隙」。间隙锁只阻止插入,不阻止修改已有记录。
  • 没有可用索引的条件会锁住全部记录和所有间隙,效果等同锁表;RC 下 InnoDB 会在判断不匹配后释放行锁,实测只剩匹配的那一行。
  • 间隙锁之间互相兼容。两个事务可以同时锁住同一个间隙,随后各自插入时就会死锁。「先 FOR UPDATE 查一下,不存在再插入」是这类死锁最常见的来源。
  • 缩小锁范围的办法和优化慢 SQL 的办法是同一个:让语句通过选择性高的索引直接定位到目标记录。

二、三种行锁 ​

锁data_locks 中的 lock_mode锁住什么阻止什么
记录锁(Record Lock)X,REC_NOT_GAP一条索引记录其他事务修改、删除、加锁读这条记录
间隙锁(Gap Lock)X,GAP两条索引记录之间的空隙,不含记录本身其他事务往这个空隙里插入
Next-Key LockX一条记录,加上它前面的间隙,即左开右闭区间以上两者

间隙锁的唯一目的是防止幻读:RR 下,同一个事务里两次加锁读取同一范围,结果必须一致,所以这个范围里不能插进新行。间隙锁之间不冲突,多个事务可以同时持有同一个间隙的锁,这一点在第五节会变成死锁的根源。

查看当前持有的锁:

sql
SELECT index_name, lock_type, lock_mode, lock_data
FROM performance_schema.data_locks
WHERE object_schema = 'lk';

三、实测:每条语句锁了什么 ​

测试表只有 5 行,主键和普通索引 k 的值都是 5、10、15、20、25,列 c 没有索引:

sql
CREATE TABLE t (id INT PRIMARY KEY, k INT, c INT, KEY idx_k (k));
INSERT INTO t VALUES (5,5,5),(10,10,10),(15,15,15),(20,20,20),(25,25,25);
510152025+∞主键 / 索引值id = 10主键等值,命中id = 12主键等值,未命中k = 10普通索引等值id 在 10..15主键范围c = 10无索引列,全表扫描记录锁间隙锁(只挡插入)阻塞一切写入RC 隔离级别下,上面所有间隙锁都消失;无索引的 UPDATE 在判断不匹配后会释放对应行锁,最终只锁 id = 10
图 1 · MySQL 8.4.11、RR 隔离级别下用 performance_schema.data_locks 读出的锁:方块是记录锁,横条是间隙锁;没有索引的条件会锁住全部记录和所有间隙
语句(RR)读出的锁实测阻塞实测放行
WHERE id = 10 FOR UPDATE主键 10:记录锁——
WHERE id = 12 FOR UPDATE主键 15:间隙锁,即 (10, 15)插入 11、14插入 16;id = 13 FOR UPDATE
WHERE k = 10 FOR UPDATEidx_k 10:Next-Key,即 (5, 10];idx_k 15:间隙锁;主键 10:记录锁插入 k=7、k=12插入 k=16;修改 id=15
WHERE id >= 10 AND id <= 15 FOR UPDATE主键 10:记录锁;主键 15:Next-Key,即 (10, 15]——
UPDATE … WHERE c = 10主键 5、10、15、20、25 和 supremum 全部是 Next-Key修改 id=25;插入 id=30—

几个值得注意的地方:

  • 唯一索引等值命中,只锁记录。id = 10 只有一个记录锁,不需要间隙锁,因为唯一约束已经保证不会再插进一个 10。
  • 等值未命中,锁的是间隙。id = 12 不存在,InnoDB 锁住 12 所在的间隙 (10, 15),防止别人插入 12。
  • 普通索引等值会锁两侧的间隙。k 不唯一,别人可能再插入一个 k=10,所以 10 两侧的间隙都要锁住。
  • 范围扫描到 15 就停了。有些资料说范围扫描会多锁下一条记录(这里是 20),实测 MySQL 8.4 没有。MySQL 8.0.18 的发布说明(Bug #29508068)提到,带范围条件的加锁读会多锁一行,最常见的情形已经修复,只锁与查询范围相交的记录和间隙。老版本上的结论不要直接套用。
  • 无索引条件锁住了全部。c 没有索引,只能扫描整个主键索引,扫描过的每条记录都加了 Next-Key Lock,连表尾的 supremum 伪记录也锁了,所以任何位置都插不进去。

四、RC 下锁少了多少 ​

同样的语句在 READ COMMITTED 下:

语句RRRC
WHERE k = 10 FOR UPDATE3 个锁,含两个间隙idx_k 10 与主键 10 两个记录锁,没有间隙锁
持有上一行的锁时插入 k=7、k=12阻塞通过
UPDATE … WHERE c = 10(无索引)5 条记录 + supremum 全部锁住只剩主键 10 一个记录锁
持有上一行的锁时修改 id=25、插入 id=30阻塞通过

官方文档对 RC 的说明有三点:除了外键检查和唯一键重复检查,不使用间隙锁;UPDATE 和 DELETE 在判断完 WHERE 条件后,释放不匹配行的记录锁;UPDATE 遇到已被别人锁住的行时,先读取最新的已提交版本判断是否匹配(半一致读,semi-consistent read),不匹配就不必等待。代价是 RC 允许幻读,binlog 只能使用 ROW 格式(设为 MIXED 时服务器会自动按 ROW 记录)。两种隔离级别的可见性差异见 InnoDB MVCC 与隔离级别。

RC 能缩小锁范围,但它掩盖不了缺索引的问题:无索引的 UPDATE 在 RC 下仍然要扫描全表,每一行都要先加锁再判断、再释放,只是持有时间变短了。

五、先查后插:间隙锁导致的死锁 ​

很多业务代码这样保证「不存在才插入」:

sql
BEGIN;
SELECT * FROM t WHERE id = 12 FOR UPDATE;   -- 查不到
INSERT INTO t VALUES (12, 12, 0);            -- 于是插入
COMMIT;

两个事务并发执行,一个查 12、一个查 13。两个值落在同一个间隙 (10, 15) 里:

事务 A事务 BSELECT … id=12 FOR UPDATE1SELECT … id=13 FOR UPDATE2INSERT id=123INSERT id=134两者都持有间隙锁 (10, 15)等待插入意向锁等待插入意向锁B 被回滚ERROR 1213实测:A 插入成功,B 收到 Deadlock found;同样的写法在 RC 下两个插入都成功修法:直接 INSERT 并依赖唯一约束处理冲突,而不是先用 FOR UPDATE 查一遍
图 2 · 间隙锁之间互不冲突,两个事务都能锁住 (10, 15);插入时要申请的插入意向锁却与对方的间隙锁冲突,形成循环等待

实测结果:事务 A 插入成功,事务 B 收到 ERROR 1213 (40001): Deadlock found when trying to get lock。SHOW ENGINE INNODB STATUS 里的死锁日志很清楚:

text
*** (1) HOLDS THE LOCK(S):
... index PRIMARY of table `lk`.`t` ... lock_mode X locks gap before rec
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
... index PRIMARY of table `lk`.`t` ... lock_mode X locks gap before rec insert intention waiting
*** (2) HOLDS THE LOCK(S):
... lock_mode X locks gap before rec
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
... lock_mode X locks gap before rec insert intention waiting
*** WE ROLL BACK TRANSACTION (2)

两个事务都持有间隙锁(locks gap before rec),又都在等待插入意向锁(insert intention waiting)。插入意向锁和别人的间隙锁冲突,于是形成循环等待。

同样的写法在 RC 下,两个插入都成功了,因为查询时根本没有加间隙锁。但更稳妥的修法与隔离级别无关:

  • 直接插入,依赖唯一约束:INSERT 后捕获唯一键冲突,或者用 INSERT … ON DUPLICATE KEY UPDATE。唯一约束才是「不重复」真正的保证,先查一遍既不必要,也挡不住并发。
  • 确实需要先读后写时,锁已存在的父记录,比如先锁住用户或订单主记录,再操作其下的子记录,让并发事务在同一个记录锁上排队。

六、索引决定锁的范围 ​

回到开头的例子。订单表有 2 万行,商户 7 有 2,000 行,只有 merchant_id 上的单列索引:

sql
UPDATE orders SET status = 'PAID'
WHERE merchant_id = 7 AND external_no = 'A1007';

EXPLAIN 显示走 idx_merchant,预估扫描 2,000 行。持有这条语句的锁时读取 data_locks,再从另一个会话发起探测:

索引锁的数量同商户另一订单的 UPDATE同商户插入新订单其他商户的 UPDATE
只有 (merchant_id)主键 2,000 + 二级索引 2,005阻塞阻塞通过
加上 (merchant_id, external_no)3通过通过通过

只有单列索引时,InnoDB 要把商户 7 的 2,000 条索引记录逐条读出、加锁,再回表判断 external_no,于是这个商户的所有订单都被锁住。加上联合索引后,语句直接定位到一条记录,只剩主键记录锁、联合索引上的 Next-Key Lock 和一个间隙锁。

这个结果也说明,锁冲突排查和慢 SQL 优化经常是同一件事。看到锁等待时,先对阻塞方的语句做一次 EXPLAIN,看它扫描了多少行。联合索引的列顺序见 SQL 调优实战。

七、死锁怎么处理 ​

死锁不是故障,而是并发事务形成了循环等待,InnoDB 默认会检测到并回滚代价较小的一方。应用侧要做的是:

  1. 把死锁当作可重试错误:SQLState 40001 或错误码 1213 时整笔事务重试一次,前提是事务里的操作是幂等的。
  2. 按固定顺序访问记录:多个事务都按主键升序加锁,就不会形成环。
  3. 缩短事务:外部调用、消息发送放到事务外,锁持有时间越短,冲突窗口越小。
  4. 补上索引:扫描范围小,锁住的记录和间隙就少。
  5. 保留现场:开启 innodb_print_all_deadlocks,把每次死锁写进错误日志。分析时同时看双方持有和等待的锁,而不是只看被回滚的那条语句。

八、常见误区 ​

  • 「行锁锁的是行」:锁的是索引记录。通过二级索引修改时,二级索引和主键索引上都有锁。
  • 「UPDATE 没走索引会升级成表锁」:data_locks 里仍然是逐条的记录锁和间隙锁,只是覆盖了全部记录,效果等同锁表;表级只有一个意向锁 IX。
  • 「间隙锁会阻止修改」:间隙锁只阻止插入。实测持有 k = 10 的锁时,修改 id=15 可以通过。
  • 「先 FOR UPDATE 查一下再插入,可以防重复」:RR 下它会制造死锁,RC 下它根本不加锁。防重复要靠唯一约束。
  • 「范围扫描总会多锁一条记录」:这是旧版本的行为,MySQL 8.0.18 修复了最常见的情形,实测 8.4 的主键范围扫描不再多锁下一条记录。

小结 ​

分析一条语句会锁什么,按这个顺序想:它走哪个索引、扫描了哪些记录、隔离级别是 RR 还是 RC、索引是否唯一、条件是否命中。答案不必靠推理,performance_schema.data_locks 能直接给出每一个锁。工程上最有效的手段仍然是索引:扫描范围越小,锁住的记录和间隙就越少。「不存在才插入」这类逻辑,则应该交给唯一约束。


配套实验

可以用 make verify 一条命令复现;链接固定在实验仓库的 c9f6692 版本。

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权