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

作者:暴富20212026.11.07 02:04浏览量:0

简介:本文将结合一个典型的订单幂等性校验场景,深入分析 MySQL InnoDB 在可重复读隔离级别下产生死锁的底层机制,重点解释记录锁、间隙锁与 Next-Key Lock 的相互作用。文章还将给出可落地的避免死锁策略,帮助开发者在高并发写入场景下降低锁冲突与事务回滚风险。

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

在订单系统中,一个非常常见的需求是:订单不能重复创建。为了保证幂等性,很多团队会在插入订单之前先执行一次查询,判断相同订单号的记录是否已经存在。为了保证并发场景下查询与插入之间的逻辑一致性,往往会使用 select ... for update 进行当前读,锁定查询结果,避免两个并发事务同时判断“不存在”后各自插入一条重复记录。

这种设计在业务量较小时通常不会暴露问题,但一旦并发量上升,特定索引结构与事务执行顺序下就很容易触发死锁。

下面通过一个简化示例说明问题。

1.1 表结构

假设存在如下订单表:

  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 是普通非唯一索引。

表中已经存在若干条记录,例如订单号从 1001 到 1006。

1.2 并发插入过程

现在有两个事务:

  • 事务 A:准备插入订单号 1007;
  • 事务 B:准备插入订单号 1008。

两个事务在插入前都需要通过 select ... for update 查询当前订单号是否已存在。执行顺序如下:

  1. 事务 A:
  2. 1. SELECT * FROM t_order WHERE order_no = 1007 FOR UPDATE;
  3. 2. 判断不存在;
  4. 3. 准备 INSERT 订单 1007;
  5. 事务 B:
  6. 1. SELECT * FROM t_order WHERE order_no = 1008 FOR UPDATE;
  7. 2. 判断不存在;
  8. 3. 准备 INSERT 订单 1008;

如果这两个事务几乎同时执行,就可能出现双方都持有各自间隙锁,又都等待对方释放锁的情况,最终导致死锁。


二、死锁产生的底层原因

要理解上面的死锁,必须回到 InnoDB 的锁机制,尤其是在可重复读隔离级别下为了解决幻读而引入的 Next-Key Lock。

2.1 可重复读与幻读

在可重复读隔离级别下,普通的一致性读通过 MVCC 机制读取快照版本,不会产生幻读。但对于当前读,例如 select ... for update、update、delete,读取的是最新已提交版本,并且需要加锁。

如果只对查询到的记录加记录锁,那么当其他事务插入一条满足相同查询条件的记录时,当前事务再次执行相同查询就可能看到新的记录,这就是幻读。

2.2 记录锁、间隙锁与 Next-Key Lock

InnoDB 为了防止当前读场景下的幻读,引入了三种锁:

  • 记录锁:锁定某一条索引记录本身;
  • 间隙锁:锁定索引记录之间的空隙,阻止其他事务在该空隙中插入新记录;
  • Next-Key Lock:记录锁与间隙锁的组合,锁定一个左开右闭的索引区间。

例如,当执行:

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

如果 order_no = 1007 的记录不存在,InnoDB 不会只返回空结果,而是会在 order_no 索引上加一个间隙锁,锁住 1007 前后相邻记录之间的空隙。

这种间隙锁会阻止其他事务在该区间插入新的 order_no 记录。它的存在虽然解决了幻读问题,但也会带来额外的锁冲突。

2.3 当前场景下的死锁形成过程

假设表中 order_no 已有 1001 到 1006,且两个事务分别查询 1007 和 1008。这两个订单号在当前索引树中都位于 1006 之后,因此它们对应的间隙锁范围可能都包含了从 1006 到正无穷的区间。

具体过程如下:

  1. 事务 A 执行 SELECT ... WHERE order_no = 1007 FOR UPDATE,由于记录不存在,加间隙锁;
  2. 事务 B 执行 SELECT ... WHERE order_no = 1008 FOR UPDATE,同样加间隙锁;
  3. 事务 A 尝试 INSERT 订单 1007,需要向间隙中插入记录,但事务 B 持有该间隙锁;
  4. 事务 B 尝试 INSERT 订单 1008,也需要向同一间隙插入记录,但事务 A 持有该间隙锁;
  5. 双方都无法继续执行,死锁发生。

当 InnoDB 的死锁检测开启时,会主动回滚其中一个事务;如果关闭死锁检测,则可能出现相互等待持续较长时间的情况。

2.4 普通索引与主键索引的锁差异

需要注意的是,上述加锁过程主要发生在 order_no 普通索引上。如果查询条件使用主键索引,并且查询的记录不存在,则通常只会在主键索引对应位置加间隙锁,影响范围较小。

而在普通索引上,由于索引结构为 B+Tree,间隙锁的粒度往往比主键索引更大,尤其当多个并发事务都查询靠近同一区间的记录时,锁冲突会显著增加。


三、避免死锁的常见策略

死锁无法完全杜绝,但通过合理的表设计、索引设计、事务编排和 SQL 写法,可以大幅降低发生概率,并将影响控制在可接受范围内。

3.1 尽量缩小事务范围

事务持锁时间越长,与其他事务发生锁冲突的概率越高。优化思路包括:

  • 将非必要的查询、计算、远程调用移出事务;
  • 减少事务中交互式等待;
  • 合并多次写入,减少事务执行时间。

例如,订单场景中,可以在应用层先执行一次普通查询,确认大概率不存在后,再进入短事务执行锁定与插入操作。

3.2 使用唯一索引作为幂等控制手段

如果业务上要求订单号唯一,最直接有效的方式是在 order_no 字段上建立唯一索引:

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

这样插入重复订单号时,数据库会直接返回唯一键冲突错误,应用层可以捕获该错误并转换为幂等返回。

这种方式避免了先查询再加锁的复杂逻辑,也减少了间隙锁的使用,能显著降低死锁概率。

3.3 避免在普通索引上使用范围查询加锁

在普通索引上执行范围查询并加锁时,InnoDB 通常会加多个间隙锁,锁范围较大。例如:

  1. SELECT * FROM t_order WHERE order_no > 1000 FOR UPDATE;

这种语句在高并发下容易锁住大量区间,与其他插入操作发生冲突。应尽量改为主键精确查询,或者缩小范围条件。

3.4 按固定顺序访问资源

当多个事务需要访问相同的多张表或索引记录时,尽量保证访问顺序一致。例如:

  1. 事务 A:先处理账户 1,再处理账户 2;
  2. 事务 B:也先处理账户 1,再处理账户 2;

避免交叉访问导致循环等待。

在订单场景中,如果同时涉及用户账户扣款、库存扣减、订单写入,建议所有事务都按相同顺序执行这些操作。

3.5 使用低隔离级别

如果业务能够容忍一定的并发一致性问题,可以考虑将隔离级别调整为“读已提交”。在读已提交隔离级别下,间隙锁的使用会减少很多,从而降低死锁概率。

但需要注意:读已提交隔离级别无法解决部分幻读场景,需要结合业务特点评估是否适用。

3.6 合理利用死锁检测与重试机制

InnoDB 默认开启死锁检测。当检测到死锁时,会选择一个代价较小的事务进行回滚。应用层应捕获死锁异常,并实现有限次数的自动重试。

示例逻辑:

  1. 1. 执行事务;
  2. 2. 如果捕获到死锁异常;
  3. 3. 等待随机小段时间;
  4. 4. 重试事务;
  5. 5. 超过最大重试次数则返回失败。

随机等待的目的是避免多个事务同时重试再次发生冲突。


四、更安全的订单幂等性设计方案

针对本文最初提到的订单幂等性校验问题,可以给出更稳妥的设计方案。

4.1 方案一:唯一索引 + 直接插入

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

如果 order_no 已存在,数据库返回唯一键冲突。应用层捕获冲突后,再查询一次返回已有订单信息。

优点:

  • 逻辑简单;
  • 事务持锁时间短;
  • 不依赖间隙锁;
  • 死锁概率低。

4.2 方案二:先查询后插入,但使用主键边界控制

如果无法使用唯一索引,可以在查询条件中增加主键范围,减少间隙锁影响:

  1. SELECT * FROM t_order
  2. WHERE order_no = 1007
  3. AND id > 0
  4. FOR UPDATE;

这种写法并不能完全消除间隙锁,但可以帮助开发者更清楚地理解锁范围。

更推荐的做法是,在查询不到记录后,立即 INSERT,并保持事务尽量短。

4.3 方案三:使用分布式锁或幂等表

在应用层引入分布式锁,保证相同订单号的请求串行处理。也可以设计一张幂等请求表,通过唯一请求号进行幂等控制。

这类方案适合跨服务、跨库场景,但会引入额外组件与复杂度,需要结合具体架构评估。


五、总结

MySQL InnoDB 在可重复读隔离级别下,通过 Next-Key Lock 解决当前读场景的幻读问题。这种机制虽然有效,但在普通索引、间隙锁重叠以及多事务交叉插入时容易诱发死锁。

本文通过一个订单幂等性校验场景,展示了两个事务查询不存在的订单号后分别插入时,如何因为间隙锁互相等待而陷入死锁。

避免死锁的关键不在于完全消除锁,而在于:

  • 尽可能使用唯一索引替代先查询后插入;
  • 缩短事务执行时间;
  • 避免在普通索引上大范围加锁;
  • 保持多事务访问资源的顺序一致;
  • 结合业务特点选择合适的隔离级别;
  • 在应用层实现死锁重试机制。

理解锁的底层行为,才能在设计阶段做出更合理的决策,从源头减少高并发场景下的死锁风险。