MySQL 死锁频发导致订单失败?从 LATEST DETECTED DEADLOCK 到加锁顺序改造的完整闭环
现象:订单接口偶发 1213,每次都是同一个报错
线上订单服务接入了监控告警,每天有几十条这样的异常:
java.sql.SQLTransactionRollbackException: Deadlock found when trying to get lock;
try restarting transaction
错误码 1213,报错很随机——同一时刻只有极少数请求失败,重试一次又能成功。因为量小,被当成"偶发"拖了两周,直到大促前压测,失败率从 0.02% 涨到 1.3%,才真正被重视。
死锁的坑在于它不是必现、不是稳定复现、重试就好,所以最容易被忽略。但它本质是并发下的加锁顺序冲突,量一大必然放大。
第一步:先确认是不是真的 InnoDB 死锁
别急着看代码。InnoDB 会把最近一次死锁的完整现场记在内存里,直接取:
SHOW ENGINE INNODB STATUS\G
输出里找 LATEST DETECTED DEADLOCK 这一段。一个真实的样例如下:
------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-11-11 21:14:03 0x7f8a1c0b7700
*** (1) TRANSACTION:
TRANSACTION 4823901, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
UPDATE order_item SET status = 2 WHERE order_id = 20261111001 AND sku_id = 8801
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 128 page no 4471 n bits 96 index PRIMARY of table `shop`.`order_item`
lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION:
TRANSACTION 4823900, ACTIVE 0 sec starting index read
UPDATE order_item SET status = 2 WHERE order_id = 20261111002 AND sku_id = 8802
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS ... index idx_order_id of table `shop`.`order_item`
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index PRIMARY of table `shop`.`order_item`
lock_mode X locks rec but not gap waiting
*** WE ROLL BACK TRANSACTION (1)
这段信息量极大,重点看四行:谁在等锁(WAITING FOR)、谁持有锁(HOLDS THE LOCK)、等的哪个索引、锁的模式是什么。
上面这个案例已经说明问题:事务 1 通过主键去锁,事务 2 先锁了 idx_order_id 普通索引再去抢主键——两个事务加锁的索引路径不同,形成了交叉等待。
第二步:把死锁现场和代码对上
死锁日志里的 SQL 是格式化后的,参数值被内联了。把 SQL 反查回代码,找出这段逻辑的事务边界:
@Transactional
public void closeOrder(Long orderId) {
// 1. 先按 order_id 批量更新明细状态
orderItemMapper.closeByOrderId(orderId);
// 2. 再按主键更新单条明细
orderItemMapper.updateById(itemId);
// 3. 更新订单主表
orderMapper.updateStatus(orderId, OrderStatus.CLOSED);
}
问题就在 1 和 2:closeByOrderId 走的是 idx_order_id 二级索引,updateById 走主键。两个并发事务如果处理的 order_id 存在交叉(比如批量关单和单条关单同时跑),加锁顺序就会反向。
InnoDB 的加锁顺序 = SQL 里访问索引的顺序,不是你以为的"先来的先拿锁"。只要两个事务访问同一批行的索引顺序不一致,就可能死锁。
第三步:三个误区,一个比一个常见
误区一:有索引就不会锁表,也就不会死锁
恰恰相反。有索引只是缩小了锁的范围,死锁概率反而更高——因为锁的是索引记录,不同索引上的锁会交叉。没索引走全表扫描,锁范围大,死锁反而"看起来少",但代价是并发度极差。
误区二:事务越短越好,加个 try-catch 重试就行
重试能降失败率,但掩盖了根因。压测时失败率涨到 1.3% 就是证明:重试本身会放大数据库压力,事务回滚重来又要重新争锁,形成正反馈。
误区三:把隔离级别降到 READ COMMITTED 就没事
RC 能消除间隙锁(gap lock),确实能减少一部分死锁,但记录锁(record lock)的交叉等待照样存在。这次案例里的死锁全是 record lock,降隔离级别无效。
第四步:三个真正有效的解法
1. 统一加锁顺序(治本)
// 改造前:先批量按 order_id,再按主键
// 改造后:全部按主键 id 升序处理
List<OrderItem> items = orderItemMapper.listByOrderId(orderId);
items.sort(Comparator.comparing(OrderItem::getId));
for (OrderItem item : items) {
orderItemMapper.updateById(item.getId(), OrderStatus.CLOSED);
}
原则:同一批行,永远用同一个索引、同一个顺序加锁。把所有"按业务字段批量更新"改成"查出 id 再按 id 排序逐条更新",虽然多一次查询,但消除了乱序加锁。
2. 缩小事务边界
// 改造前:整个方法一个事务,包含 RPC 调用和 3 次 DB 写
// 改造后:只包 DB 写,RPC 移到事务外
public void closeOrder(Long orderId) {
List<OrderItem> items = orderItemMapper.listByOrderId(orderId); // 事务外查
notifier.send(orderId); // 事务外 RPC
orderTxService.closeItemsAndOrder(orderId, items); // 只剩 DB 写
}
事务里夹 RPC 或 HTTP 调用,等于把网络延迟塞进了持锁时间。锁持有时间从 200ms 缩到 20ms,冲突窗口直接小一个数量级。
3. 索引改造:让更新只落在一个索引上
-- 改造前
KEY idx_order_id (order_id)
-- 改造后:覆盖更新条件,避免回表再锁聚簇索引
KEY idx_order_sku_status (order_id, sku_id, status)
把更新条件涉及的列做成联合索引,让 UPDATE 只在一个二级索引上完成,不需要再去聚簇索引上加锁。注意:这会增加写放大,只对高频更新的表值得做。
第五步:上线后怎么盯
- 监控
Innodb_deadlocks状态变量(MySQL 8.0 用SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks'),配 Prometheus 告警,环比突增就查 - 开启
innodb_print_all_deadlocks = ON,把全部死锁打到 error log,别只靠 SHOW ENGINE 的最近一次 - 业务侧保留重试,但只重试 1213 和 1205(锁等待超时),重试次数 ≤2,且必须加退避,避免重试风暴
- 压测必须带并发,单线程永远测不出死锁
效果
改造后同样的压测场景:死锁从每分钟 40+ 次降到 0,订单失败率回到 0.01% 以内,closeOrder 的平均持锁时间从 180ms 降到 15ms。真正起作用的不是重试,而是把加锁顺序锁定成一种。
排查口诀记住三句话:死锁先看 LATEST DETECTED DEADLOCK,重点比对两个事务的索引路径,解法永远优先统一加锁顺序而不是加索引。
- 点赞
- 收藏
- 关注作者
评论(0)