MySQL 中死锁是如何产生的?如何有效避免?
作者:rousong2026.11.07 01:57浏览量:0简介:本文将结合一个典型的订单幂等校验场景,分析 InnoDB 在可重复读隔离级别下产生死锁的根本原因,重点说明记录锁、间隙锁与 Next-Key Lock 的作用机制。同时给出可落地的避免死锁方案,包括索引优化、加锁顺序控制、唯一约束设计等,帮助开发者在高并发写入场景下降低锁冲突与死锁风险。
MySQL 中死锁是如何产生的?如何有效避免?
在数据库开发中,死锁是一个既常见又容易被忽视的问题。很多业务在初期流量较小时运行正常,一旦并发量上升,就会突然出现大量事务相互等待、最终被回滚的现象。本文以一个真实的订单业务场景为背景,分析 MySQL InnoDB 在可重复读隔离级别下死锁产生的根本原因,并讨论如何从设计层面尽量避免死锁。
一、一个典型场景:订单幂等校验引发的死锁
假设有一个订单系统,核心操作包括新增订单、修改订单和查询订单。由于订单号不能重复,开发人员在新增订单前会先做一次幂等校验:使用 SELECT ... FOR UPDATE 查询该订单号是否已存在,如果不存在再插入新记录。
这条 SQL 的初衷是防止并发场景下两个相同订单号的请求同时插入,导致幻读或重复数据。例如,如果直接使用普通 SELECT 查询后插入,两个事务可能都查询不到同一订单号,然后先后插入,最终产生两条相同订单号的记录。
然而,正是这种“先查再插”的加锁方式,在大量并发插入不同订单号时,也可能引发死锁。
二、建表结构与初始数据
为了便于说明,建一张订单表 t_order,结构如下:
CREATE TABLE `t_order` (`id` int NOT NULL AUTO_INCREMENT,`order_no` int DEFAULT NULL,`create_date` datetime DEFAULT NULL,PRIMARY KEY (`id`),KEY `index_order` (`order_no`) USING BTREE) ENGINE=InnoDB;
其中 id 是主键索引,order_no 是普通非唯一索引。表中已有 6 条记录,order_no 分别为 1001 到 1006。
此时有两个事务同时执行:
- 事务 A:插入订单号 1007;
- 事务 B:插入订单号 1008。
按照业务逻辑,两个事务都会先执行类似如下的查询:
SELECT * FROM t_order WHERE order_no = 1007 FOR UPDATE;SELECT * FROM t_order WHERE order_no = 1008 FOR UPDATE;
如果此时没有开启死锁检测,两个事务会陷入相互等待,最终死锁。
三、可重复读隔离级别下的幻读问题
InnoDB 在可重复读隔离级别下解决了幻读问题。幻读是指一个事务在执行两次相同条件的查询时,第二次查询结果中出现了第一次没有的新记录,通常由其他事务插入数据导致。
例如,在普通 SELECT 查询条件下:
- 事务 A 查询订单号 1007,发现不存在;
- 事务 B 也查询订单号 1007,发现不存在;
- 事务 A 插入订单号 1007;
- 事务 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. 使用唯一索引做幂等控制
最直接的方式是为 order_no 字段增加唯一索引。唯一索引本身就能阻止重复订单号的插入,不再需要“先查询再插入”的逻辑。插入重复数据时会直接报唯一键冲突,业务层可以捕获该错误并做相应处理。
例如:
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; - 优化索引设计,减少不必要的间隙锁;
- 控制事务大小和资源访问顺序;
- 应用层做好死锁检测与重试机制。
理解锁机制与业务场景之间的关系,才能在高并发下既保证数据一致性,又避免因锁冲突引发的性能与可用性问题。

登录后可评论,请前往 登录 或 注册