MVCC 解决了读写不互相阻塞,但写写之间、以及"当前读"之间仍然必须靠锁来串行化。InnoDB 的锁不止"行锁"这么简单:它锁的有时不是行,而是行与行之间的"缝隙"。很多线上诡异的死锁、莫名的阻塞,根源都在没搞清楚 InnoDB 到底锁了什么。本文把行锁、间隙锁、临键锁拆开讲清楚,并还原死锁的成因与排查路径。

场景:删一行为什么锁住了一片

一个常见的困惑:会话 A 执行 DELETE FROM t WHERE id BETWEEN 10 AND 20,会话 B 想 INSERT 一行 id = 15,结果 B 被阻塞了——可 15 这行根本不存在,A 又没插入它,凭什么挡我?答案是 A 锁住的不只是已存在的行,还锁住了 10 到 20 之间的间隙,防止任何人往这个范围里插数据。这是 InnoDB 在 RR 隔离级别下消除幻读的手段。

机制:三种锁的形态

InnoDB 的行级锁本质上有三种"作用域":

  • 记录锁(Record Lock):锁住索引上的某一条具体记录。
  • 间隙锁(Gap Lock):锁住索引记录之间的开区间,不包含记录本身,纯粹防止"插入"。
  • 临键锁(Next-Key Lock):记录锁 + 它前面的间隙,是个左开右闭区间 (prev, cur]RR 隔离级别下,InnoDB 加锁的默认单位就是临键锁。

用一个比喻:图书馆书架上的书是"记录",书与书之间的空位是"间隙"。记录锁是把某本书锁住不让别人动;间隙锁是在空位塞个挡板不让别人插新书;临键锁则是连书带它左边的空位一起锁。

假设表里有索引值 5, 10, 15,则临键锁把数轴切成这些区间:

1
(-∞, 5]   (5, 10]   (10, 15]   (15, +∞)

每个区间都可能被独立加锁。当你 WHERE id = 10 FOR UPDATE,命中的临键锁是 (5, 10],于是 6~9 的间隙也被锁了。

加锁如何随查询条件变化

加锁范围高度依赖索引类型命中情况,规则微妙:

1
2
3
4
5
6
7
8
9
10
11
-- 唯一索引等值查询,命中存在的行 → 退化为记录锁(只锁 id=10 这一行)
SELECT * FROM t WHERE id = 10 FOR UPDATE;

-- 唯一索引等值查询,行不存在 → 退化为间隙锁(锁 (5,10) 防止插入 10)
SELECT * FROM t WHERE id = 7 FOR UPDATE;

-- 普通(非唯一)索引等值查询 → 临键锁 + 向右的间隙锁
SELECT * FROM t WHERE k = 10 FOR UPDATE;

-- 范围查询 → 锁住整个范围涉及的多个临键区间
SELECT * FROM t WHERE id >= 10 FOR UPDATE;

两个必须记住的优化原则:等值查询命中唯一索引时,临键锁退化为记录锁(没有幻读风险,间隙没必要锁);等值查询未命中时退化为间隙锁。普通索引因为可能有重复值,需要锁住右边的间隙防止插入相同值,所以更"重"。

还有个致命细节:WHERE 条件如果没走索引,会退化成全表扫描,给扫到的每一行都加锁——相当于锁了整张表。这是慢 SQL 引发大面积阻塞的常见元凶。

死锁:循环等待

死锁的本质是两个事务各持有对方想要的锁,形成循环等待。最经典的例子:

1
2
3
4
5
6
7
8
-- 会话 A
UPDATE t SET v = 1 WHERE id = 1; -- 持有 id=1 的锁
-- 会话 B
UPDATE t SET v = 1 WHERE id = 2; -- 持有 id=2 的锁
-- 会话 A
UPDATE t SET v = 1 WHERE id = 2; -- 等待 B 释放 id=2
-- 会话 B
UPDATE t SET v = 1 WHERE id = 1; -- 等待 A 释放 id=1 → 死锁!

InnoDB 有死锁检测(innodb_deadlock_detect,默认开启):它维护一张"等待图",发现环就立刻回滚其中一个事务(通常选回滚代价小的,即修改行数少的那个),让另一个继续。被牺牲的事务收到 Deadlock found when trying to get lock 错误。

排查死锁的第一手资料是引擎状态里的 LATEST DETECTED DEADLOCK 段:

1
SHOW ENGINE INNODB STATUS\G

它会打印出两个事务各自持有什么锁、在等什么锁,以及涉及的 SQL,按图索骥就能定位。

工程权衡与踩坑

死锁检测本身有成本。 每个事务在等锁时都要遍历等待图,热点行(比如所有事务都更新同一行计数器)场景下,检测会消耗大量 CPU,反而成为瓶颈。这种极端高并发更新单行的场景,有时关掉死锁检测、依赖锁等待超时(innodb_lock_wait_timeout)反而更稳——但这要非常谨慎权衡。

统一加锁顺序是预防死锁的根本。 上面的例子里,如果 A、B 都约定"先锁小 id 再锁大 id",循环等待就不可能形成。批量更新前对主键排序、避免事务里交叉访问多张表,都是同一原则的体现。

让事务短小、把锁尽量后加。 锁是从加锁那一刻持有到事务提交才释放(两阶段锁协议)。事务里先做耗时的外部调用、再去 UPDATE,会让锁持有时间不必要地拉长。把更新放到事务末尾、提交前完成,能显著降低冲突概率。

间隙锁只在 RR 下存在。 切到 RC 隔离级别,间隙锁基本被关闭(只保留唯一性检查必需的),并发插入冲突大幅减少——这也是不少高并发系统选择 RC 的原因。但代价是放弃了 RR 对幻读的防护,需要业务层自己保证一致性。

唯一索引冲突也会死锁。 两个事务并发插入相同唯一键,会因为插入意向锁与唯一性检查的间隙锁交织产生死锁,这类死锁在唯一索引上的"先查后插"逻辑里尤其高发。

小结

InnoDB 锁的精髓不在"行",而在"它锁的是索引上的区间"。记录锁锁点、间隙锁锁缝、临键锁锁段,RR 下默认临键锁是为了消除幻读。死锁是循环等待,靠统一加锁顺序、缩短事务、控制锁粒度来预防,靠 SHOW ENGINE INNODB STATUS 来定位。每次写下一条带 WHERE 的更新前,问自己一句:它走的是哪个索引,会锁住哪些区间?想清楚这个,大半的并发问题都能提前规避。