一次 MySQL 死锁排查两条 update 的顺序反了周三下午三点半客服在群里说转单转不过去一直转圈。进后台看日志一屏都是同一句话com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction重启服务没用因为这不是偶发——把日志按时间排一下从下午一点开始平均十几分钟来一次死锁每次都是转单这个动作。第一步把死锁日志拿出来只看那一段SHOW ENGINE INNODB STATUS输出很长真正有用的只有LATEST DETECTED DEADLOCK那一段。摘关键部分------------------------ LATEST DETECTED DEADLOCK ------------------------ *** (1) TRANSACTION: TRANSACTION 4812293, ACTIVE 1 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 3 lock struct(s), heap size 1136 MySQL thread id 8821, query id 9182741 updating UPDATE order_item SET order_no SO20260318092 WHERE order_no SO20260318077 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 421 page no 887 n bits 96 index idx_order_no of table db.order_item lock_mode X locks rec but not gap waiting *** (2) TRANSACTION: TRANSACTION 4812292, ACTIVE 2 sec starting index read mysql tables in use 2, locked 2 UPDATE order_main SET status CANCELLED WHERE order_no SO20260318077 *** (2) HOLDS THE LOCK(S): RECORD LOCKS ... index PRIMARY of table db.order_main lock_mode X locks rec but not gap *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS ... index idx_order_no of table db.order_item lock_mode X locks rec but not gap waiting *** WE ROLL BACK TRANSACTION (1)翻译成人话事务 2取消单先锁住了order_main里SO20260318077这行然后去等order_item上的锁。事务 1转单已经拿住了order_item上的锁正在等order_main。两个人各拿一把、各等一把环就这么合上了。InnoDB 检测到之后选代价小的那个事务 1回滚。第二步确认是顺序反了不是锁太多死锁有经典的四个必要条件能人为干预的其实只有一个——循环等待。缩小事务范围、加索引、调隔离级别都能降低概率但只要两个事务的加锁顺序是反的它迟早会撞上。看一眼两个流程的代码顺序问题一目了然。转单旧代码// 先把子表挂到新单上再改主表状态orderItemMapper.updateOrderNo(oldNo,newNo);orderMainMapper.updateStatus(newNo,TRANSFERRED);取消旧代码// 先改主表状态再删子表orderMainMapper.updateStatus(no,CANCELLED);orderItemMapper.deleteByOrderNo(no);主表 → 子表和子表 → 主表两个人各走各的撞上就死。这两个流程平时各跑各的没事但那天下午客户在批量转单一张单拆成三张运营同时在取消几张作废的单正好赶上同一批单号就撞了。第三步定一个全局加锁顺序然后写进规范改法很土所有同时涉及order_main和order_item的事务一律先主表、后子表。// 转单先动主表状态再挂子表orderMainMapper.updateStatus(newNo,TRANSFERRED);orderItemMapper.updateOrderNo(oldNo,newNo);有一个地方不好改批量导入那段老代码是子表先行的动它要连带改一批测试用例我们暂时没动给它单开了一条串行的队列同一批单号不会并发进来。单靠改代码不够。这种问题半年后会以另一种形式回来所以同时做两件事代码规范里加一条跨两张表的事务按表名的字母序决定加锁顺序。code review checklist 加一项看到事务里出现两张以上表的写操作先问顺序。排查死锁两个真正省时间的开关一、打开innodb_print_all_deadlocks。默认 InnoDB 只在最后一次死锁时更新 status你去看的时候往往已经被覆盖了。打开这个开关所有死锁都会写进 error log能看出频率和分布——比如我们这次就是靠它确认了集中在转单取消同时发生时。[mysqld] innodb_print_all_deadlocks ON二、死锁异常要在业务层重试一次。InnoDB 回滚掉的那个事务其实什么都没改重试一次通常就过了。但这里有个前提——重试的接口必须是幂等的。我们在另一个项目上就吃过亏加了重试结果重试把库存又扣了一遍。死锁重试和幂等得一起上只上一个是给自己埋雷。关于缩小事务范围能避免死锁我不完全同意这句话在网上流传很广但我觉得它被说成万能药了。缩小事务范围能减少锁的持有时间从而降低冲突概率这没错。但它解决不了加锁顺序相反这一类问题。我们那个转单事务从头到尾就两条 SQL范围已经小得不能再小了照样死锁。真正解决它的是统一顺序。我的看法是概率类的问题治标顺序类的问题才治本。两个都要做但别指望只做前者。复盘死锁不报错不代表没有。在低并发下它可能几个月不出现一到大促就集中爆发。压测的时候要专门造并发别只测单线程吞吐。索引缺失会把行锁放大成范围锁。我们这次是顺序问题但在同一个库的另一张表上我们撞到过没索引导致update ... where status ?锁了半张表的情况。这个后面单独写一篇。死锁日志要主动留存。现在我们的做法是每天定时把SHOW ENGINE INNODB STATUS的 Deadlock 段落抓一份存档出事的时候能往前翻。版本是 MySQL 5.7InnoDB上面的日志输出格式和开关名在 8.0 里基本一致。