logo

MySQL死锁成因深度剖析与规避策略

作者:搬砖的石头2026.11.07 01:11浏览量:0

简介:本文从一则订单幂等校验引发死锁的真实案例出发,深入剖析InnoDB在可重复读隔离级别下,`SELECT ... FOR UPDATE`与插入意向锁、间隙锁相互作用导致死锁的完整机理,并系统总结减少锁冲突、调整加锁顺序、优化索引设计、合理使用幂等方案等可落地的规避手段,帮助开发者在高并发场景下构建更稳健的数据库访问逻辑。

MySQL死锁成因深度剖析与规避策略

在订单、支付、库存等高频写入的业务场景中,数据库死锁是开发者绕不开的一类问题。它往往不会稳定复现,却会在流量高峰时突然出现,表现为事务长时间等待、接口超时甚至连接池被耗尽。本文从一个常见的订单幂等校验逻辑切入,完整还原死锁的发生过程,再深入分析其底层机理,最后给出成体系的规避策略。

一、问题的起点:一个看似合理的幂等实现

假设有一张订单表,主键为自增 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) USING BTREE
  7. ) ENGINE=InnoDB;

业务要求订单号不能重复,因此新增订单前先做一次幂等校验:查询该订单号是否已存在,不存在才插入。为了防止事务执行期间其他事务插入同号记录造成幻读,查询使用了 SELECT ... FOR UPDATE:

  1. BEGIN;
  2. SELECT * FROM t_order WHERE order_no = 1007 FOR UPDATE;
  3. -- 若查询结果为空,则执行插入
  4. INSERT INTO t_order (order_no, create_date) VALUES (1007, NOW());
  5. COMMIT;

这段逻辑在低并发下运行良好,但在业务量上升后开始出现死锁。

二、死锁是如何发生的

设表中已有 6 条记录,order_no 分别为 1001 到 1006。此时两个事务并发执行:

  • 事务 A 校验订单 1007 是否存在;
  • 事务 B 校验订单 1008 是否存在。

由于 1007 和 1008 都不存在,两个事务的 SELECT ... FOR UPDATE 在可重复读隔离级别下,不仅会对扫描到的记录加锁,还会对查询区间加间隙锁,以防止其他事务在区间内插入新记录。两个事务各自持有了相邻区间的间隙锁,随后又都要执行 INSERT,而 INSERT 需要申请插入意向锁。插入意向锁与间隙锁不兼容,于是:

  • 事务 A 等待事务 B 释放其持有的间隙锁;
  • 事务 B 等待事务 A 释放其持有的间隙锁。

双方互相等待,形成环路,死锁由此产生。需要注意,这一现象的前提是死锁检测未被触发或未被及时处理;在默认配置下,InnoDB 的死锁检测机制会主动发现环路并回滚其中一个事务,但检测本身也有开销,高并发下频繁回滚同样会影响业务成功率。

三、底层机理:三类锁的相互作用

理解死锁,关键在于理解 InnoDB 在可重复读级别下的加锁规则。

记录锁(Record Lock):锁定索引上的某条具体记录。当查询条件命中唯一索引的等值条件时,通常只加记录锁。

间隙锁(Gap Lock):锁定索引记录之间的“空隙”,作用是阻止其他事务在间隙中插入新记录,从而避免幻读。在可重复读级别下,普通的范围查询和未命中的等值查询都可能产生间隙锁。

插入意向锁(Insert Intention Lock):INSERT 操作在插入前会先在目标位置申请插入意向锁,它本身是一种特殊的间隙锁,多个插入意向锁之间兼容,但与已存在的间隙锁冲突。

回到案例:WHERE order_no = 1007 FOR UPDATE 在 order_no 索引上未命中任何记录,InnoDB 会对 1007 所在的间隙加锁;同理,1008 落在相邻间隙。两个间隙锁互不冲突,可以同时持有,但随后的 INSERT 都需要在对方持有的间隙上加插入意向锁,冲突不可避免。

还有一个容易被忽略的细节:如果 order_no 上建的是唯一索引,等值查询未命中时加锁行为会退化为间隙锁甚至更轻的锁形式,冲突概率会显著降低。原始表中 order_no 只是普通索引,这放大了间隙锁的影响范围。

四、如果不加 FOR UPDATE 会怎样

有人会问:把 SELECT ... FOR UPDATE 换成普通 SELECT 是否就能避免死锁?答案是死锁概率下降,但会引入另一个问题——幻读与重复插入。两个订单号相同的请求同时进入,都查询到“不存在”,随后都执行插入,最终产生两条重复订单。在非唯一索引下,数据库层面并不会阻止这种重复。因此,幂等性不能简单依赖“先查后插”的应用层逻辑,而应借助数据库约束或原子操作。

五、系统性规避策略

1. 用唯一约束替代应用层校验

最直接的做法是在 order_no 上建立唯一索引,插入时依赖数据库的唯一性保证,捕获唯一键冲突异常并转为幂等成功或友好提示。这样既消除了“先查后插”的竞态窗口,也大幅减少了间隙锁的使用。

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

2. 调整加锁顺序,避免交叉等待

若业务必须使用悲观锁,应保证所有事务以一致的顺序访问资源。例如按订单号排序后再批量加锁,或先对业务维度做统一的行锁(如按用户维度加锁),避免不同事务持有不同区间的锁后相互等待。

3. 缩小事务范围与持锁时间

把查询、校验、插入、外部调用拆开,避免在事务中执行 RPC、消息发送等耗时操作。事务越长,持锁时间越久,冲突窗口越大。校验类查询尽量放在事务外,仅在写入时开启短事务。

4. 优化索引设计,减少锁的覆盖范围

等值查询尽量走唯一索引;范围查询尽量缩小范围;避免在无索引字段上做加锁读,否则可能升级为全表扫描并锁定大量记录。索引设计直接影响加锁粒度,是治理死锁的根本手段之一。

5. 合理使用乐观锁与状态机

对于更新类操作,可采用版本号或状态字段实现乐观锁,通过 UPDATE ... WHERE version = ? 的受影响行数判断是否成功,避免长时间持有悲观锁。对于订单创建,可结合业务唯一流水号做去重,把并发控制前移到写入环节。

6. 监控与应急处理

开启死锁日志(如调整 InnoDB 相关参数输出最近一次死锁信息),定期分析死锁发生的表、索引与 SQL 模式。应用层对死锁异常做有限重试,并配合退避策略,避免重试风暴。同时关注长事务与锁等待指标,及早发现隐患。

六、小结

死锁不是偶然故障,而是并发访问与锁机制共同作用的必然结果。本案例的根因在于:可重复读级别下,非唯一索引上的加锁读产生了间隙锁,两个事务各自持有相邻间隙后又申请插入意向锁,形成环路等待。解决思路不应停留在“换个写法”,而应从约束设计(唯一索引)、加锁顺序、事务粒度、索引优化和并发控制模型五个层面系统治理。只有在设计阶段就把锁行为纳入考量,才能让高并发下的数据访问既正确又稳定。

发表评论

活动