logo

MySQL 中死锁是如何产生的?如何有效避免?

作者:rousong2026.11.07 01:57浏览量:0

简介:本文将结合一个典型的订单幂等校验场景,分析 InnoDB 在可重复读隔离级别下产生死锁的根本原因,重点说明记录锁、间隙锁与 Next-Key Lock 的作用机制。同时给出可落地的避免死锁方案,包括索引优化、加锁顺序控制、唯一约束设计等,帮助开发者在高并发写入场景下降低锁冲突与死锁风险。

MySQL 中死锁是如何产生的?如何有效避免?

在数据库开发中,死锁是一个既常见又容易被忽视的问题。很多业务在初期流量较小时运行正常,一旦并发量上升,就会突然出现大量事务相互等待、最终被回滚的现象。本文以一个真实的订单业务场景为背景,分析 MySQL InnoDB 在可重复读隔离级别下死锁产生的根本原因,并讨论如何从设计层面尽量避免死锁。

一、一个典型场景:订单幂等校验引发的死锁

假设有一个订单系统,核心操作包括新增订单、修改订单和查询订单。由于订单号不能重复,开发人员在新增订单前会先做一次幂等校验:使用 SELECT ... FOR UPDATE 查询该订单号是否已存在,如果不存在再插入新记录。

这条 SQL 的初衷是防止并发场景下两个相同订单号的请求同时插入,导致幻读或重复数据。例如,如果直接使用普通 SELECT 查询后插入,两个事务可能都查询不到同一订单号,然后先后插入,最终产生两条相同订单号的记录。

然而,正是这种“先查再插”的加锁方式,在大量并发插入不同订单号时,也可能引发死锁。

二、建表结构与初始数据

为了便于说明,建一张订单表 t_order,结构如下:

  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。

此时有两个事务同时执行:

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

按照业务逻辑,两个事务都会先执行类似如下的查询:

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

如果此时没有开启死锁检测,两个事务会陷入相互等待,最终死锁。

三、可重复读隔离级别下的幻读问题

InnoDB 在可重复读隔离级别下解决了幻读问题。幻读是指一个事务在执行两次相同条件的查询时,第二次查询结果中出现了第一次没有的新记录,通常由其他事务插入数据导致。

例如,在普通 SELECT 查询条件下:

  1. 事务 A 查询订单号 1007,发现不存在;
  2. 事务 B 也查询订单号 1007,发现不存在;
  3. 事务 A 插入订单号 1007;
  4. 事务 B 再插入订单号 1007,导致重复数据。

这种场景正是“先查再插”方式需要避免的。因此开发人员使用了 SELECT ... FOR UPDATE 来锁定查询结果,试图阻止其他事务插入相同订单号。

但问题在于,当两个事务查询的订单号都不存在时,它们获得的并不是传统意义上的“记录锁”,而是“间隙锁”。

四、锁的类型:记录锁、间隙锁与 Next-Key Lock

为了理解死锁原因,需要先了解 InnoDB 的锁机制。

1. Record Lock(记录锁)

记录锁锁定的是索引中的具体记录。例如主键为 1 的记录被锁住,其他事务就不能更新或删除该记录。

2. Gap Lock(间隙锁)

间隙锁锁定的是两条记录之间的空隙,防止其他事务在空隙中插入新记录。例如订单表中有 order_no = 1006 的记录,再没有其他记录时,间隙可能包括 (1006, +∞)。如果某个事务在查询不存在的 order_no = 1007 时加了间隙锁,那么其他事务就不能在这个区间内插入新记录。

3. Next-Key Lock

Next-Key Lock 是记录锁与间隙锁的组合。它锁定一个索引记录以及该记录之前的间隙。例如对 order_no = 1006 的记录使用 Next-Key Lock,会锁定 (1005, 1006] 这个区间。这种锁既保护已有记录不被修改,也防止新记录插入到前方空隙。

在可重复读隔离级别下,SELECT ... FOR UPDATE 会根据索引扫描情况加 Next-Key Lock。当查询的记录不存在时,通常会退化为间隙锁。

五、死锁是如何发生的

回到前面的例子:

  • 事务 A 查询订单号 1007,该记录不存在;
  • 事务 B 查询订单号 1008,该记录也不存在。

由于 order_no 字段有普通索引,两个事务都会通过 index_order 索引进行查找。此时表中最大的 order_no 是 1006,因此 1007 和 1008 都位于同一个间隙 (1006, +∞) 中。

事务 A 查询 1007 时,会给这个间隙加上间隙锁;
事务 B 查询 1008 时,也会尝试给同一间隙加锁。

这里需要特别说明:间隙锁与间隙锁之间是兼容的,因为间隙锁的作用是阻止插入,而不是阻止其他事务在相同间隙上加锁。因此事务 A 和事务 B 都可以获得 (1006, +∞) 的间隙锁。

接下来,事务 A 准备插入订单号 1007,事务 B 准备插入订单号 1008。插入操作需要获得插入意向锁。插入意向锁是一种特殊的间隙锁,它表示事务准备在某个间隙中插入记录,但只有在间隙锁冲突时才会等待。

由于事务 A 已经持有 (1006, +∞) 的间隙锁,事务 B 的插入操作需要等待事务 A 释放这个间隙锁;
同样,事务 B 也持有同一间隙锁,事务 A 的插入操作也需要等待事务 B 释放。

于是双方互相等待对方释放间隙锁,形成死锁。

这个过程可以简单概括为:

  1. 两个事务同时对同一间隙加间隙锁;
  2. 两个事务都准备插入数据;
  3. 彼此持有的间隙锁阻塞了对方的插入意向锁;
  4. 循环等待,死锁产生。

六、如何避免这类死锁

理解死锁原因后,可以从以下几个方面避免或减少此类问题。

1. 使用唯一索引做幂等控制

最直接的方式是为 order_no 字段增加唯一索引。唯一索引本身就能阻止重复订单号的插入,不再需要“先查询再插入”的逻辑。插入重复数据时会直接报唯一键冲突,业务层可以捕获该错误并做相应处理。

例如:

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

这样在插入前就不需要执行 SELECT ... FOR UPDATE,从根本上消除了间隙锁冲突的问题。

2. 尽量避免锁定不存在的记录

如果业务无法使用唯一索引,可以考虑减少对不存在记录的锁定。例如先使用普通 SELECT 查询,如果发现记录不存在,则直接插入并依赖其他机制处理冲突;或者在查询条件中使用主键或唯一键进行精确匹配,避免扫描索引时产生大范围间隙锁。

3. 控制加锁顺序

当确实需要同时操作多个记录时,应保证所有事务按照相同的顺序访问资源。例如先锁定主键值较小的记录,再锁定主键值较大的记录,避免交叉等待。

4. 缩小事务范围

将查询、校验、插入等操作放在一个较小的事务中执行,尽量缩短事务持有锁的时间。事务越短,与其他事务竞争锁的概率越低。

5. 降低隔离级别

如果业务允许,可以考虑使用读已提交隔离级别。读已提交隔离级别下,间隙锁的影响范围会减小,能显著降低死锁概率。但需要评估是否会出现幻读或不可重复读问题。

6. 捕获死锁并重试

即使做了上述优化,死锁仍可能因复杂业务而发生。应用层应捕获死锁错误,并采用合理的重试策略。MySQL 会主动检测死锁并回滚其中一个事务,应用程序可以根据返回的错误码进行重试。

七、总结

死锁是数据库并发控制中难以完全避免的现象,但通过合理的设计可以大幅降低其发生概率。以本文的订单幂等校验为例,根本问题在于对不存在的记录加锁导致多个事务持有相同的间隙锁,最终在插入时相互阻塞。

避免死锁的关键在于:

  • 优先使用唯一约束实现幂等;
  • 尽量避免对不存在记录使用 SELECT ... FOR UPDATE;
  • 优化索引设计,减少不必要的间隙锁;
  • 控制事务大小和资源访问顺序;
  • 应用层做好死锁检测与重试机制。

理解锁机制与业务场景之间的关系,才能在高并发下既保证数据一致性,又避免因锁冲突引发的性能与可用性问题。

发表评论

活动