数据库与缓存排查 列表全部文章
数据库与缓存排查

“Deadlock found when trying to get lock”:两个事务互相谦让,谁也没让成

业务偶发报 MySQL 1213 死锁,几百单里才一两单。用 SHOW ENGINE INNODB STATUS 逐行读懂 LATEST DETECTED DEADLOCK,配合 innodb_print_all_deadlocks 复现两类典型死锁:相反顺序加锁与间隙锁,并给出重试和统一加锁顺序的治法。

更多

周五下午四点半,离下班还有一个半小时,也就是“最适合出事”的黄金时段。测试同学在群里@我:“库存扣减偶尔报错,大概每几百单一次,重试又好了。”我看了眼日志,一行熟悉的红字:

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 次,说明不是我的错觉。拿它做一条监控曲线,按增量告警,是非常便宜的办法。

二、排查路线图

死锁排查的三板斧:拿日志 → 读日志 → 对代码。

① 应用日志1213 + 出错的 SQL ② SHOW ENGINE INNODB STATUSLATEST DETECTED DEADLOCK(仅最近一次) ③ innodb_print_all_deadlocks=ON每次死锁都写进 error log,攒样本 ④ 读两个事务各持有什么、在等什么 ⑤ 对应到代码里的事务谁先锁哪张表/哪一行,顺序是否相反 ⑥ 治:统一顺序 + 缩短事务 + 重试必要时加索引、降低锁范围

三、动手:读懂 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 2LOCK 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,撞上了。这也印证了官方文档对死锁成因的第一条概括:多个事务以相反顺序锁定多行/多表。

事务 A(购物车 2001, 2002) 事务 B(购物车 2002, 2001) UPDATE sku=2001 → 拿到 X 锁 UPDATE sku=2002 → 拿到 X 锁 UPDATE sku=2002 → 等 B UPDATE sku=2001 → 等 A 形成环:1213

四、根因:环形等待,还有它的“隐形表亲”——间隙锁

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 waiting

locks 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 用到的列建立合适索引”的原因。上一篇讲的索引失效,往往就是这里死锁的幕后推手。

五、止血与修复

止血

  1. 确认死锁是低频、可重试的:InnoDB 已经自动回滚了一个事务,数据没有损坏,用户点击重试就能成功。所以不需要重启、不需要 kill;
  2. 先给下单接口加上有限次数的重试(最多 3 次,带随机退避),让用户无感;
  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 与 12051213 是检测到环,要看两事务的加锁顺序;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 的死锁检测就是那个及时出现的保安,把其中一个人请出去。我们要做的,是在走廊上画一条箭头——所有人都按同一个方向走。

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人