MySQL 死锁频发导致订单失败?从 LATEST DETECTED DEADLOCK 到加锁顺序改造的完整闭环

举报
yd_243555368 发表于 2026/11/12 00:11:56 2026/11/12
【摘要】 订单接口每天几十条 1213 Deadlock found,被当成偶发拖了两周,压测失败率直接涨到 1.3%。用 SHOW ENGINE INNODB STATUS 取出死锁现场,比对两个事务的索引路径,发现二级索引与聚簇索引交叉加锁;再对上代码里事务内的乱序 UPDATE。给出三个真正有效的解法:统一按主键升序加锁、把 RPC 移出事务边界、用联合索引避免回表加锁,并纠正常见误区(降隔离级别治不

现象:订单接口偶发 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,重点比对两个事务的索引路径,解法永远优先统一加锁顺序而不是加索引。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。