2800 字
约 9 分钟
13
S11-X12 · 锁与死锁:行锁、间隙锁与排查
2024-12-31
2026-11-08

应用日志里冒出一句 Deadlock found when trying to get lock; try restarting transaction,重试一次就过了,于是这个错误被当成偶发抖动忽略。但每一次死锁都在指出同一件事:某两条执行路径的加锁顺序不一致。InnoDB 已经把现场完整记下来了,SHOW ENGINE INNODB STATUS 里那一段 LATEST DETECTED DEADLOCK 就是尸检报告。读懂它,比加一条重试逻辑有用得多。

按"锁的层级 → 加锁落在哪 → 死锁判定 → 日志读法 → 预防"拆开。


1. 锁的层级与类型

锁 粒度 作用
意向共享锁 IS 表 表示"要往这张表的某些行加 S 锁"
意向排他锁 IX 表 表示"要往某些行加 X 锁"
记录锁 LOCK_REC_NOT_GAP 行 锁住一条索引记录
间隙锁 GAP 行间隙 锁住两条记录之间的空隙,阻止插入
临键锁 Next-Key 记录 + 前间隙 记录锁与间隙锁的组合,RR 下的默认形态
插入意向锁 间隙 INSERT 在间隙里"占位",与间隙锁互斥
SELECT * FROM performance_schema.data_locks\G
SELECT * FROM performance_schema.data_lock_waits\G

8.0 用 performance_schema.data_locks 取代了 5.7 的 information_schema.INNODB_LOCKS,字段更多也更准:LOCK_TYPE(TABLE/RECORD)、LOCK_MODE(X、X,REC_NOT_GAP、X,GAP、X 即 next-key)、LOCK_STATUS(GRANTED/WAITING)。看 LOCK_MODE 就能立刻区分记录锁、间隙锁和临键锁。


2. 加锁加在索引上

这是理解 InnoDB 锁的第一条铁律:行锁是加在索引记录上的,不是加在行上的。没有可用的索引,引擎只能扫过全部记录并对扫到的每条加锁,效果接近锁表。

WHERE 条件 走什么 实际加锁
主键等值命中 聚簇索引 单条记录锁
唯一索引等值命中 唯一二级索引 记录锁,不加间隙
唯一索引等值未命中 唯一二级索引 间隙锁(锁住那个"不存在的空位")
非唯一索引等值 普通二级索引 记录锁 + 前后间隙(next-key)
非唯一索引范围 普通二级索引 区间内所有记录与间隙的 next-key
无索引 全表扫 扫到的所有记录都上 next-key
-- 有索引:只锁 user_id = 10086 那几行
UPDATE orders SET status = 'paid' WHERE user_id = 10086;
-- 没索引:整表被扫过的记录全上锁
UPDATE orders SET status = 'paid' WHERE remark LIKE '%加急%';

第二句在高并发下几乎等于把表锁住。"我明明只改一行,为什么全表都在等",答案通常是 WHERE 列上没有索引。


3. 唯一索引的退化与非唯一索引的差别

唯一索引等值命中时,等值条件已经能唯一定位,不需要防区间插入,于是 next-key 退化成纯记录锁:

语句 索引 锁形态
WHERE id = 1 主键 X,REC_NOT_GAP
WHERE uk_col = 'a'(存在) 唯一索引 X,REC_NOT_GAP
WHERE uk_col = 'a'(不存在) 唯一索引 X,GAP
WHERE idx_col = 'a' 普通索引 X(next-key,含间隙)
SELECT index_name, non_unique, column_name
FROM information_schema.STATISTICS
WHERE table_schema = 'app' AND table_name = 'orders';

能改成唯一索引的业务键就改——它同时买到"唯一性约束"和"更少的锁"。反过来,非唯一索引上的等值查询会锁住相邻间隙,这正是很多"看似无关的两行互相阻塞"的来源。


4. INSERT 的隐式锁与插入意向锁

新插入的行在事务提交前不显式加锁,靠记录上的 DB_TRX_ID 充当"隐式锁"。别的事务要读/改这一行时发现事务号还活着,才把隐式锁转成显式锁。这个设计让插入很便宜,但也带来两个副作用:

副作用 机制 表现
唯一键冲突升级为 S 锁 冲突检查时对已存在记录加共享锁 两个事务互相等对方的 S 锁 → 死锁
插入意向锁与间隙锁互斥 INSERT 需要在间隙里占位 间隙锁未释放时插入直接进入等待
SHOW ENGINE INNODB STATUS\G   -- 关注 TRANSACTIONS 与 LATEST DETECTED DEADLOCK

经典死锁由此而来:两个事务同时 INSERT 同一个唯一键,都先拿到间隙的插入意向锁,再各自去检查对方刚插入的记录,互相等 S 锁。这类死锁在"先查再插"的幂等逻辑里非常常见。


5. 死锁:四个条件与检测机制

死锁需要互斥、持有并等待、不可抢占、循环等待四条同时成立。InnoDB 不去防前三条,只处理第四条——用等待图检测环。

机制 说明
等待图 事务是节点,"在等谁的锁"是有向边,成环即死锁
innodb_deadlock_detect 默认 ON;关闭后只能靠超时兜底
回滚选择 回滚"代价较小"的事务(通常是修改行数少的那个)
检测时机 每次进入锁等待时触发,代价与等待事务数相关
对比项 死锁 锁等待超时
触发者 等待图出现环 单个等待超过 innodb_lock_wait_timeout
报错 Deadlock found ... try restarting transaction Lock wait timeout exceeded
谁被牺牲 代价小的事务被回滚 等待方放弃
能否靠调参缓解 不能,调超时无效 能,但不该调小
SELECT @@innodb_deadlock_detect, @@innodb_lock_wait_timeout;

把 innodb_lock_wait_timeout 调小不是治死锁——它对死锁完全无效,只会让正常的长等待更早失败。


6. 死锁日志怎么读

SHOW ENGINE INNODB STATUS\G
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 42190, ACTIVE 2 sec starting index read
LOCK WAIT 3 lock struct(s), 2 row lock(s)
UPDATE orders SET status='paid' WHERE id=101
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS ... index PRIMARY of table `app`.`orders`
 lock_mode X locks rec but not gap
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index PRIMARY of table `app`.`orders`
 lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION:
TRANSACTION 42191, ACTIVE 1 sec starting index read
UPDATE orders SET status='paid' WHERE id=102
*** WE ROLL BACK TRANSACTION (2)
段 读什么
TRANSACTION n, ACTIVE x sec 事务号与已运行时长;starting index read 说明卡在读索引
紧跟的那条 SQL 死锁时的当前语句,不是整个事务
HOLDS THE LOCK(S) 已持有:locks rec but not gap 是记录锁,无 not gap 才是临键锁
WAITING FOR ... 正在等的那把锁,与对方 HOLDS 对照即可看出环
WE ROLL BACK TRANSACTION (n) 被牺牲的是哪个,业务重试要认这个

读的顺序固定:先看两个事务各自"等什么",再看对方"持有什么",环就出来了。这里 (1) 持有 101 等 102、(2) 持有 102 等 101——教科书式相反顺序。


7. 三个典型场景

场景 模式 触发条件
相反顺序更新两行 A 改 1→2,B 改 2→1 两条业务路径遍历顺序不一致
范围条件交叉 A 更新 id<100,B 更新 id>50 区间重叠,next-key 互相卡住
唯一键并发插入 两事务同时插同一 uk 唯一冲突检查互等 S 锁
-- 场景一:顺序不一致
-- 会话 A
BEGIN; UPDATE accounts SET v=v+1 WHERE id=1; UPDATE accounts SET v=v+1 WHERE id=2; COMMIT;
-- 会话 B(同时)
BEGIN; UPDATE accounts SET v=v+1 WHERE id=2; UPDATE accounts SET v=v+1 WHERE id=1; COMMIT;

-- 修复:两边都按 id 升序
BEGIN; UPDATE accounts SET v=v+1 WHERE id IN (1,2) ORDER BY id; COMMIT;
-- 场景三:先查后插
INSERT INTO coupons (user_id, code) VALUES (1,'X')
ON DUPLICATE KEY UPDATE code = code;   -- 用原子语句替代"先 SELECT 再 INSERT"

ON DUPLICATE KEY UPDATE 或 INSERT IGNORE 把"检查 + 插入"合成一条语句,插入意向锁的互等窗口就被消掉了。


8. 预防手段

手段 原理 代价
固定加锁顺序 消除循环等待 需要统一约定,容易漏
缩短事务 减少持锁时间窗口 业务改造
减少范围更新 少用 WHERE 上的区间与 LIKE 可能要多几次往返
用唯一索引 把 next-key 退化成记录锁 索引设计约束
降级到 READ COMMITTED RC 下无间隙锁(外键与唯一检查除外) 失去 RR 的可重复读
批量更新排序 ORDER BY 让多行按固定顺序上锁 无
SET GLOBAL transaction_isolation = 'READ-COMMITTED';
-- 或写进 my.cnf:transaction-isolation = READ-COMMITTED

降级到 RC 是对"间隙锁引发的死锁"最有效的一招,但要接受不可重复读的语义变化,且它并不消除记录锁层面的相反顺序死锁。


9. 排错:五种反复出现的误判

现象 原因 处理
死锁"偶尔"出现 并发窗口窄,压测才复现 压测时抓 LATEST DETECTED DEADLOCK,别等线上
只看应用日志 日志只有一句报错,没有持锁/等锁信息 同步拉 SHOW ENGINE INNODB STATUS
调小 innodb_lock_wait_timeout 超时与死锁是两条路径 对死锁无效,对长等待只是提前失败
批量更新不排序 多行加锁顺序随机 ORDER BY 主键,或分批按主键区间
以为低并发就没死锁 两个会话就够成环 关键是顺序不一致,不是并发量

10. 边界:锁管不到的地方

场景 是否靠 InnoDB 锁解决 说明
跨实例的并发写 不解决 需要分布式锁或唯一约束兜底
缓存与库的一致性 不解决 锁只作用于库内
业务层的重复提交 部分解决 幂等键 + 唯一索引更可靠
长事务导致的 purge 停滞 不解决 见隔离级别那篇
只读从库的并发 不解决 从库回放是单线程/多线程 SQL 线程

一句话收束:锁加在索引上,死锁来自顺序;调超时治不了死锁,读日志才能。


11. 自测

  1. 为什么 WHERE 条件没有索引时,改一行会表现成锁表?
  2. 唯一索引等值命中,为什么只加记录锁不加间隙锁?等值未命中时又加什么?
  3. 隐式锁在什么时候才转成显式锁?
  4. 死锁日志里 locks rec but not gap 和 locks rec 的区别是什么?
  5. 降级到 READ COMMITTED 能消除哪一类死锁,又不能消除哪一类?
  6. 为什么调小 innodb_lock_wait_timeout 对死锁没用?
S11-X12 · 锁与死锁:行锁、间隙锁与排查
https://www.clxhxhhr.top/posts/5883/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。