一次订单并发更新引发的 MySQL 死锁:从等待环定位到统一加锁顺序
一次订单并发更新引发的 MySQL 死锁:从等待环定位到统一加锁顺序
同一笔订单,可以同时收到售后处理、人工采集、移动端复核和自动化设备回调。这些请求分别看都符合业务规则,组合起来却可能在数据库中互相等待。
这次排查遇到的就是这样的情况:来自两台不同宿主机的请求,分别在数据库中开启事务,并发修改同一笔订单。两条代码路径都涉及订单级状态记录和商品明细记录,但取得行锁的顺序相反:一个事务拿着订单级状态记录的锁,等待商品明细;另一个事务拿着商品明细的锁,等待订单级状态记录,最终在 InnoDB 中形成环形等待,也就是典型的加锁顺序反转(lock inversion)。
本文按“还原等待关系 → 定位业务路径 → 选择修复方案 → 验证边界”的顺序,记录这次问题的处理过程。文中的数据结构、业务标识和代码均经过抽象,不包含真实表名、字段名、类名、订单号或部署信息。
1. 业务场景:一笔订单有多个写入入口
在订单履约过程中,至少有几类操作可能修改商品与订单状态:
| 业务入口 | 主要动作 |
|---|---|
| 售后处理 | 调整商品信息,记录订单内容已发生变化 |
| 人工采集 | 保存商品采集结果,更新订单级完成状态 |
| 移动端复核 | 校验、重新标记商品,更新复核相关信息 |
| 自动化设备回调 | 保存部分商品的处理结果,再补齐其他商品的履约信息 |
这些动作涉及两类记录。为了便于讨论,下面用 A 和 B 表示:
- A:订单级状态记录。 每笔订单对应一条记录。
- B:商品明细记录。 每笔订单对应多条记录。
一次请求可能先修改 A,再修改 B;另一次请求可能先修改 B,再修改 A。只要两个事务操作到相同记录,并在事务结束前持续持锁,就具备形成等待环的条件。
这里不需要假设很高的并发量。两个时序恰好交错的事务,就足够触发问题。
2. 死锁根因:两个宿主机上的事务如何互相卡住
2.1 先分清宿主机、请求和数据库事务
本次报错材料中,两个参与者来自不同宿主机,并对应不同的数据库连接和事务。可以抽象成下面的关系:
宿主机甲上的请求 → 数据库事务 T1 ─┐
├→ 同一个数据库中的同一订单
宿主机乙上的请求 → 数据库事务 T2 ─┘
应用实例虽然分布在不同机器上,但它们访问的订单数据是共享的。两个请求一旦进入数据库,InnoDB 会根据实际访问的记录、索引和锁模式协调并发,不会因为请求来自不同宿主机而把数据隔离开。
如果业务层没有让这两个入口在修改同一订单之前互斥,它们就可能同时执行。单个 JVM 内的锁无法协调另一个 JVM;分别使用不同标识的 Redis 锁,也无法让两个请求排成同一条队列。
不同宿主机说明了本次请求的来源,但不是死锁发生的必要条件。 即使只有一个应用实例,只要两个数据库事务以同样的方式交错执行,也可能形成相同的等待环。
2.2 从“当前 SQL”与“已经持有的锁”看出相反顺序
报错 SQL 通常只展示事务当时执行到哪一步,不能代表它此前没有做过其他操作。本次材料中的关键信息可以整理为:
| 观察项 | 事务 T1 | 事务 T2 |
|---|---|---|
| 来源 | 宿主机甲上的请求 | 宿主机乙上的请求 |
| 当前操作 | 更新该订单的某条商品明细 | 更新该订单的订单级完成状态 |
| 已持有的锁 | A 中对应记录的排他锁 | B 中相关明细记录的排他锁 |
| 正在申请的锁 | B 中目标明细记录的排他锁 | A 中对应记录的排他锁 |
| 报告中的处理结果 | 被选中回滚 | 未被选中回滚 |
这张表解释了为什么“一个在改商品,另一个在改订单状态”仍然会互相影响:它们分别已经拿到了对方下一步需要的资源。
T1 当前虽然只显示商品更新语句,但它已经持有 A 的锁。这把锁是在当前语句之前取得的,可能来自此前的写入或加锁读取,需要结合代码和 SQL 轨迹确认具体来源。T2 同理:当前正在更新 A,不代表它没有锁住 B。
从这组持锁与等待关系中,可以还原出两条相反的锁序:
T1:先取得 A 的锁 → 再申请 B 的锁
T2:先取得 B 的锁 → 再申请 A 的锁
这才是定位代码时应当追踪的特征。商品更新语句很长、更新字段很多,能够帮助搜索候选调用点,但这些外观特征本身并不是形成等待环的必要条件。
2.3 按时间顺序还原四步交错
把同一订单的状态记录记为 A,将发生冲突的那条商品明细记为 B。下面是一种与报告等待关系相符的执行时序,用于解释机制,并非完整历史 SQL 轨迹的逐条重放:
| 时间 | 宿主机甲:事务 T1 | 宿主机乙:事务 T2 |
|---|---|---|
| 第一步 | 修改 A,取得 A 的排他锁,事务尚未提交 | — |
| 第二步 | 继续执行业务逻辑,仍持有 A 的锁 | 修改 B,取得 B 的排他锁,事务尚未提交 |
| 第三步 | 尝试修改 B;因为 B 被 T2 锁住,进入等待 | 仍持有 B 的锁 |
| 第四步 | 等待 B,同时继续持有 A 的锁 | 尝试修改 A;因为 A 被 T1 锁住,也进入等待 |
关键发生在第三步:T1 等待 B,并不会主动释放已经持有的 A 的写锁。 对于这里讨论的事务性写入,这些记录锁会保留到事务提交或回滚。否则,事务还没结束,它已经修改的数据就可能失去必要的并发保护。
T2 也一样。于是第四步之后,双方都无法走到正常提交的位置:
T1 要继续执行,需要 T2 释放 B
T2 要继续执行,需要 T1 释放 A
T1 ──等待 B──→ T2
↑ │
└──等待 A───────┘
事务提交或回滚会释放其持有的 InnoDB 锁,相关语义见 MySQL 事务提交与回滚说明。
2.4 为什么这里是死锁,而不只是执行慢
普通锁等待是单向的。例如 T2 等待 T1,而 T1 可以继续执行并提交,那么等待最终有机会自然结束。
本次关系则是闭环:T1 的进展依赖 T2,T2 的进展又依赖 T1。单纯增加超时时间,无法消除这种依赖关系。
报告展示的锁描述中有几项值得关注:
| 通用锁描述 | 在这里的含义 |
|---|---|
PRIMARY |
冲突锁涉及主键索引记录 |
lock_mode X |
申请或持有排他锁;两个事务不能同时持有同一记录上互不兼容的排他锁 |
locks rec but not gap |
这一项锁描述指向记录锁,不包含间隙锁 |
waiting |
当前锁请求尚未获得批准,事务正在等待 |
所以,这里的主要矛盾是既有记录上的排他锁冲突,加上相反的资源获取顺序。它并不要求发生全表扫描,也不要求存在间隙锁;精确命中主键的更新同样可以参与死锁。
锁保护的粒度也不是“某个字段”。即使两个操作修改的业务字段不同,只要需要取得同一索引记录上的互斥锁,仍然可能产生等待。这里的“跨表死锁”同样不意味着两张表都被整表锁住,冲突可以发生在两张表各自的少量记录上。
另外,相同页号只能帮助关联锁所在位置,不能单独证明锁住了同一行。严格核查应结合完整死锁输出中的具体记录内容或记录位置,以及事务等待关系,不能把“同页”直接当成“同行”。
2.5 为什么一个订单级状态记录会成为共同争用点
一笔订单可以有多条商品明细,但相关业务状态通常集中在同一条订单级记录中。不同入口处理的商品范围可能不同,最后却都需要回到这条记录上更新状态。
这让 A 成为同订单写入流程容易相遇的位置。只要商品明细的更新范围也有重叠,就可能出现“一边持有 A 等待某条 B,另一边持有该条 B 等待 A”的关系。
但“同一订单”本身还不足以证明死锁。如果两边操作的明细没有重叠,也不存在其他冲突资源,仅凭都要修改 A,通常只会在 A 上排队。是否形成闭环,必须根据实际持锁和等待情况判断。
这次问题可以分成三层原因:
- 业务并发条件: 多个入口允许同一订单的操作在两个宿主机上同时进入数据库事务。
- 冲突持续条件: 两个事务都访问到重叠记录,第一步取得的排他锁尚未随事务结束而释放。
- 直接成环原因: 两条路径分别采用 A → B 和 B → A 的顺序,且执行时序交错,最终互相等待。
缺少跨入口的共同互斥协议,使请求能够相遇;相反的加锁顺序,则把一次可能正常排队的竞争变成了循环依赖。这也解释了为什么本次可以先从统一数据库加锁顺序入手,而不必把修改 Redis 锁当作唯一修复手段。
2.6 为什么数据库回滚了其中一个事务
在启用死锁检测的情况下,InnoDB 可以发现等待图中的环,并选择一个事务作为回滚对象,使另一个事务有机会继续执行。本次材料记录的是 T1 被选中回滚。
这并不表示 T1 的业务逻辑一定比 T2 更错误,也不表示 T2 的整段业务已经成功完成。报告中的存活状态,只能说明它没有在这次死锁处理中被选为回滚对象。
发生死锁时,InnoDB 回滚的是受害事务,而不只是那条正在等待的 SQL。因此,应用需要处理整笔事务失败的结果,并重新评估后续重试的幂等性。参见 MySQL 死锁检测和 InnoDB 错误处理。
2.7 如何把这条证据链保留下来
排查时可以通过下面的命令查看最近一次 InnoDB 死锁信息:
SHOW ENGINE INNODB STATUS;
需要持续采样时,可以评估启用 innodb_print_all_deadlocks,将死锁信息记录到错误日志,并做好日志访问控制。相关诊断方法见 MySQL 官方死锁处理文档。
整理复盘时,应将原始报错片段与自动生成的摘要逐项核对。业务编号、时间或连接信息一旦混入另一轮事件,后续关联应用日志就可能找错对象;没有统计样本,也不应给出精确的死锁发生概率。
这里还要保留一个证据边界:持锁与等待关系支持数据库层的锁序反转判断,但仅凭截断的 SQL,未必能够唯一确定历史请求来自哪个业务方法。 精确归因仍需要结合对应时刻的调用链、应用日志、连接信息以及可获得的 SQL 记录。
3. 从数据库现象回到业务事务
拿到 A → B 和 B → A 两种顺序后,代码排查才有了明确方向。
我的做法是先找出两类记录的写入方法,再向上追踪调用者,直到找到实际事务边界。这里有三个容易遗漏的地方。
3.1 看完整事务,不能只看一个公共方法
假设某个公共保存方法是:
保存商品处理结果
更新订单级完成状态
它表面上体现的是 B → A。但如果上层在调用它之前,已经因为其他业务分支修改了 A,那么整个事务可能变成:
A → B → A
反过来,即使某个方法内部已经调整了顺序,上层若提前修改过商品明细,也仍然可能持着 B 的锁去等待 A。
因此,加锁顺序应当以一个真实数据库事务内的完整调用链为单位分析。
3.2 正常分支与降级分支可能走出不同顺序
本次复核路径中,外部依赖异常触发降级时,可能先更新订单级标记,再继续处理商品。正常分支则可能先处理商品,稍后再写订单级状态。
只检查正常流程,就会漏掉这种反向顺序。
类似地,自动化回调还可能先更新一部分商品,再写订单级状态,最后补齐其他商品信息。它的事务序列可能是:
B 的部分记录 → A → B 的其他记录
这也是为什么不能只靠移动末尾的一条更新语句来判断问题已经解决。
3.3 连续两次写入,不一定属于同一个事务
搜索结果中出现“先写 B,再写 A”,只能说明调用顺序可疑,不能直接证明它就是这次死锁的参与者。
还需要确认:调用是否经过 Spring 事务代理、事务是否向下传播、有没有开启独立事务,以及第一步持有的锁会不会保留到第二步。
Spring 默认代理模式下,同类内部直接调用一个带事务注解的方法,并不会因此重新经过事务拦截。这一点可参考 Spring 的事务注解说明。不过,内部调用仍可能运行在调用者已经开启的事务中,不能据此反推“必然没有事务”。
完成这些核对后,本次修复聚焦在售后处理、人工采集、移动端复核和自动化回调四个核心事务入口。它们的具体分支并非每次都会同时修改 A 和 B,但相关写入路径需要遵循同一个锁顺序约定。
4. 已经用了 Redis 锁,为什么还会冲突
项目中存在分布式锁,并不等于所有相关请求都互斥。
不同入口可能使用不同业务编号生成锁,也可能处于不同的命名空间。还有入口根本没有参与同一套锁协议。即使它们最终修改的是同一笔订单,也可能同时进入数据库事务。
例如,下面是两套抽象的锁标识:
某入口:业务域甲 + 内部订单标识
另一入口:业务域乙 + 外部订单标识
“都加了锁”无法证明它们取得的是同一把锁。
排查时要核对的是:同一业务实体是否映射到同一个锁标识,相关写入者是否都参与,以及保护范围是否覆盖到数据库事务真正提交。
最后一点尤其容易忽略:如果在带事务的方法内部通过 finally 解锁,而数据库提交由外层 Spring 代理完成,那么 Redis 解锁时,事务可能尚未提交。
因此,统一分布式锁需要单独评估等待行为、锁持有时间、失败返回和既有入口的互斥范围。它可以用于业务协调,但不能仅凭“统一了 key”就宣称数据库死锁已经被根治。
5. 本次选择:先取得订单级状态记录的锁
这次先处理数据库层已经确认的等待环,保留现有 Redis 锁的 key、粒度和获取方式。
采用的约定是:
涉及同一订单的相关事务,在修改商品明细及其他业务数据之前,先取得该订单 A 记录的排他锁,并一直持有到事务结束。
这里统一的是首次取得锁的顺序。业务上的某些状态更新仍然可以保留在后面,只要对应记录的锁已经提前拿到。
抽象后的流程如下:
开启事务
取得该订单 A 记录的排他锁
读取并校验当前业务状态
处理商品明细及相关数据
更新订单级业务状态
提交事务,释放数据库锁
两个遵守该约定的事务并发处理同一订单时:
T1:取得 A 的锁 → 处理商品和状态 → 提交
T2:等待 A 的锁 → 取得锁后读取状态 → 继续处理
T2 会在持有商品明细锁之前等待,从而切断本次讨论的“一个持有 A 等 B,另一个持有 B 等 A”的闭环。
这条结论有明确前提:相关事务都必须遵守约定,而且不能在进入公共加锁方法之前已经取得相反顺序的锁。它不意味着其他表、其他索引或其他业务路径上的死锁也一并消失。
选择 A 作为共同入口,是因为它在该业务模型中每单一条、身份稳定,适合让同订单事务在这里依次执行。不同订单可以锁住不同记录;不过,如果后续还会争用共享库存等资源,仍可能发生其他等待。
6. 落地时,真正需要小心的几个细节
6.1 加锁与业务修改必须属于同一个事务
公共加锁方法要求调用方已经存在事务,并加入这个事务。项目中采用了 MANDATORY 传播行为来约束调用方式。
如果把加锁方法放进一个独立事务,方法返回时锁就释放了,外层后续更新自然得不到保护。需要注意的是,业务入口本身使用独立事务,与公共加锁方法另外开启独立事务,是两回事。
同样,MyBatis 的缓存也不能成为绕过实际加锁 SQL 的原因。加锁查询需要真正到达数据库,并取得当前记录。
6.2 扩展记录缺失时,不能假设已经锁住了一条现有行
历史数据并不一定始终具备完整的附加记录。仅执行一次 SELECT ... FOR UPDATE,如果没有查到记录,就不能把它理解为“成功锁住了那条已存在的订单记录”。
缺失记录情况下,具体锁行为还与索引和隔离级别有关。若再简单采用“查不到就插入”,并发补建可能引入新的竞争。
本次落地使用的步骤是:
在当前事务中:
按订单唯一标识执行幂等补建
已存在时执行不改变业务值的重复键更新
再通过加锁查询读取当前记录
这里使用了 INSERT ... ON DUPLICATE KEY UPDATE 的机制。在主键重复的情况下,它会取得待更新记录的排他锁;这与普通插入发生重复键冲突时的锁行为不同。参见 MySQL 不同语句的加锁说明。
这不是可以不加检查地复制到任何表上的通用模板。实施前需要确认唯一约束、默认值、触发器以及补建后参与业务校验的含义。本次验证还检查了已有业务值和自动更新时间没有被这种无变化更新改写。
对于无效订单,则需要回滚补建动作,避免失败请求留下孤立记录。
6.3 等待锁后,要重新读取业务状态
即使请求进入系统时订单还可以处理,等待期间,前一个事务也可能已经完成复核、提单或其他状态变更。
因此,取得锁之后不能继续无条件使用事务外传入的旧对象,应重新读取并校验订单和商品状态。
这里还有一个更隐蔽的问题:在 InnoDB 的 REPEATABLE READ 下,普通一致性读通常复用首次一致性读建立的快照。在同一个事务里再查一次,不一定就看到了锁等待期间别人提交的新值。 锁定读也不会自动把此前的普通读快照整体刷新。参见 MySQL 一致性读文档。
本次让相关入口尽量先加锁,再建立业务读取快照。对于必须先通过外部编号定位内部订单的入口,定位查询在调用方事务之外执行,只取定位所需的标识;取得订单级锁后,再在业务事务内读取并校验状态与订单归属。
这样做也有成本:挂起事务期间可能需要另一条数据库连接,因此不能把它当成随处添加的通用操作。
6.4 提前返回同样需要审查
事务方法返回一个失败结果,通常不等于事务会自动回滚。
以前某个校验失败发生在任何写入之前,直接返回没有问题。提前增加补建动作后,同一个返回点就需要重新判断:补建是否应当提交,还是应该随失败一起撤销。
这次对订单不存在的相应路径明确设置了回滚,同时保留失败返回。其他提前返回点也应按已有业务语义判断,不能一概认为“返回失败必然全回滚”或“全部回滚一定正确”。
7. 如何验证这次修复
死锁修复需要真实数据库的锁行为来支撑判断。仅验证方法调用顺序,无法证明事务是否真的等待、是否读到旧快照,以及锁是否持续到提交。
本次使用独立的本地 MySQL 8.0 实例,结合真实的 MyBatis 映射和 Spring 事务代理进行验证。测试采用最小表结构和模拟业务写入序列,不连接外部业务服务。
共完成了 8 项测试:
| 验证项 | 结果 |
|---|---|
| 复现原始反向写入顺序 | 成功触发预期死锁 |
| 加锁方法在无事务环境下被调用 | 被事务约束拒绝 |
| 已有记录重复取得锁 | 业务值及自动更新时间保持不变 |
| 同订单等待、不同订单并行 | 同订单请求等待,另一订单可继续执行 |
| 并发补建缺失记录 | 最终只有一条记录,默认值符合预期 |
| 补建及后续修改回滚 | 新记录和修改均撤销,之后可以重新取得锁 |
| 定位订单后等待锁再读状态 | 读到前一个事务提交的新状态 |
| 模拟售后、采集、降级及混合商品写入序列 | 采用共同加锁入口后均完成执行 |
测试同时使用同步屏障或明确的等待信号控制关键时序,避免只依赖“多跑几次,希望碰巧撞上死锁”。
这组证据支持的是:原始等待环能够复现,共同加锁协议在所覆盖的场景下按预期工作。它不等于四个业务入口已经完成全部外部服务联调,也不等于已经获得生产环境长期运行的效果数据。
8. 修复后的成本与后续观察
提前取得订单级行锁,会让同订单请求更早排队。这正是修复发挥作用的方式,也会扩大某些流程的持锁时间。
如果事务内包含较慢的外部调用,后续同订单请求也会等得更久。上线验证阶段应观察锁等待、事务耗时、超时比例和数据库连接占用,而不能只盯着死锁数量。
后续可以结合业务一致性要求,逐步缩短事务、减少不必要的持锁操作。若一个事务需要处理多笔订单,还必须约定订单之间的锁顺序,避免两个事务分别拿着不同订单的锁互相等待。
对于残余死锁,也可以评估有限次数的整事务重试,但必须先判断外部调用是否幂等。不能因为数据库回滚了,就默认已经发出的消息或远程请求也一并撤销。本次修改没有顺带引入自动重试,也没有更改现有分布式锁协议。
这次排查最值得保留的方法,是把三个层面连起来看:数据库正在等待什么,业务事务已经做过什么,以及代码打算在哪里释放锁。把这条链路还原清楚之后,才能知道该调整哪个入口、哪个事务边界,以及用什么测试证明修改确实切断了原来的等待环。

浙公网安备 33010602011771号