logo

MySQL死锁成因深度剖析与规避实践

作者:很酷cat2026.11.06 20:31浏览量:0

简介:本文从一个真实订单幂等校验场景出发,剖析可重复读隔离级别下间隙锁与next-key锁如何引发死锁,结合加锁规则、锁等待分析与SQL示例,给出事务设计、索引优化、锁粒度控制等可落地规避策略,帮助读者系统理解死锁机理并构建高并发下的稳定数据库访问方案。

MySQL死锁成因深度剖析与规避实践

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

在订单类业务中,”订单号不可重复”是一条基本约束。为了保证幂等性,很多实现会采用这样的流程:在插入订单记录之前,先用 SELECT ... FOR UPDATE 查询该订单号是否已存在,若不存在再执行 INSERT。这个做法看似稳妥,却在高并发场景下埋下了死锁的隐患。

本文围绕这一典型场景展开,使用的存储引擎为 InnoDB,隔离级别为可重复读(RR)。我们先复现问题,再解释底层加锁机制,最后给出系统性的规避方案。

先看一张简化的订单表结构:

  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`) USING BTREE
  7. ) ENGINE=InnoDB;

其中 id 为主键索引,order_no 为普通非唯一索引。假设表中已有 6 条记录,此时两个事务分别尝试插入订单号 1007 和 1008:

  • 事务 A 执行 SELECT * FROM t_order WHERE order_no = 1007 FOR UPDATE;,由于记录不存在,InnoDB 会在 order_no 索引上对相应间隙加锁。
  • 事务 B 执行 SELECT * FROM t_order WHERE order_no = 1008 FOR UPDATE;,同样对间隙加锁。
  • 随后两个事务各自执行 INSERT,此时需要往对方持有的间隙中插入数据,于是互相等待对方释放锁,形成环路,死锁发生。

在没有开启死锁检测的情况下,两个事务都会进入等待状态;而即便开启了死锁检测,InnoDB 也只能回滚其中一个事务来打破环路——业务层面依然会感知到失败与重试。

二、为什么”查不存在再插入”会加锁到间隙上

要理解死锁的根源,必须先理解 RR 隔离级别下的幻读问题与 Next-Key Lock。

1. 幻读与 Next-Key Lock

RR 隔离级别要求同一事务内多次执行相同查询,结果保持一致。但单纯的记录锁只能锁住已存在的行,无法阻止其他事务在”空隙”中插入新记录,从而产生幻读。InnoDB 为此引入了 Next-Key Lock,它是记录锁(Record Lock)与间隙锁(Gap Lock)的组合:

  • 记录锁:锁住索引记录本身;
  • 间隙锁:锁住两条索引记录之间的开区间,阻止其他事务在该区间插入新值;
  • Next-Key Lock:左开右闭区间,即”间隙 + 右侧记录”。

当查询条件命中一个不存在的值时,InnoDB 无法加记录锁,只能对该值所在的间隙加间隙锁。这里的关键在于:间隙锁之间是兼容的,多个事务可以同时持有同一间隙的间隙锁,但它们接下来执行的插入操作却会互相冲突。

2. 死锁环路的形成

以上述场景为例,假设 order_no 索引上的记录为 1001、1002、1003、1004、1005、1006。事务 A 查询 1007,落在 (1006, +∞) 的间隙;事务 B 查询 1008,同样落在 (1006, +∞) 的间隙。两者都能成功拿到间隙锁,因为间隙锁互不排斥。

随后事务 A 执行 INSERT order_no = 1007,需要在该间隙中插入,而插入意向锁与事务 B 持有的间隙锁冲突,事务 A 等待;事务 B 执行 INSERT order_no = 1008,同样与事务 A 的间隙锁冲突,事务 B 等待。双方都在等对方释放间隙锁,环路形成,死锁不可避免。

用一张简化的时序表描述:

步骤 事务 A 事务 B
1 SELECT ... WHERE order_no=1007 FOR UPDATE(持间隙锁) -
2 - SELECT ... WHERE order_no=1008 FOR UPDATE(持间隙锁)
3 INSERT order_no=1007(等待 B 的间隙锁) -
4 - INSERT order_no=1008(等待 A 的间隙锁)
5 死锁,InnoDB 回滚其中一个事务 同左

3. 不用 FOR UPDATE 会怎样

如果把 SELECT ... FOR UPDATE 换成普通 SELECT,虽然不会加锁、不会死锁,但会带来另一个问题:两个相同订单号的请求同时进来时,都可能查到”不存在”,然后各自插入,最终产生两条重复订单。这说明”幂等校验”与”锁竞争”之间存在天然的张力,需要更精细的设计来平衡。

三、容易被忽视的其他死锁成因

除了上述”间隙锁 + 插入意向锁”的经典组合,实际生产中还有几类高频死锁来源。

1. 加锁顺序不一致

两个事务以不同顺序访问同一批资源,是最常见的死锁模式。例如事务 A 先更新订单表再更新库存表,事务 B 先更新库存表再更新订单表,两者交叉等待。这类问题与隔离级别无关,本质是资源获取顺序不一致。

2. 唯一索引冲突与共享锁升级

在唯一索引上做 INSERT ... ON DUPLICATE KEY UPDATE 或 INSERT IGNORE 时,若发生键冲突,事务可能先持有共享锁再尝试升级为排他锁,多个事务同时升级就会互相等待。此外,SELECT ... FOR UPDATE 在唯一索引等值查询命中记录时通常只加记录锁,但未命中时仍会退化为间隙锁。

3. 二级索引与主键索引的双重加锁

通过二级索引更新数据时,InnoDB 需要先在二级索引上加锁,再回到主键索引上加锁。若两个事务走不同的二级索引路径更新同一批主键记录,加锁顺序可能不同,从而形成环路。

4. 长事务放大锁持有时间

事务越长,锁持有时间越久,冲突窗口越大。常见的诱因包括:事务中夹杂远程调用、事务内做大批量操作、忘记提交等。长事务不一定直接制造死锁,但会显著提高死锁发生概率。

四、系统性的死锁规避策略

死锁无法被彻底消除,但可以被有效管理和大幅降低。建议从以下几个层面入手。

1. 统一加锁顺序

对所有涉及多资源的事务,约定全局一致的访问顺序,例如始终”先订单、后库存、再账务”。统一顺序可以从根本上破坏环路等待条件。这一原则同样适用于批量更新:按主键排序后再逐条更新,避免交叉加锁。

2. 用唯一索引替代”查询后插入”

对于幂等性场景,最直接有效的方案是给 order_no 建立唯一索引,然后直接 INSERT,捕获唯一键冲突异常来判断是否重复:

  1. ALTER TABLE t_order ADD UNIQUE KEY uk_order_no (order_no);
  1. try {
  2. orderMapper.insert(order);
  3. } catch (DuplicateKeyException e) {
  4. // 幂等命中,按已存在处理
  5. }

这样既保证了幂等,又避免了大范围的间隙锁,是高频写入场景下的首选做法。若业务确实需要”先查后插”的语义,也应尽量缩小事务范围,把查询与插入放在尽可能短的临界区内。

3. 缩小锁粒度与事务范围

  • 事务内只做数据库操作,把远程调用、消息发送、文件读写移到事务之外;
  • 尽量使用等值条件命中唯一索引,让加锁退化为记录锁而非间隙锁;
  • 批量操作拆分为小批次,控制单次加锁记录数;
  • 能走主键就不走二级索引,减少回表加锁的层次。

4. 合理选择隔离级别

若业务对幻读不敏感,可以考虑在特定场景使用读已提交(RC)。RC 下 InnoDB 不使用间隙锁(外键约束与唯一性检查除外),能显著减少间隙锁引发的死锁,但需要业务侧自行评估一致性影响。这是一个权衡,而非默认答案。

5. 建立死锁监控与重试机制

  • 开启 innodb_print_all_deadlocks,将死锁信息写入错误日志,便于复盘;
  • 通过 SHOW ENGINE INNODB STATUS 查看最近的死锁详情,定位冲突的 SQL 与加锁对象;
  • 在应用层对死锁错误(错误码 1213)做有限次重试,并配合退避策略;
  • 对高频冲突的业务做告警,推动从设计层面优化,而不是依赖重试兜底。

6. 缩短锁等待与及时发现

适当调低 innodb_lock_wait_timeout,让长时间等待尽早失败,避免请求堆积。同时保持死锁检测开启(innodb_deadlock_detect = ON),让 InnoDB 主动打破环路。在高并发热点写入场景下,死锁检测本身也有成本,此时更应从业务设计上分散热点。

五、小结

回到最初的订单场景:问题的本质不是 SELECT ... FOR UPDATE 用错了,而是”查询不存在的值”在 RR 隔离级别下必然触发间隙锁,而间隙锁之间互相兼容、插入意向锁又与间隙锁互斥,两个事务便在同一间隙上形成了环路等待。

理解这一点后,规避思路就清晰了:能用唯一索引解决的,不要用”先查后插”;必须加锁的,统一顺序、缩小范围、缩短事务;再配合监控与重试形成闭环。死锁是并发系统的常态,工程上的目标不是”零死锁”,而是让死锁可发现、可恢复、可优化。

发表评论

活动