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

作者:狼烟四起2026.11.06 21:57浏览量:0

简介:本文从一个真实的订单幂等校验场景切入,系统剖析InnoDB在可重复读隔离级别下死锁的形成机理,重点讲解间隙锁与插入意向锁的冲突过程,并给出降低死锁概率的工程实践建议,帮助开发者在高并发业务中写出更稳健的数据库代码。

MySQL造成死锁的原因有哪些?如何避免?

在交易类系统中,订单的幂等性校验几乎是绕不开的一环。很多团队为了避免重复下单,会在写入之前先做一次“存在性判断”,确认不存在后再插入。这个思路本身没有问题,但在高并发场景下,如果实现方式不够谨慎,就可能把一次普通的写入操作演变成数据库死锁。本文从一个订单幂等校验的案例出发,分析死锁产生的完整链路,并给出可落地的规避策略。

一、案例背景:幂等校验为什么会引入死锁

假设有一张订单表,结构如下:

  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 条记录,order_no 分别为 1001 到 1006。

业务逻辑要求订单号不能重复,因此新增订单前先执行:

  1. SELECT * FROM t_order WHERE order_no = ? FOR UPDATE;

如果查询不到记录,再执行 INSERT。这条 SELECT ... FOR UPDATE 的目的是加锁,防止在判断与插入之间,有其他事务插入了相同订单号,从而破坏幂等性。

表面上看,这个逻辑是自洽的。但当两个事务同时插入 1007 和 1008 时,问题就出现了:两个事务都查不到记录,都认为自己应该插入,最终却互相等待对方释放锁,形成死锁。

二、死锁发生的完整过程

要理解死锁,必须先理解 InnoDB 在可重复读隔离级别下的加锁规则。这里涉及两类关键锁:

  • 间隙锁(Gap Lock):锁住索引记录之间的间隙,防止其他事务在间隙中插入数据。
  • 插入意向锁(Insert Intention Lock):一种特殊的间隙锁,表示事务想在某个间隙中插入记录。多个事务在同一间隙插入不同位置时,插入意向锁本身并不冲突,但如果间隙已经被其他事务加了间隙锁,插入就会被阻塞。

假设当前表中 order_no 索引上的记录为 1001、1002、1003、1004、1005、1006。

事务 A 执行:

  1. SELECT * FROM t_order WHERE order_no = 1007 FOR UPDATE;

由于 1007 不存在,InnoDB 会在 order_no 索引上定位到大于 1007 的第一个记录,也就是 supremum 伪记录之前的区间,并对这个区间加上间隙锁。此时事务 A 持有了 (1006, +∞) 这个间隙的锁。

事务 B 执行:

  1. SELECT * FROM t_order WHERE order_no = 1008 FOR UPDATE;

1008 同样不存在,事务 B 也需要对 (1006, +∞) 区间加间隙锁。间隙锁之间并不互斥,因此事务 B 也能成功加上间隙锁。此时两个事务都持有同一区间的间隙锁,看起来相安无事。

接下来,事务 A 执行插入:

  1. INSERT INTO t_order (order_no, create_date) VALUES (1007, NOW());

插入操作需要申请插入意向锁。但 (1006, +∞) 区间上已经有事务 B 持有的间隙锁,插入意向锁与间隙锁冲突,因此事务 A 被阻塞,进入等待状态。

事务 B 随后执行插入:

  1. INSERT INTO t_order (order_no, create_date) VALUES (1008, NOW());

同样地,事务 B 需要申请插入意向锁,而该区间上有事务 A 持有的间隙锁,于是事务 B 也被阻塞。

此时,事务 A 等待事务 B 释放间隙锁,事务 B 等待事务 A 释放间隙锁,双方形成循环等待,死锁正式产生。如果没有开启死锁检测,或者检测机制未能及时介入,两个事务就会一直挂起。

三、为什么单纯 SELECT 也不行

有读者可能会想:既然 FOR UPDATE 会加间隙锁,那改成普通 SELECT 是不是就没事了?

普通 SELECT 在可重复读隔离级别下确实不加锁,但它也无法阻止其他事务插入。如果两个请求携带相同的订单号同时进入,都执行普通 SELECT,都查不到记录,然后都执行 INSERT,最终就会产生两条重复订单。这就是典型的幻读问题,幂等性校验也就形同虚设。

所以问题的根源不在于“要不要加锁”,而在于“加锁的方式是否与业务语义匹配”。用普通二级索引做存在性判断,再依赖间隙锁来防重,本身就是一种脆弱的方案。

四、规避死锁的工程实践

1. 用唯一索引替代“查询 + 插入”

最直接、最可靠的方案是给 order_no 建立唯一索引,然后直接插入,依赖数据库的唯一约束来保证幂等:

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

插入时捕获唯一键冲突异常,转为业务上的“订单已存在”处理。这样既不需要间隙锁,也不会产生死锁,还能把并发控制交给数据库的唯一性约束,语义更加清晰。

2. 如果必须保留查询逻辑,尽量缩小锁范围

如果业务确实需要先查询再插入,可以考虑:

  • 使用主键或唯一索引进行查询,避免普通索引上的间隙锁;
  • 将隔离级别调整为读已提交(RC),在 RC 下 InnoDB 会关闭间隙锁,只保留记录锁,能显著降低死锁概率;
  • 控制事务粒度,尽快提交,减少锁持有时间。

需要注意的是,RC 下虽然间隙锁被关闭,但唯一性校验仍然需要唯一索引兜底,否则依然可能出现重复数据。

3. 统一加锁顺序

如果业务中不可避免要加多把锁,应确保所有事务按照相同的顺序访问资源。例如,先按订单号排序,再依次加锁,避免交叉等待。统一的加锁顺序是打破循环等待条件的经典手段。

4. 设置合理的锁等待超时与死锁检测

InnoDB 默认开启死锁检测,一旦发现循环等待,会主动回滚代价较小的事务。应用层应当捕获死锁异常并做重试。同时可以设置 innodb_lock_wait_timeout,避免事务长时间挂起。对于高并发写入场景,还可以考虑关闭死锁检测以降低检测开销,但必须配合应用层的重试机制。

5. 缩短事务生命周期

死锁往往发生在事务持有锁的时间过长时。应避免在事务中执行远程调用、文件操作、复杂计算等耗时逻辑。事务应只包含必要的数据库操作,并在操作完成后尽快提交。

6. 使用乐观锁或分布式锁做前置控制

对于订单号这类业务唯一键,可以在应用层使用分布式锁或乐观锁做前置控制,减少数据库层面的并发冲突。数据库唯一索引作为最终兜底,形成多层防护。

五、总结

MySQL 死锁并不是随机发生的,它通常源于加锁顺序不一致、锁范围过大、事务持有锁时间过长等问题。本文案例中的死锁,本质上是普通二级索引上的间隙锁与插入意向锁相互冲突,加上两个事务交叉等待造成的。

规避死锁的核心思路可以归纳为三点:能用唯一索引就不用间隙锁,能缩小锁范围就不扩大锁范围,能缩短事务就不延长事务。在此基础上,配合统一的加锁顺序、合理的超时设置和应用层重试机制,就能在大多数高并发场景下有效降低死锁发生的概率。

数据库锁机制是工具,不是目的。理解业务语义,选择与语义匹配的并发控制手段,才是写出稳健代码的关键。