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 | -- 唯一索引等值查询,命中存在的行 → 退化为记录锁(只锁 id=10 这一行) |
两个必须记住的优化原则:等值查询命中唯一索引时,临键锁退化为记录锁(没有幻读风险,间隙没必要锁);等值查询未命中时退化为间隙锁。普通索引因为可能有重复值,需要锁住右边的间隙防止插入相同值,所以更"重"。
还有个致命细节:WHERE 条件如果没走索引,会退化成全表扫描,给扫到的每一行都加锁——相当于锁了整张表。这是慢 SQL 引发大面积阻塞的常见元凶。
死锁:循环等待
死锁的本质是两个事务各持有对方想要的锁,形成循环等待。最经典的例子:
1 | -- 会话 A |
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 的更新前,问自己一句:它走的是哪个索引,会锁住哪些区间?想清楚这个,大半的并发问题都能提前规避。