☰
MySQL 死锁排查
2026/10/15 15:16:30 网站建设 项目流程

一次 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.7(InnoDB),上面的日志输出格式和开关名在 8.0 里基本一致。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询