周五下午四点半,离下班还有一个半小时,也就是“最适合出事”的黄金时段。测试同学在群里@我:“库存扣减偶尔报错,大概每几百单一次,重试又好了。”我看了眼日志,一行熟悉的红字:
java.sql.SQLTransientException / com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException:
Deadlock found when trying to get lock; try restarting transaction
### SQL: UPDATE t_stock SET qty = qty - ? WHERE sku_id = ?
### Cause: ... Deadlock found when trying to get lock; try restarting transaction
Error Code: 1213 SQLState: 40001错误码 1213,SQLState 40001。我的第一反应是“又是数据库的问题”,第二反应是“别甩锅,先看是不是我们代码里两个事务在打架”。这篇就是那次排查的完整记录,以及我顺手补的一堂“怎么读死锁日志”的课。
一、现象:低频、随机、重试即好
死锁有一个很有辨识度的特征:它是被 InnoDB 主动“裁决”出来的,不是超时。先分清两个长得像的错误:
ERROR 1213 (40001): Deadlock found when trying to get lock:InnoDB 检测到环形等待,立即选一个事务回滚(受害者)。瞬间报错,几乎不等;ERROR 1205 (HY000): Lock wait timeout exceeded:没有环,只是等得太久(默认innodb_lock_wait_timeout= 50 秒)。多半是有长事务在占锁。
本次是 1213,而且时间点分散、和流量成正比,说明是并发时序相关的问题,代码逻辑本身没有 bug,只是某种并发交错会形成环。先保留现场:
mysql> SHOW GLOBAL STATUS LIKE 'Innodb_row_lock%';
+-------------------------------+---------+
| Variable_name | Value |
+-------------------------------+---------+
| Innodb_row_lock_current_waits | 0 |
| Innodb_row_lock_time | 8412553 |
| Innodb_row_lock_time_avg | 187 |
| Innodb_row_lock_time_max | 51203 |
| Innodb_row_lock_waits | 44981 |
+-------------------------------+---------+
mysql> SELECT NAME, COUNT FROM information_schema.INNODB_METRICS WHERE NAME = 'lock_deadlocks';
+----------------+-------+
| NAME | COUNT |
+----------------+-------+
| lock_deadlocks | 173 |
+----------------+-------+lock_deadlocks 是自服务启动以来的死锁累计次数(官方死锁示例里也是用它确认计数增加的)。173 次,说明不是我的错觉。拿它做一条监控曲线,按增量告警,是非常便宜的办法。
二、排查路线图
死锁排查的三板斧:拿日志 → 读日志 → 对代码。
三、动手:读懂 LATEST DETECTED DEADLOCK
第 1 步:抓最近一次死锁
mysql> SHOW ENGINE INNODB STATUS\G输出很长,找 LATEST DETECTED DEADLOCK 这一段。注意:它只保留最近一次,下一次死锁来了就被覆盖,所以要么立刻抓,要么打开 innodb_print_all_deadlocks(下一步)。这是我们那次的输出(做过脱敏和精简):
------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-09-26 16:41:07 140218391475968
*** (1) TRANSACTION:
TRANSACTION 48213907, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 2
MySQL thread id 60218, OS thread handle 140218100852480, query id 91827364 10.0.3.21 app updating
UPDATE t_stock SET qty = qty - 1 WHERE sku_id = 2002
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 321 page no 5 n bits 96 index uk_sku_id of table `shop`.`t_stock` trx id 48213907 lock_mode X locks rec but not gap
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 8; hex 00000000000007d1; asc ;;
1: len 8; hex 0000000000000004; asc ;;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 321 page no 5 n bits 96 index uk_sku_id of table `shop`.`t_stock` trx id 48213907 lock_mode X locks rec but not gap waiting
Record lock, heap no 5 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 8; hex 00000000000007d2; asc ;;
1: len 8; hex 0000000000000005; asc ;;
*** (2) TRANSACTION:
TRANSACTION 48213908, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
4 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 2
MySQL thread id 60231, OS thread handle 140218099799808, query id 91827365 10.0.3.22 app updating
UPDATE t_stock SET qty = qty - 1 WHERE sku_id = 2001
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 321 page no 5 n bits 96 index uk_sku_id of table `shop`.`t_stock` trx id 48213908 lock_mode X locks rec but not gap
Record lock, heap no 5 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 8; hex 00000000000007d2; asc ;;
1: len 8; hex 0000000000000005; asc ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 321 page no 5 n bits 96 index uk_sku_id of table `shop`.`t_stock` trx id 48213908 lock_mode X locks rec but not gap waiting
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 8; hex 00000000000007d1; asc ;;
1: len 8; hex 0000000000000004; asc ;;
*** WE ROLL BACK TRANSACTION (2)别被这一大坨吓到,按下面的表逐行翻译(字段含义参考 MySQL 官方“死锁示例”文档):
| 行 | 含义 |
|---|---|
2026-09-26 16:41:07 ... | 死锁被检测到的时间。拿它去对应用日志,找同一时刻的请求 |
*** (1) TRANSACTION: | 参与死锁的第 1 个事务,(2) 是第 2 个 |
TRANSACTION 48213907, ACTIVE 0 sec starting index read | 事务 ID;已活跃 0 秒(很短,说明不是长事务);当前处于“通过索引读取”阶段 |
LOCK WAIT 4 lock struct(s), ... 3 row lock(s), undo log entries 2 | LOCK WAIT 表示它正在等锁;持有 4 个锁结构、3 个行锁;已经改了 2 条记录(undo 条目)。“undo log entries 2”是重要线索:它在这条 UPDATE 之前已经改过别的行,说明是一个多语句事务 |
MySQL thread id 60218 ... 10.0.3.21 app updating 与下一行 SQL | 对应的连接、来源、以及当前等锁的那条语句。注意:这只是“最后一条”,不是事务里的全部语句 |
HOLDS THE LOCK(S) | 它已经拿到的锁:uk_sku_id 索引上 lock_mode X locks rec but not gap,即 sku_id=2001(hex 07d1)的记录锁 |
WAITING FOR THIS LOCK TO BE GRANTED | 它在等的锁:同一个索引上的 hex ...07d2,也就是 sku_id=2002。末尾的 waiting 表示尚未授予 |
*** WE ROLL BACK TRANSACTION (2) | InnoDB 选择回滚事务 (2)。规则是回滚“权重最小”的事务,权重看修改的行数、锁数量等,大致是“代价最小的先牺牲” |
把它翻成人话:
- 事务 1:已持有 sku 2001 的行锁,想拿 sku 2002 的行锁;
- 事务 2:已持有 sku 2002 的行锁,想拿 sku 2001 的行锁;
教科书式的环形等待:A 等 B,B 等 A。hex 00000000000007d1 是 8 字节大端整数,0x07d1 = 2001,0x07d2 = 2002,不用查表也能读出来。锁模式速记:S 共享、X 排他;locks rec but not gap 是纯记录锁;locks gap before rec 是间隙锁;不带任何后缀的 X/S 是 next-key 锁(记录锁 + 前面的间隙);insert intention 是插入意向锁。
第 2 步:把死锁全部记进 error log
mysql> SET GLOBAL innodb_print_all_deadlocks = ON;
Query OK, 0 rows affected (0.00 sec)开启后,每次死锁都会写入 MySQL 的 error log(官方文档说明它用于诊断,并会输出所有死锁信息而不是只保留最近一次)。想永久生效就写进 my.cnf。注意日志量:死锁很频繁时会刷很多,排查完可关掉。之后我们蹲了一个下午,攒了 6 份,模式高度一致:都是 t_stock 上两个 sku 的记录锁互相等待。
$ grep -A3 'LATEST DETECTED\|WE ROLL BACK' /var/log/mysqld.log | head
$ grep -c 'WE ROLL BACK TRANSACTION' /var/log/mysqld.log
6第 3 步:回到代码,找出“相反顺序”
事务里“undo log entries 2”提示这是多语句事务。翻下单逻辑,果然:
@Transactional
public void createOrder(Order order, List<OrderItem> items) {
orderMapper.insert(order);
for (OrderItem item : items) {
// 按购物车顺序扣库存:用户 A 的购物车是 [2001, 2002],用户 B 的是 [2002, 2001]
int n = stockMapper.deduct(item.getSkuId(), item.getQty());
if (n == 0) throw new BizException("库存不足");
}
}两位用户几乎同时下单,购物车里是同样两件商品,只是加入顺序相反。A 先锁 2001 再要 2002,B 先锁 2002 再要 2001,撞上了。这也印证了官方文档对死锁成因的第一条概括:多个事务以相反顺序锁定多行/多表。
四、根因:环形等待,还有它的“隐形表亲”——间隙锁
1)相反顺序加锁
InnoDB 的行锁是加在索引记录上的。事务在提交前不释放锁(两阶段锁)。一旦两个事务以相反顺序去拿同一批锁,就会出现上面的环。InnoDB 默认开启 innodb_deadlock_detect,会在等待图里发现环后立刻回滚其中一个事务;关掉它则只能靠 innodb_lock_wait_timeout 超时兜底。官方文档也强调:即使逻辑写对了,应用也必须能处理“事务被回滚要重试”这件事,因为死锁在高并发下无法彻底根除。
2)RR 隔离级别下的间隙锁 / next-key 锁
另一类死锁更隐蔽,我在同一个库里也踩过,顺便记一下。InnoDB 默认隔离级别是 REPEATABLE READ(RR)。RR 下,范围条件或者对不存在的记录做 FOR UPDATE / UPDATE 时,会使用next-key 锁(记录锁 + 该记录前面的间隙锁)来防止幻读。官方文档:唯一索引上等值命中已存在的行,只会加记录锁,不会加间隙锁;但如果条件命中不到,或者是范围/非唯一索引,就会锁住间隙。
经典的“先查后插”死锁:
-- 表 t_coupon 有唯一索引 uk_code,当前 code 有 'A100' 和 'A300',没有 'A200'
-- 会话 1
BEGIN;
SELECT * FROM t_coupon WHERE code = 'A200' FOR UPDATE; -- 不存在 → 锁住 (A100, A300) 这个间隙
-- 会话 2
BEGIN;
SELECT * FROM t_coupon WHERE code = 'A200' FOR UPDATE; -- 间隙锁与间隙锁不冲突,也成功
-- 会话 1
INSERT INTO t_coupon(code) VALUES ('A200'); -- 需要插入意向锁,被会话 2 的间隙锁挡住,等待
-- 会话 2
INSERT INTO t_coupon(code) VALUES ('A200'); -- 被会话 1 的间隙锁挡住 → 环 → 1213对应到死锁日志里,你会看到类似这样的片段:
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 330 page no 4 n bits 72 index uk_code of table `shop`.`t_coupon` trx id 48220011 lock_mode X locks gap before rec
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 330 page no 4 n bits 72 index uk_code of table `shop`.`t_coupon` trx id 48220011 lock_mode X locks gap before rec insert intention waitinglocks gap before rec 是持有的间隙锁,insert intention waiting 是想插入却被挡住。看到这两个词并排出现,八成就是“先 SELECT FOR UPDATE 再 INSERT”这个套路。治法:不要先查后插,直接 INSERT ... ON DUPLICATE KEY UPDATE 或者 INSERT IGNORE,由唯一索引保证幂等;或者在业务上确保这段逻辑走分布式锁串行化;如果业务允许幻读,可以把隔离级别降到 READ COMMITTED(RC 下大部分间隙锁不再使用,但要配合 binlog_format=ROW,并评估可重复读语义的变化)。
3)没走索引:锁范围放大
还有一点:UPDATE/DELETE 的 WHERE 条件没有走索引,会给扫描过的所有行加锁,死锁概率会呈数量级上升。这也是官方文档建议“给 SELECT ... FOR UPDATE 和 UPDATE ... WHERE 用到的列建立合适索引”的原因。上一篇讲的索引失效,往往就是这里死锁的幕后推手。
五、止血与修复
止血
- 确认死锁是低频、可重试的:InnoDB 已经自动回滚了一个事务,数据没有损坏,用户点击重试就能成功。所以不需要重启、不需要 kill;
- 先给下单接口加上有限次数的重试(最多 3 次,带随机退避),让用户无感;
- 打开
innodb_print_all_deadlocks收集样本,别再靠“最近一次”拼图。
// 只对死锁/锁等待超时重试,其它异常一律不重试
public <T> T withDeadlockRetry(Supplier<T> action) {
int max = 3;
for (int i = 1; ; i++) {
try {
return action.get(); // 注意:action 内部必须是一个完整的新事务
} catch (org.springframework.dao.DeadlockLoserDataAccessException
| org.springframework.dao.CannotAcquireLockException e) {
if (i >= max) throw e;
try { Thread.sleep(20L * i + ThreadLocalRandom.current().nextInt(30)); }
catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw e; }
}
}
}一个容易忽略的点:重试要在事务边界之外,不能在同一个 @Transactional 方法内部 catch 后再来一遍——事务已被回滚,同一事务上下文里重试是没用的(MySQL 的 1213 会回滚整个事务)。
根治
// 统一加锁顺序:先按 skuId 升序排序,再依次扣减
items.sort(Comparator.comparing(OrderItem::getSkuId));
for (OrderItem item : items) {
int n = stockMapper.deduct(item.getSkuId(), item.getQty());
...
}
// 或者把多次 UPDATE 合并成一条语句,让引擎一次按索引顺序锁定
// UPDATE t_stock SET qty = qty - CASE sku_id WHEN 2001 THEN 1 WHEN 2002 THEN 1 END
// WHERE sku_id IN (2001, 2002) AND qty >= 1; -- 注意仍要校验每个 sku 的库存其余的“降低概率”手段,全部来自官方文档的建议:
- 事务尽量小、尽量短:不要在事务里调用远程接口、发 MQ、做慢计算;先做完所有非数据库操作,最后再开事务集中写库;
- 固定操作顺序:不只是同一张表里的行顺序,多张表之间也要有全局约定,比如永远“先订单、后库存、再流水”;
- 给条件列加索引:避免全表扫描锁全表;
- 用事务而不是
LOCK TABLES; - 先
SELECT ... FOR UPDATE再UPDATE也建议在所有代码路径上顺序一致。
补一个反直觉的事实(同样来自官方文档):隔离级别本身并不改变“写写冲突死锁”的概率,死锁来自写操作而不是读;降到 RC 只是去掉了间隙锁这一类死锁来源,而不是消灭死锁。
六、复盘:如何提前躲开
| 做法 | 为什么 |
|---|---|
| 分清 1213 与 1205 | 1213 是检测到环,要看两事务的加锁顺序;1205 是等太久,要找长事务,两者排查方向完全不同 |
常开 innodb_print_all_deadlocks(或至少出事时立刻打开) | LATEST DETECTED DEADLOCK 只保留最近一次,很容易被后来的覆盖 |
监控 lock_deadlocks 增量、Innodb_row_lock_time_avg | 死锁从偶发变成频发,往往是某次发版或流量变化引入的,曲线能第一时间告诉你 |
| 多行/多表更新前统一排序(主键升序) | 从源头消灭“相反顺序”,是成本最低的根治方式 |
避免先查后插,改用 ON DUPLICATE KEY UPDATE | 去掉间隙锁 + 插入意向锁这个经典死锁模式,同时天然幂等 |
| 事务里不做 RPC、不做慢操作 | 持锁时间越长,撞车概率越大 |
| 业务层对 1213 有限次数重试 | 官方明确说死锁无法完全避免,应用必须能重试;注意重试要包在事务外面 |
一句话备忘:1213 →
SHOW ENGINE INNODB STATUS看 LATEST DETECTED DEADLOCK → 看两个事务“持有什么 / 等什么” → 对代码找“相反顺序”或“先查后插”→ 统一加锁顺序、缩短事务、外层重试。
最后一个感想:死锁很像两个人在窄走廊里相遇,都说“您先请”,然后同时向左、又同时向右。InnoDB 的死锁检测就是那个及时出现的保安,把其中一个人请出去。我们要做的,是在走廊上画一条箭头——所有人都按同一个方向走。
参考资料
- MySQL 8.0:InnoDB 中的死锁——死锁的成因、减少死锁的建议、
innodb_print_all_deadlocks与innodb_deadlock_detect。 - MySQL 8.0:InnoDB 死锁示例——完整复现步骤,以及 LATEST DETECTED DEADLOCK 各段落的含义。
- MySQL 8.0:InnoDB 锁类型——记录锁、间隙锁、next-key 锁、插入意向锁及 RR 下的默认行为。
- MySQL 8.0:复制中的事务重试与超时——官方对死锁、锁等待超时可自动重试的说明,印证“死锁应被视为可重试错误”。
一起聊聊