应用日志里冒出一句 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. 自测
- 为什么
WHERE条件没有索引时,改一行会表现成锁表? - 唯一索引等值命中,为什么只加记录锁不加间隙锁?等值未命中时又加什么?
- 隐式锁在什么时候才转成显式锁?
- 死锁日志里
locks rec but not gap和locks rec的区别是什么? - 降级到
READ COMMITTED能消除哪一类死锁,又不能消除哪一类? - 为什么调小
innodb_lock_wait_timeout对死锁没用?