深入理解数据库死锁:成因剖析与规避实践

作者:新兰2026.11.06 19:58浏览量:0

简介:订单幂等校验中常见的“先查后插”写法,在高并发下极易触发数据库死锁。本文以一个真实业务场景为切入点,完整复现死锁发生全过程,拆解记录锁、间隙锁、临键锁与插入意向锁的协作机制,并给出唯一索引下沉校验、事务瘦身、隔离级别权衡、统一加锁顺序等可落地的防范方案,帮助你写出更健壮的并发代码。

一、从一个订单幂等场景说起

很多业务系统的核心链路都围绕“订单”展开:创建订单、修改订单、查询订单。其中创建订单有一个典型约束——同一订单号不能重复落库。为保证这一点,开发同学常在插入前做幂等性校验:先用 SELECT ... FOR UPDATE 查询该订单号是否存在,存在则直接返回,不存在才执行插入。

加 FOR UPDATE 是有意为之:如果只用普通 SELECT,两个携带相同订单号的请求同时到达时,可能都查到“记录不存在”,随后各自插入,最终产生重复订单,幂等性失效。

然而,这套看似严谨的“先查后插”,在业务量上来之后却埋下了死锁隐患。下面以支持事务的主流关系型数据库、可重复读(RR)隔离级别为背景,完整复盘一次死锁的产生。

二、复现准备:表结构与数据

建一张订单表,id 为主键,order_no 上建有普通(非唯一)索引:

  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. );

表中已有 6 条记录,order_no 依次为 1001~1006。此时有两个并发事务:事务 A 要创建订单 1007,事务 B 要创建订单 1008。两者都会先执行幂等查询:

  1. -- 事务 A
  2. SELECT * FROM t_order WHERE order_no = 1007 FOR UPDATE;
  3. -- 事务 B
  4. SELECT * FROM t_order WHERE order_no = 1008 FOR UPDATE;

三、死锁是如何一步步发生的

把两个事务的执行时序展开:

时刻 事务 A(插 1007) 事务 B(插 1008)
T1 查询 1007,记录不存在,对区间 (1006, +∞) 加间隙锁
T2 查询 1008,记录不存在,对同一区间 (1006, +∞) 加间隙锁
T3 插入 1007,需获取插入意向锁,被 B 持有的间隙锁阻塞
T4 插入 1008,需获取插入意向锁,被 A 持有的间隙锁阻塞

T4 之后,A 在等 B 释放间隙锁,B 在等 A 释放间隙锁,两个事务陷入循环等待。若关闭死锁检测,二者将无限期挂起;若开启死锁检测(多数实现默认开启),数据库会立即回滚其中回滚代价较小的事务,并向另一个事务放行,同时向被回滚一侧的客户端返回死锁错误。错误日志中通常也会记录最近一次检测到的死锁详情,便于事后排查。

值得注意:即使两个事务插入的是同一个订单号,结局也类似——各自持有兼容的间隙锁,又互相等待对方的插入意向锁,同样构成死锁。

四、原理拆解:几类锁如何协作

理解上述现象,关键在于可重复读隔离级别下的几种锁。

1. 记录锁(Record Lock)

锁定索引上的一条具体记录,阻止其他事务修改它。当查询走唯一索引且命中已存在的记录时,加的就是纯记录锁。

2. 间隙锁(Gap Lock)

锁定索引记录之间的“空隙”,例如相邻记录 1005 与 1006 之间的区间。它的使命只有一个:阻止其他事务在空隙里插入新数据,从而在可重复读级别下杜绝幻读。它不阻塞普通读取,只阻塞插入。

间隙锁有一个非常关键、也最容易踩坑的特性:不同事务对同一空隙持有的间隙锁是彼此兼容的。事务 A 加了 (1006, +∞) 的间隙锁,事务 B 依然可以对同一区间再加间隙锁,互不冲突。这正是上文 T2 时刻两个事务都能“顺利拿到锁”的原因。

3. 临键锁(Next-Key Lock)

记录锁与间隙锁的组合,锁定“记录本身+记录前的空隙”。当条件列是非唯一索引、或目标记录不存在时,引擎加的往往是临键锁或纯间隙锁,而非单纯的记录锁。案例中 1007、1008 都不存在,条件列又不是唯一索引,所以两次幂等查询最终都落在 (1006, +∞) 这段空隙上。

4. 插入意向锁(Insert Intention Lock)

事务在真正插入前需要先获取的一种特殊意向锁。多个插入意向锁之间互不冲突(各插各的行),但它与间隙锁天然互斥:只要空隙上存在其他事务的间隙锁,插入就必须等待。这条规则本是为了保护间隙锁“禁止插空”的语义,但在“两个事务先各持间隙锁、再各自插入”的场景下,直接造就了闭环等待。

五、死锁的根本原因

把机制串起来,死锁链条就非常清晰:

  1. 幂等查询未命中,且条件列为非唯一索引,于是加间隙锁;
  2. 间隙锁彼此兼容,两个事务“不知不觉”持有了同一区间的锁;
  3. 插入需要插入意向锁,而它与对方持有的间隙锁互斥,于是互相等待;
  4. 等待关系成环,死锁形成。

本质上,这类死锁源于“加锁顺序不一致 + 锁区间重叠”。只要两个事务以相反顺序竞争重叠的锁资源,无论业务场景如何变化,死锁都会遵循同样的模式出现。

六、如何避免死锁

1. 用唯一索引替代“先查后插”(推荐)

给 order_no 建唯一索引,把幂等性校验下沉给数据库:

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

之后直接插入,重复插入会因唯一约束冲突而失败,业务捕获重复键错误即可,或使用数据库支持的“冲突则忽略”“冲突则更新”类插入写法。这样根本不再需要 SELECT ... FOR UPDATE,间隙锁死锁的土壤被直接铲除,幂等性反而由数据库的强约束保证,比应用层校验更可靠。

2. 精确化查询条件,缩小锁范围

查询尽量走唯一索引精确命中。命中已存在的唯一记录时只需记录锁,不会波及周围空隙,锁区间不重叠,死锁概率大幅降低。

3. 缩短事务、统一加锁顺序

事务里只保留必要的数据库操作,不要夹杂远程调用、耗时计算,尽快提交以释放锁;同一事务需要锁多行时,先按统一规则(如主键升序)排序再操作,保证加锁顺序一致;批量更新拆成小批次,避免单事务锁定过大范围。

4. 权衡隔离级别

可重复读下间隙锁是防幻读的必要手段。若业务对幻读不敏感,可将隔离级别降为读已提交(RC),该级别下间隙锁基本不再使用(仅外键、唯一性检查等少数场景保留),间隙锁类死锁随之消失。代价是放弃了可重复读语义,需结合业务评估后决策。

5. 关键路径引入应用层互斥

对“同一订单号只允许一个写入者”这类强约束,可在入口处用分布式锁(或本地互斥锁)把校验与插入串行化,数据库层自然无锁竞争。注意设置合理的超时与重试,避免把并发问题转化为可用性问题。

6. 保留死锁检测与重试兜底

主流实现默认开启死锁检测,能在死锁形成瞬间回滚代价较小的事务。业务侧应把死锁错误视为可重试异常:捕获后按随机短间隔(或指数退避)重试,通常即可自愈。即便采取了上述所有措施,保留这一层兜底仍是必要的防御深度。

7. 建立监控与排查手段

日常可借助数据库提供的锁等待视图、错误日志定位死锁现场,分析互等的两条事务各自持有的锁类型与索引信息。若业务部署在云端,例如使用百度智能云上的托管数据库与监控告警类通用能力,还应配置锁等待、死锁发生次数的告警,在问题影响用户之前感知异常趋势,并结合慢查询分析持续优化热点表的访问模式。

七、总结

“先查后插”的幂等写法,在可重复读隔离级别配合非唯一索引时,会让并发请求各自持有相互兼容的间隙锁,再因插入意向锁与间隙锁互斥而互相等待,最终形成死锁。规避它的核心思路有两条:一是釜底抽薪,用唯一索引把幂等校验交给数据库,删掉不必要的锁定查询;二是控制并发秩序,缩短事务、统一加锁顺序、必要时权衡隔离级别,并始终保留死锁检测与重试兜底。真正理解记录锁、间隙锁与插入意向锁的协作关系,不仅能化解这一类死锁,也能帮助你在设计任何并发写入逻辑时提前预判锁行为,写出更健壮的代码。