Skip to content

InnoDB 锁全景:行锁、间隙锁与死锁

本文是 MySQL 系统学习系列的核心篇。前置:事务与 MVCC:隔离级别的实现原理。 相关阅读:Next-Key Lock 加锁规则死锁排查:SHOW ENGINE INNODB STATUS

MVCC 管不了的地方,才需要锁

上一篇讲 MVCC 时留了个口子:普通 SELECT 走快照读,不加锁,靠 ReadView 看旧版本。但两个事务同时 UPDATE 同一行,版本链分不出胜负,必须有人排队,排队机制就是锁。

InnoDB 的锁比"给行上个锁"复杂,原因在于 RR 隔离级别下它要同时解决两件事:已存在行之间的写冲突(记录锁负责),以及防止别的事务往查询范围里插入新行导致幻读(间隙锁负责)。间隙锁锁的不是数据,是两条索引记录之间的空隙,专门挡 INSERT。记住这两个动机,后面的分类就好记了。

锁的分类矩阵

两个维度:锁什么模式(S 还是 X),锁在什么对象上。

维度取值说明
模式S 共享锁 / X 排他锁S 之间兼容,S/X 与 X/X 互斥
对象记录锁只锁一条索引记录
对象间隙锁 Gap Lock锁开区间 (a,b),只挡 INSERT
对象临键锁 Next-Key Lock记录锁 + 前面的间隙,左开右闭 (a,b],RR 下的默认加锁单位
对象意向锁 IS/IX表级,只用来快速判断"这张表里有没有行锁",不与行锁冲突

什么 SQL 触发什么锁:

  • 普通 SELECT:不加锁,走快照读
  • SELECT ... FOR UPDATE:X 锁(8.0 里 LOCK IN SHARE MODE 的别名是 FOR SHARE,两种写法均可)
  • UPDATE / DELETE:X 锁,且走当前读——读最新已提交版本,绕过 MVCC 快照
  • INSERT:先申请插入意向锁,撞上别人的间隙锁就等待;命中唯一键冲突会转为在冲突记录上加 S 锁,这是 INSERT 也会卷进死锁的原因

一个容易忽略的点:间隙锁之间彼此兼容。两个事务可以同时持有同一片间隙的间隙锁,它们只挡别人的 INSERT,不挡对方。死锁的第一经典场景正是从这条性质来的,后面实操会碰到。

加锁怎么判:两条退化规则和无索引锁全表

加锁的基本单位是临键锁,按索引定位到范围后逐段加,但有两条重要的退化:

  1. 唯一索引等值查询且记录存在:临键锁退化为纯记录锁。行已经在了,唯一性又保证不会有第二个相同的值,锁住这条记录就足够挡住所有冲突,间隙没有锁的价值。
  2. 等值查询但记录不存在:退化为纯间隙锁,锁住目标值所在的那段空隙。此时另一个事务也能加同样的间隙锁(兼容),但谁都别想 INSERT 进去。
sql
-- 表 t(id INT PRIMARY KEY),已有 id = 10, 15, 20
BEGIN;
SELECT * FROM t WHERE id = 15 FOR UPDATE;  -- 唯一索引等值命中:只锁 id=15 这条记录
SELECT * FROM t WHERE id = 18 FOR UPDATE;  -- 18 不存在:间隙锁 (15,20),挡住向这段插入的事务

无索引列上做 UPDATE 为什么锁全表:UPDATE 是当前读,必须在索引上逐条定位并加锁。WHERE 列没有索引时只能全表扫描,扫到的每条记录都加临键锁,效果上等于锁住整张表,并发会话全被挡住。这不是 bug,而是 InnoDB 无法判断哪些行在范围内、哪些不在,只能全锁。修法就一个:给过滤列建索引,让锁范围收窄到索引定位的那几段。

死锁的三个高发场景

场景一:间隙锁互相等待。 两个事务各自持有兼容的间隙锁,又都想 INSERT 到对方的间隙里。INSERT 需要的插入意向锁与间隙锁互斥,于是互相等待。

场景二:AB-BA 更新顺序。 事务 A 先改 id=1 再改 id=2,事务 B 先改 id=2 再改 id=1,各持一把锁去要对方的锁。转账、扣库存这类"先锁 A 账户再锁 B 账户"的业务最常见,顺序不统一就必然偶发死锁。

mermaid
graph LR
    A["事务 A"] -- "持有 id=1 的 X 锁" --> L1(("id=1"))
    B["事务 B"] -- "持有 id=2 的 X 锁" --> L2(("id=2"))
    A -. "等 id=2(被 B 持有)" .-> L2
    B -. "等 id=1(被 A 持有)" .-> L1

场景三:INSERT 撞唯一键的隐式锁。 事务 1 INSERT 一行未提交,事务 2 INSERT 相同唯一键的行会被挡住(等待事务 1);如果事务 1 随后又去 INSERT 事务 2 已持有的行,环就闭上了——闭环出现在锁的等待图里,不是业务流程里。

死锁排查:LATEST DETECTED DEADLOCK 怎么读

SHOW ENGINE INNODB STATUS 的输出里找 LATEST DETECTED DEADLOCK 段,最近一次死锁的全貌都在里面,读法在下面的实操里逐行注解。要点:先看两个事务各自 HOLDS 什么、WAITING FOR 什么,锁对象会写成 index PRIMARY of table ... lock_mode X locks rec but not gap(记录锁)或 locks gap before rec(间隙锁)这种格式;最后一句 WE ROLL BACK TRANSACTION (N) 告诉你 InnoDB 回滚了谁——选 undo 量小的那个回滚,代价最小。被回滚的事务收到 1213 错误,应用侧应该重试。

预防死锁的实践清单

  • 固定更新顺序:同一批行的事务内按主键排序后再更新,AB-BA 就没有生存土壤
  • 小事务:拿到锁尽快提交,锁的持有窗口越小,撞车概率越低
  • 索引完备:避免无索引 UPDATE 引发的全表加锁
  • 热点行改成批量条件更新UPDATE ... WHERE status='new' LIMIT 100 逐批认领,别让所有事务挤在同一行上做 SELECT FOR UPDATE
  • 目标不是消灭死锁(做不到),而是概率低、回滚代价小、可重试

动手实操:复现 AB-BA 死锁并读懂输出

两个 mysql 客户端连同一个库,照下面左右两栏交错执行:

sql
-- 准备
CREATE TABLE acc (id INT PRIMARY KEY, balance INT NOT NULL DEFAULT 0) ENGINE=InnoDB;
INSERT INTO acc VALUES (1, 100), (2, 100);

-- 会话 A                                        -- 会话 B
BEGIN;
UPDATE acc SET balance = balance - 10 WHERE id = 1;  -- A 拿到 id=1 的 X 记录锁
                                                 BEGIN;
                                                 UPDATE acc SET balance = balance - 10 WHERE id = 2;  -- B 拿到 id=2
UPDATE acc SET balance = balance - 10 WHERE id = 2;  -- A 等 B 释放 id=2(阻塞)
                                                 UPDATE acc SET balance = balance - 10 WHERE id = 1;
                                                 -- B 等 A 释放 id=1,等待环形成
                                                 -- -> 其中一个会话立刻收到:
                                                 -- ERROR 1213 (40001): Deadlock found when trying to get lock;
                                                 --        try restarting transaction

死锁检测是即时的(不是等锁超时),被回滚的会话马上报 1213。另开一个客户端看现场:

sql
SHOW ENGINE INNODB STATUS\G

输出的死锁段长这样,逐块拆:

text
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:                        -- 事务1(本次存活)
TRANSACTION 421948, ACTIVE 5 sec starting index read
UPDATE acc SET balance = balance - 10 WHERE id = 2   -- 出事时正在执行的语句
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS ... index PRIMARY of table `test`.`acc`
lock_mode X locks rec but not gap           -- 事务1 已持有:id=1 上的 X 记录锁(无间隙部分)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
lock_mode X locks rec but not gap           -- 事务1 在等:id=2,正被事务2 持有
*** (2) TRANSACTION: / HOLDS / WAITING      -- 事务2 对称:持 id=2,等 id=1
*** WE ROLL BACK TRANSACTION (2)            -- 回滚 undo 量更小的事务2

排查思路照着走:语句 -> 各自持有/等待的锁 -> 找到环 -> 改顺序或改索引。被回滚的 1213 在应用层重试即可,转账场景重试前记得核对余额状态。

常见误区与小结

  • 「间隙锁互相冲突」——错,间隙锁彼此兼容,只挡插入意向锁;不少死锁正是因为两把"不冲突"的间隙锁各自等对方的插入
  • 「死锁是 bug」——不是,它是并发更新顺序不一致的偶发结果,InnoDB 检测到就回滚一方,重试是应用的责任
  • 「SELECT 不加锁」——普通 SELECT 是快照读,但 FOR UPDATE / FOR SHARE 以及 UPDATE 内部的读全是当前读,全上锁
  • 「无索引 UPDATE 只是慢」——还会给全表记录加临键锁,并发会话直接排队
  • 「调大 innodb_lock_wait_timeout 能防死锁」——两码事,那是等锁超时;死锁靠等待图检测,参数是 innodb_deadlock_detect

小结:上一篇的 MVCC 解决了读的一致性,这一篇的锁补上了写冲突与幻读防护。加锁单位是临键锁,两条退化规则决定它最终长成记录锁还是间隙锁;死锁排查盯住"谁持有什么、等什么"。下一篇讲 redo log、undo log、binlog 在两阶段提交里怎么配合,把事务的持久性也接上。

参考

  • MySQL 8.0 Reference Manual, 15.7.1 InnoDB Locking
  • MySQL 8.0 Reference Manual, 15.7.5 Deadlocks in InnoDB
  • 《高性能 MySQL(第 3 版)》第 1 章

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1