从幂等校验死锁案例谈数据库死锁成因与规避

作者:谁偷走了我的奶酪2026.11.07 02:20浏览量:0

简介:本文从一个真实的高并发订单案例切入,完整推演“先查后插”式幂等校验如何一步步演变为死锁,深入剖析可重复读隔离级别下“间隙锁彼此兼容、插入意向锁与间隙锁互斥”的冲突机制,并给出唯一索引改造、隔离级别调整、事务收敛、死锁检测与应用层重试等多条可落地的规避方案,帮助你系统性地治理高并发场景下的死锁问题。

一、从一个真实案例说起:防重复的幂等校验,为何引发了死锁

某订单类业务的核心操作是新增订单、修改订单、查询订单。由于订单号在业务上不允许重复,开发时在“新增订单”前做了一道幂等性校验:先用 SELECT ... FOR UPDATE 查询订单是否存在,不存在才执行 INSERT。这看似稳妥的“先查后插”,在业务量上来之后,却频繁触发死锁。

先看示意建表语句:

  1. CREATE TABLE `t_order` (
  2. `id` int NOT NULL AUTO_INCREMENT,
  3. `order_no` int DEFAULT NULL,
  4. `create_date` datetime DEFAULT NULL,
  5. PRIMARY KEY (`id`),
  6. KEY `index_order` (`order_no`)
  7. );

注意两个关键前提:

  1. 使用支持行级锁与 MVCC 的事务型存储引擎,隔离级别为可重复读(RR);
  2. order_no 上建的只是普通二级索引,而非唯一索引——业务上的“唯一”只靠应用层校验来保证,这是埋下隐患的伏笔。

假设表中已有 6 条记录,order_no 为 1001~1006。此时两个事务并发插入订单 1007 和 1008,过程如下:

时刻 事务A(插入订单1007) 事务B(插入订单1008)
T1 执行 SELECT ... FOR UPDATE 查 order_no=1007,记录不存在,对 (1006, +∞) 间隙加间隙锁 —
T2 — 执行同样的查询查 order_no=1008,记录不存在,对同一间隙加间隙锁成功(间隙锁之间兼容)
T3 执行 INSERT 1007,需先获取插入意向锁,与B持有的间隙锁冲突,阻塞等待 —
T4 — 执行 INSERT 1008,同样被A持有的间隙锁阻塞,阻塞等待 → 循环等待成立,死锁形成

最终两个事务互相等待对方释放锁(若死锁检测未及时介入,双双挂起;实际运行中检测机制会发现环路并回滚其中一个事务,另一个得以继续)。

再补充一个更隐蔽的问题:如果不用 SELECT ... FOR UPDATE,而用普通 SELECT 做“是否存在”判断,在可重复读级别下会存在幻读——两个携带相同订单号的请求同时到达,都查不到记录、都通过校验、各自插入,于是产生两条重复订单。也就是说:普通 SELECT 防不住并发插入,SELECT … FOR UPDATE 防住了幻读,却引入了死锁,典型的“按下葫芦浮起瓢”。

二、为什么会死锁:锁机制层面的根因

1. 可重复读为什么需要间隙锁

可重复读要解决不可重复读与幻读问题。对于当前读(如 SELECT ... FOR UPDATE、UPDATE、DELETE),为了防止其他事务往已扫描的区间里“插队”写入,除了给记录本身加记录锁,还要给记录之间的“间隙”加间隙锁,二者合称临键锁。本案例中,order_no=1007 的记录不存在,等值查询未命中,锁便落在其所在的间隙上,即 (1006, +∞) 这个区间。

2. 间隙锁彼此兼容,是死锁的温床

间隙锁的设计目的只是“阻止别人往这个间隙里插入”,它并不阻止别人“同样加间隙锁”。因此:

  • 事务A对某间隙加间隙锁后,事务B依然可以对同一间隙再加间隙锁,两者互不阻塞;
  • 但一旦真正要执行 INSERT,就需要先获取插入意向锁,而插入意向锁与已存在的间隙锁冲突,只能等待。

于是形成这样的局面:A 持有间隙锁,等 B 放开间隙锁才能插入;B 持有间隙锁,等 A 放开间隙锁才能插入。谁都无法先释放,循环等待成立。无论两个请求携带的是不同订单号(1007 与 1008 恰好落在同一间隙),还是相同订单号的并发请求,都会触发同样的死锁链条。

3. 用死锁四要素对照验证

经典死锁理论要求同时满足四个条件,本案例全部命中:

  1. 互斥:插入意向锁与间隙锁互斥,同一间隙的插入动作实际只能由一个事务完成;
  2. 请求与保持:两个事务都持有间隙锁,又同时请求插入意向锁;
  3. 不可剥夺:行锁只能由持有者提交或回滚后释放,不能被强行抢占;
  4. 循环等待:A 等 B,B 等 A,等待图成环。

4. 索引设计进一步放大风险

如果查询条件上没有合适索引,SELECT ... FOR UPDATE 会退化为全表扫描,把沿途所有记录和间隙都锁住,锁冲突面急剧扩大,死锁概率与“误伤”范围显著上升。加锁范围由索引决定,这是死锁治理中最容易被忽视的一环。

5. 其他常见的高发模式

除本案例外,以下模式也极易死锁,值得对照自查:不同事务以相反顺序更新多行或多表;大批量更新未按统一顺序排序;热点行并发更新叠加二级索引锁放大;外键级联检查触发的隐式加锁等。

三、如何避免:五条可落地的实践方案

方案一:用唯一索引替代“先查后插”(推荐,治本)

订单号在业务上唯一,就应该建立唯一索引,把幂等约束下沉给数据库,而不是用应用层校验对抗并发:

  1. ALTER TABLE t_order ADD UNIQUE KEY uk_order (order_no);

插入时直接 INSERT,捕获唯一键冲突错误做幂等处理;或按业务需要使用 INSERT IGNORE、INSERT ... ON DUPLICATE KEY UPDATE 等语义。这样彻底删掉预检查步骤,“间隙锁 + 插入意向锁”的死锁组合从源头消失。并发插入相同键值时,后到的事务会等待前者提交,并最终收到唯一键冲突错误,结果依然正确。

需要强调:如果保留“先查后插”,即使把索引改成唯一索引,RR 下对不存在记录的当前读同样会加间隙锁,死锁链条依旧成立。关键不是换索引类型,而是删除预检查这一步。

方案二:将隔离级别调整为读提交(RC)

读提交级别下,普通当前读不再加间隙锁(唯一键冲突检查、外键检查等少数场景除外),“查不到就插入”不再产生间隙锁死锁链。代价是可能出现不可重复读与幻读,因此必须配合方案一,由唯一索引兜底唯一性,否则两个事务都能通过检查并各自插入成功。实践中,多数高并发业务对 RC 接受度很高,且 RC 下锁冲突面更小,整体并发表现更好。

方案三:收敛事务、统一加锁顺序、用好索引

  • 事务要短:事务内只保留必要的数据库操作,避免把远程调用、消息发送、大计算等耗时动作混入事务,缩短持锁时间;
  • 加锁顺序要一致:涉及多行、多表更新时按同一顺序(如按订单号排序)访问,避免交叉等待;
  • 语句必须走索引:为查询与更新条件建立合适索引,避免锁范围被放大到整表。

方案四:开启死锁检测、设置合理超时、应用层重试

  • 保持死锁检测开启:数据库发现等待图成环后,会立即回滚代价较小的事务,让另一个继续执行;超高并发热点场景下检测本身有开销,可结合业务评估权衡;
  • 设置合理的锁等待超时时间,作为检测之外的第二道保险;
  • 应用层务必识别死锁类错误与锁等待超时错误(数据库会返回专门的错误码),对整个事务做重试,且重试逻辑自身必须幂等。

方案五:把幂等与串行化上移到应用层

对同一订单号的并发写,可在应用层先做一道收敛:用分布式锁(以订单号为 key)把同一订单的操作串行化;或通过缓存、去重表等手段先行拦截重复请求,减少直接打到数据库的并发量。注意分布式锁自身的超时与可靠性问题,锁服务异常时要有降级路径。

四、死锁的排查与监控

  1. 查看最近一次死锁日志:数据库引擎状态输出中包含“最近检测到的死锁”详情,能看到两个事务各自持有的锁、正在等待的锁以及被回滚的对象,是定位死锁最直接的入口;
  2. 打开“记录所有死锁”的日志选项,把每次死锁持续写入错误日志,便于统计频次、归纳模式;
  3. 查询系统视图中当前的锁等待关系,定位长事务与阻塞链,及时清理“挂着不提交”的会话;
  4. 若使用托管型云数据库(例如百度智能云的云数据库类产品),控制台通常提供死锁次数、锁等待时长等监控指标与告警能力,可先于用户发现问题;
  5. 建立研发规范:所有 SELECT ... FOR UPDATE、UPDATE、DELETE 语句上线前审查执行计划,确保走索引;事务边界在代码评审中显式标注。

五、总结

回到案例本身,死锁的完整因果链是:业务要求订单号唯一 → 用普通索引 + 应用层“先查后插”做幂等 → RR 下等值查询未命中会对间隙加间隙锁 → 间隙锁彼此兼容、插入意向锁与间隙锁互斥 → 并发插入时双方持锁互等 → 死锁。

治理思路可浓缩为四句话:

  1. 唯一性约束交给数据库(唯一索引),不要用应用校验对抗并发;
  2. 消除“先查后插”模式,或将隔离级别调整为读提交;
  3. 事务要短、加锁顺序要一致、语句要走索引;
  4. 保持死锁检测与超时兜底,应用层对死锁错误做幂等重试。

最后要认识到:死锁无法被绝对消灭,治理目标是把发生概率压到可忽略,并保证发生时能快速发现、快速恢复。一句话收束——把约束下沉给数据库,把重试留给应用,把监控交给平台。