☰
MySQL死锁自动重试机制:原理、陷阱与工程化设计
2026/10/22 5:41:48 网站建设 项目流程

凌晨两点半,某个同事在群里丢了一条线上告警截图,说是订单服务报了死锁错误。大家一开始以为是偶发问题,结果第二天数据对账发现少了两条库存扣减记录。你去看应用日志,发现事务明明报了重试成功,可数据库里就是缺数据。这就是今天想聊的这个“特性”——MySQL死锁时的事务自动重启机制。它并不是MySQL本身的重试,而是应用框架、中间件配合出来的一个行为,很多人理解错了一半,踩坑就踩在这上面。

这个机制说起来很反直觉:一个数据库事务因为死锁被回滚了,但你看到的应用日志里事务却“重新执行成功”了。于是你以为万无一失,实际上问题被掩盖了。本文就把这个机制从原理到工程实践完整拆一遍,包括死锁检测到底怎么选牺牲者、自动重试发生在哪一层、为什么重试会掩盖业务错误,以及怎么设计一套真正安全的事务重试策略。

1. 先把原理捋清楚:InnoDB死锁检测与自动回滚机制

1.1 死锁是怎么形成的,四个必要条件缺一不可

数据库死锁和操作系统里的死锁是同一套理论模型。形成死锁必须同时满足四个条件:互斥、持有并等待、不可剥夺、循环等待。放到MySQL的InnoDB里理解就很具体了:

  • 互斥:同一行记录在同一时刻只能被一个事务的排他锁(X锁)锁住。
  • 持有并等待:事务A持有某行的锁,同时又去申请事务B已经持有的那行锁,它不会释放自己手里的锁。
  • 不可剥夺:InnoDB默认不会强制把事务A已经持有的锁抢走,只能等事务自己提交或回滚。
  • 循环等待:A等B、B等C、C等A,形成一个环。

现实中99%的死锁都满足“两个事务以不同顺序锁定了同一组资源”这个特征。举个最朴素的例子:账户转账。事务A先锁账户1再锁账户2,事务B先锁账户2再锁账户1,两边同时执行,就极容易互相卡住。这个场景后面会详细拆。

1.2 InnoDB靠什么发现死锁:等锁图和搜索环

InnoDB默认开启了死锁检测,对应参数是innodb_deadlock_detect=ON。每次一个事务请求锁被阻塞时,InnoDB会构建一张等待图(Wait-For Graph),节点是当前活跃事务,边是“事务A在等待事务B持有的锁”。然后从发生阻塞的事务节点出发,做深度优先搜索,如果找到一条能回到自身的路径,就说明出现了环,也就是死锁。

这个检测不是独立的守护进程在后台跑,而是发生在加锁那个操作的关键路径上。所以死锁检测本身就是有开销的。并发量高、等待图复杂时,检测本身会成为热点。这也是为什么在某些高并发场景下,官方会建议关闭死锁检测,改用innodb_lock_wait_timeout来兜底。

一旦检测到死锁,InnoDB必须牺牲一个事务打破僵局。它的选择标准不是“谁先发起就回滚谁”,而是比较事务的回滚成本:具体看事务已经修改的行数、事务中的undo量、锁数量、执行时间等因素。成本较小的那个事务被选为牺牲品,收到错误码1213(ER_LOCK_DEADLOCK),对应Java里的DeadlockLoserDataAccessException。另一个事务正常运行,阻塞解除后继续执行。

1.3 隔离级别和间隙锁让死锁场景变得更复杂

如果说行锁死锁是经典教科书案例,那么间隙锁死锁就是生产环境的常客。默认隔离级别REPEATABLE READ下,InnoDB除了行锁,还有间隙锁(Gap Lock)和临键锁(Next-Key Lock)。间隙锁锁的是“行与行之间的空隙”,目的是防止幻读。

间隙锁有个很微妙的特点:在不同事务里,两个间隙锁之间是兼容的,都可以加在同一段间隙上。但间隙锁和插入意向锁(Insert Intention Lock)是冲突的。于是就会出现一种看起来很奇怪的情况:两个事务在没有任何相同行数据的情况下,因为同时往同一个范围的间隙里插入数据,互相卡住,被检测为死锁。

这类死锁排查起来最难,因为从业务上看,两边插入的数据根本不一样,你也很难第一时间想到是间隙锁在作祟。这就引出一个重要结论:很多时候死锁不是数据错乱,而是锁模型本身的副作用,不要盲目认为“数据一定被改坏了”。

提示:在READ COMMITTED隔离级别下,InnoDB会禁用间隙锁(仅保留外键约束和唯一性检查需要的场景),死锁概率会显著下降。但你不能为了规避死锁就随意降隔离级别,必须把binlog格式、主从复制一致性等因素一起考虑进去。

2. “自动重启”到底发生在哪一层:其实数据库根本不背这个锅

2.1 你以为MySQL会自动重试?它只负责回滚

很多新手刚接触MySQL时会产生一个误解:死锁后MySQL会自动把事务重试一次,所以最终数据应该是成功的。这里必须把话说清楚:InnoDB在检测到死锁后,只会回滚选中的牺牲事务,然后向客户端返回一个错误码1213,它绝不会自己去重试这个事务。

数据库层面没有“自动重启事务”这个功能。这其实是个合理设计,因为数据库不知道业务方法的完整语义。事务里可能已经执行了多条SQL,反映了整个业务操作的一部分,自动重试事务等于让数据库替业务做决策,这超出了存储引擎的职责边界。真正意义上的“自动重启”,发生在数据库之外的代码层。

2.2 应用层的事务重试:Spring事务和重试组件的合谋

那为什么大家在日志里经常看到“死锁后重试成功”?因为应用框架层面有重试机制。以Spring为例,@Transactional方法发生异常时会触发事务回滚,异常继续向外抛。如果外层再包了一层重试逻辑——比如@Retryable注解、Spring Retry的RetryTemplate、或者自研的AOP重试切面——那么同一个业务方法会被重新执行一遍。

重新执行意味着完全新开一个事务,重新走一遍SQL。对开发者来说,可能看到的现象是:“明明是死锁报错,但方法最终返回成功了”。如果不做细致追踪,你根本感知不到中间发生过一次回滚和一次重试。

这里就是“让开发者失眠”的核心来源:自动重试把异常掩盖了,但数据的正确性取决于重试是否真的做到了幂等和一致性。如果没做好,轻则日志里丢失真实错误信息,重则出现重复扣款、重复插单、库存对不上。

2.3 一个具体案例:重试“成功”了,数据还是错了

说一个我实际排查过的场景,便于理解问题全貌。有一个支付回调接口,事务方法做了两件事:先给用户余额加钱,再写一条流水记录。某次并发情况下发生了死锁,MySQL回滚了事务。应用层配置了重试策略,于是整个方法重试了一次。这次成功了,余额加了,流水也写了。从接口返回值看,调用方收到“成功”。

但问题出在哪?重试的那次事务里,查询接口从上游回调报文里取订单号,直接用来写流水,并没有对“这个订单是否已经处理过”做幂等校验。在同一时刻,另一个重试任务也收到了同一个订单号,又把余额加了一次。最终余额多加了,流水却有两条,对账时才发现异常。

这个例子说明:事务重试机制本身是合理的,但它要求业务方法必须具备幂等性。否则每次重试都是在制造新的脏数据。这是很多团队在引入自动重试功能时最容易忽略的一环。

注意:@Retryable只控制方法的重新调用,它不会替你做幂等校验。重试之前先问自己三个问题:这笔业务是否有唯一索引?是否使用了乐观锁?是否存在“查询-判断-写入”这种需要加锁保护的竞态窗口?

3. 那些日常开发里最容易引发死锁的典型场景

3.1 最经典的冤家:多个事务以不同顺序更新同一组行

这是所有死锁案例中出现频率最高的。伪代码如下:

-- 事务A UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; -- 事务B UPDATE account SET balance = balance - 50 WHERE id = 2; UPDATE account SET balance = balance + 50 WHERE id = 1;

事务A先锁了id=1的行,再去申请id=2的行锁;事务B刚好相反,先锁了id=2,再去申请id=1。两边各持一把锁,又各等对方的锁,形成标准的循环等待,InnoDB检测后立刻回滚其中一个。

解决办法也很直接:所有事务都按照相同的顺序去访问同一组资源。比如约定“更新账户时总是先更新id较小的那行”,两个事务的执行顺序就统一了,不会再互相环起来。这不是什么高深理论,就是工程规范问题,但很多团队就是没把SQL执行顺序固化下来,导致间歇性出现死锁。

3.2 批量更新时的“顺序不一致”陷阱

批量更新是另一个重灾区。假设有两批任务,分别对一批订单做状态流转:

-- 任务A:批量更新id IN (1, 2, 3) UPDATE order SET status = 'processing' WHERE id IN (1, 2, 3); -- 任务B:批量更新id IN (3, 2, 1) UPDATE order SET status = 'finished' WHERE id IN (3, 2, 1);

表面上看,两批任务执行的是不同状态,语义上没有冲突。但UPDATE ... WHERE id IN (...)的加锁顺序通常按照索引遍历顺序来,如果遍历顺序正好相反,就会互相持有对方需要的锁,死锁就出现了。

这类死锁很迷惑人,因为SQL写得没什么问题,查出来的数据也对,就是偶发在凌晨跑批任务和白天订单状态更新产生交集时。规避方式主要有两种:一是对批量任务做串行化处理,同一张表的批量更新任务不要并行跑;二是把批量更新拆成单条记录逐行更新,并保持相同的更新顺序。拆成单条后性能略有下降,但在低峰期跑批量任务完全可接受。

3.3 插入场景里的间隙锁死锁:这个坑最隐蔽

间隙锁导致的死锁出现得最多的地方是自增主键表配合普通唯一索引的场景。举个例子:用户表有一个手机号唯一索引uk_mobile,并发执行“先删除旧手机号,再插入新手机号”这种操作时,删除操作会持有旧记录附近的间隙锁,插入操作需要申请插入意向锁,如果两条记录落在相邻间隙,就可能互相等待。

还有一种典型是“插入-删除-插入”交替出现。唯一索引的重复插入会触发一次duplicate key检查,这时InnoDB会对已有记录加S锁,如果两个并发事务都在竞争同一个唯一键,就可能演化成死锁。这种死锁没有“顺序更新”那样明显的业务特征,只能靠日志里显示的锁结构来定位。

这里给一个非常实用的建议:如果有“不存则插入,存在则更新”的需求,优先使用INSERT ... ON DUPLICATE KEY UPDATE或REPLACE INTO,让数据库原子地完成“检查-插入/更新”,避免自己先SELECT再判断再插入的竞态窗口。后者的三步操作之间没有任何锁保护,就是死锁和丢更新问题的温床。

3.4 外键和级联操作的系统性风险

InnoDB对外键约束的处理也比较特殊:在对父表记录加锁时,需要检查子表有没有引用关系,这时会在子表上加共享锁;反过来,修改子表外键列时,会在父表上加锁。父子表在并发增删改时,锁的获取顺序一旦交叉,死锁就来了。

有些团队为了省事,数据库表设计里塞满了外键,业务代码里一层层级联更新。表面看逻辑清晰,实际上锁的传播范围完全不可控。我的建议是,电商、交易、支付这类高并发系统,外键约束尽量在业务层自己实现,底层表结构只做索引和约束,不给数据库加外键级联负担。这不是说外键不能用,而是你要知道它的锁成本可能在某个时刻突然放大成一个系统性死锁源。

4. 事务自动重试的正确打开方式:工程化重试设计经验

4.1 先定标准:什么异常值得重试

不是所有异常都应该触发自动重试。重试的错误分类如果做不好,会让业务异常被吞掉、接口响应变慢,甚至造成更大的数据问题。我的经验是把异常分成三类:

  • DeadlockLoserDataAccessException(MySQL错误码1213):死锁导致的回滚,整个事务是干净的,重试安全。
  • CannotAcquireLockException(MySQL错误码1205):锁等待超时,事务也可能已经回滚了,可以重试,但要控制重试次数。
  • 业务校验异常、数据库连接不可用、SQL语法错误等:不允许重试或者重试没意义。

判断标准就一条:重试前必须确认事务已经被完整回滚,且重试不会重复执行业务副作用。1213和1205都是MySQL主动回滚的事务,没有副作用残留,所以适合重试。其他情况大概率是事务还在执行中途就报错了,重试可能产生重复数据。

4.2 重试次数和退避策略:别把短暂的高峰打穿

重试不是“多试几次总有一次成功”的去赌。死锁往往发生在并发高峰期,如果你设置的退避时间不合理,重试会立刻挤入原本已经很紧张的执行队列,形成“失败越多、重试越多、压力越大”的雪上加霜效应。

一个比较通用的经验参数:

参数建议值说明
最大重试次数3次超过3次基本说明系统性锁冲突,重试无意义
初始退避时间100ms给其他事务足够的提交窗口
退避增长策略指数退避,倍率2x100ms -> 200ms -> 400ms
最大退避时间1000ms避免高负载下无限延长的等待
随机抖动每次增加0~100ms随机值防止多个重试在同一时刻齐射

带随机抖动的逻辑很简单但非常有效:假如10个请求同时失败并同时进入重试,如果退避时间完全一致,它们会在同一时刻再次发起请求,下一轮大概率还是同时失败。加了一个随机偏移后,请求会错开,系统自愈概率大幅上升。

4.3 重试必须包住整个事务边界

这是很多人写错的地方。假设你用的是Spring,事务注解加在Service方法上,那重试必须包在这个Service方法的外层,而不是进入方法内部对某条SQL单独重试。原因很简单:事务边界决定回滚范围。如果方法内部第一条SQL成功、第二条SQL死锁失败,单独重试第二条SQL时,第一条SQL的影响还在事务里,重试逻辑会被这个半成品事务搞得一团糟。

推荐做法是单独抽一个重试处理器,在事务方法外层调用。比如:

public OrderResult createOrder(OrderRequest request) { return retryTemplate.execute( context -> orderService.createOrderInTransaction(request) ); }

orderService.createOrderInTransaction上的@Transactional定义的是真正的事务边界,retryTemplate在最外层控制重试流程。每次重试都从整个事务的最开始重新执行,避免残留的未提交操作干扰。

4.4 幂等是重试安全的前提

没有幂等保护的重试,本质是在风险上加杠杆。最基础的三板斧,按优先级排列:

  • 唯一业务键:数据库表加唯一索引,重复插入直接报错,不会出现双份流水。例如支付流水表以“订单号+渠道流水号”作为唯一键。
  • 乐观锁版本号:UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?,失败说明已经被别人改过,重试时重新读取数据再处理。
  • 状态机校验:支付状态从“待支付”到“已支付”是单向的,重复回调时先检查当前状态,不是待支付就直接返回成功。这个方式是幂等性里最实用的。

每重试一次,相当于业务重新走了一遍,所以必须在入口处就把重复操作挡回去。我见过不少团队死磕事务隔离级别、调了一堆锁参数,最后发现问题是回调接口没有做幂等,白折腾了好几天。

4.5 什么时候可以考虑关闭死锁检测

innodb_deadlock_detect=OFF是一个争议比较大的优化项。官方文档说得很清楚:如果某个工作负载有大量热点行,并且死锁检测本身消耗了大量CPU,可以考虑关闭它,然后依赖innodb_lock_wait_timeout让锁等待超时报错。

但要注意,关闭死锁检测后,真正的死锁也无法被及时发现,只能等锁等待超时,默认值是50秒。这意味着死锁现场可能要等50秒才释放,期间这些事务会一直占着连接、堆积在慢查询里。所以关掉之前要三思:热点行冲突是不是真的存在?业务是否允许把死锁错误转化为锁等待超时错误?错误重试的判定条件是否要同步调整?

从我处理过的案例看,大部分系统的死锁频率并不高,死锁检测的开销通常不是瓶颈。真正需要关闭的场景是纯粹的“乐观抢锁”型业务——大量线程更新同一行,通过更新行数判断谁赢,这种情况死锁检测频繁触发确实会吃满CPU。但你得先确认自己属于这种量级,而不是凭直觉去裸调系统参数。

5. 排查实录:拿到死锁日志后,我到底怎么定位根因

5.1 三张最常用的排查表:日志、事务表、锁表

遇到死锁问题,不要盯着应用日志反复看堆栈。数据库的死锁现场全在下面这几个地方:

  • SHOW ENGINE INNODB STATUS\G:这是第一站,里面记录了最近一次死锁的完整信息,包括涉及的事务、持有的锁、等待的锁。
  • information_schema.innodb_trx:查看当前所有活跃事务,关注trx_state、trx_started、trx_query,找长时间不提交的事务。
  • performance_schema.data_locks和performance_schema.data_lock_waits:实时查看锁等待关系,能看出谁在等谁的锁,是定位阻塞源的利器。

排障顺序我一般是:先看innodb_trx找出长时间运行未提交的事务,再通过data_lock_waits看锁等待链,最后用SHOW ENGINE INNODB STATUS最近一次死锁信息做根因确认。

5.2 死锁日志里到底要看哪几行

SHOW ENGINE INNODB STATUS输出的死锁段落很长,但核心信息就集中在几个位置:

LATEST DETECTED DEADLOCK ------------------------ *** (1) TRANSACTION: TRANSACTION 123456, ACTIVE 12 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s) *** (1) HOLDS THE LOCK(S): ... *** (1) WAITING FOR THIS LOCK TO BE GRANTED: ... *** (2) TRANSACTION: TRANSACTION 789012, ACTIVE 8 sec starting index read *** (2) HOLDS THE LOCK(S): ... *** (2) WAITING FOR THIS LOCK TO BE GRANTED: ... *** WE ROLL BACK TRANSACTION (2)

重点看三个地方。第一是WE ROLL BACK TRANSACTION (2),这行告诉你MySQL最终牺牲了哪个事务,也就是谁收到了1213错误。第二是HOLDS THE LOCK(S)和WAITING FOR THIS LOCK TO BE GRANTED,逐个事务对照,理解锁的持有和等待关系。第三是事务内部的SQL语句,日志末尾通常会打印事务最近执行的SQL,这能帮你快速定位是哪条业务代码。

还有一个细节:LOCK STRUCT的数量能反映事务加了多少个锁结构,行锁数量越多的事务越容易被选为牺牲品。如果你发现某个事务持有了几十个行锁,那大概率它会被回滚。

5.3 一个典型的凌晨跑批死锁排查案例

某项目凌晨出过一个死锁告警,现象是两个并发跑批任务,一个做订单归档删除,一个做统计聚合更新。一开始怎么都想不通,因为一个在删订单表,一个在更新统计表,为什么会有锁冲突?

后来看死锁日志才发现,删除订单时MySQL需要维护二级索引上的记录,同时统计聚合任务在订单表上做全表扫描更新汇总行,两个事务在订单索引的相邻间隙上发生了间隙锁和插入意向锁的冲突。这就是“没有共同行数据却死锁”的典型示例。

最后解法是调整任务调度:把统计任务挪到归档删除之后串行执行,而不是并行。死锁直接归零。

5.4 生产环境死锁快速处置速查表

情况直接现象快速处理建议
两个事务不同顺序更新同一组行日志里两个事务的SQL都在UPDATE同一张表统一更新顺序,或在应用层做资源排序
批量任务和单行更新并发批量任务偶发1213批量任务串行化,避免和在线交易并行
唯一键插入冲突死锁日志显示duplicate key伴随死锁用ON DUPLICATE KEY UPDATE或提前做幂等校验
间隙锁死锁日志里有gap lock相关记录考虑降级隔离级别(需评估binlog格式)或串行化冲突任务
锁等待超时错误码1205优化SQL走索引,缩短事务执行时间,必要时调整innodb_lock_wait_timeout

这个表我通常打印出来贴在工位上,每次排查时对照着看一眼,很多时候能直接把方向定位到具体那一类问题上,省得从头看一遍日志。

6. 个人经验与避坑建议

最后分享几个我从踩坑里总结出来的习惯,不展开讲原理,就是直接给结论。

第一,任何涉及事务重试的入口,我一定先把幂等校验写好,再谈重试策略。这件事的顺序不能反。很多人先上了@Retryable,等到数据重复了才想起来补幂等,那已经在线上交过学费了。

第二,死锁日志不是出问题了才去看的。我习惯在每次上线涉及新SQL之前,先在测试环境压一压并发事务场景,SHOW ENGINE INNODB STATUS里看一眼预期内会不会有死锁记录。提前发现比半夜被叫醒去查要舒服得多。

第三,事务方法做得越短越安全。死锁本质是锁的持有时间太长,导致和别人的锁互相等待。把不必要的查询移出事务、把远程调用移出事务、只保留最核心的写操作,锁持有时间降下来,死锁概率会成倍下降。

第四,能查performance_schema就别只盯日志。日志告诉你发生过什么,但data_lock_waits能告诉你当前正在发生的等待关系,一个是事后复盘,一个是实时止血,两个搭配起来效率最高。

死锁这事,说到底是并发资源竞争的一个必然产物。你没法让数据库完全不产生死锁,能做的是把死锁发生后的事情处理干净:正确的重试、幂等的业务、清晰的日志、快速的定位手段。把这些基本功做到位,半夜的告警自然就少了。

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

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

立即咨询