MySQL 死锁是怎么产生的?如何系统化避免?

作者:渣渣辉2026.11.06 19:43浏览量:0

简介:本文从一条订单幂等校验语句引发的死锁现象出发,结合 InnoDB 的锁机制、隔离级别与索引结构,拆解死锁产生的真实原因。你将收获一套可落地的排查思路与规避方案,包括索引优化、加锁顺序控制、事务拆分与重试机制等,适合后端开发与数据库设计人员阅读。

MySQL 死锁是怎么产生的?如何系统化避免?

在数据库并发场景中,死锁是一个既常见又容易被误解的问题。很多人以为只要用了事务,或者只要加锁,就能保证数据安全,但实际上,锁的使用方式、加锁顺序、索引设计和事务范围,都会直接影响死锁发生的概率。

本文以一个典型业务场景为切入点,逐步拆解 MySQL 在可重复读隔离级别下的死锁形成机制,并总结出一套可落地的避免策略。

一、从一个订单业务说起

假设有一个订单系统,核心逻辑包括新增订单、修改订单和查询订单。由于订单号不能重复,开发人员在插入订单前,会先做一次幂等性校验:查询该订单是否已经存在,如果不存在,再执行插入。

为了保证并发场景下不会插入重复订单,查询使用了 select ... for update。这种写法本身并没有错,但在高并发下,却可能引发死锁。

订单表结构大致如下:

  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 条记录,假设现在有两个事务,分别要插入订单号 1007 和 1008。

两个事务都先执行 select ... for update 查询订单是否存在,结果发现不存在,于是都准备插入。但此时,两个事务却都陷入了等待状态,最终触发死锁。

二、为什么会发生死锁?

要理解这个问题,必须先理解 InnoDB 在可重复读隔离级别下的锁机制。

2.1 幻读与 Next-Key Lock

在可重复读隔离级别下,普通查询无法完全避免幻读。所谓幻读,指的是同一个事务内,两次相同条件的查询,结果集却不同,通常是因为其他事务插入了新的数据。

为了解决幻读问题,InnoDB 引入了 Next-Key Lock,它是记录锁和间隙锁的组合:

  • 记录锁:锁定索引中的具体记录;
  • 间隙锁:锁定两个索引记录之间的空隙,防止其他事务在这个空隙中插入新数据。

以 order_no 为例,假设表中已有的 order_no 值为 1001、1002、1003、1004、1005、1006。那么间隙锁会锁定这些值之间的区间,比如 (1003, 1005)、(1005, 1006) 以及 (1006, 正无穷) 等。

2.2 死锁的形成过程

回到前面的场景。两个事务分别要插入 1007 和 1008,它们都会执行类似的查询:

  1. select * from t_order where order_no = 1007 for update;

由于 order_no 是普通索引,且 1007 不存在,InnoDB 不会只锁一条不存在的记录,而是会对索引区间加间隙锁。

事务 A 查询 1007 时,会锁定 (1006, 正无穷) 这个间隙。事务 B 查询 1008 时,也会锁定同样的间隙,因为 1008 同样落在 1006 之后。

此时,两个事务都持有了同一个间隙的锁。接下来它们分别尝试插入 1007 和 1008,但插入操作需要获取插入意向锁,而插入意向锁与间隙锁是互斥的。

于是出现了一个经典的死锁环:

  • 事务 A 持有 (1006, +∞) 的间隙锁,等待插入 1007;
  • 事务 B 持有 (1006, +∞) 的间隙锁,等待插入 1008;
  • 事务 A 的插入需要事务 B 释放间隙锁;
  • 事务 B 的插入需要事务 A 释放间隙锁。

两个事务互相等待对方释放锁,最终死锁。

三、如果没有加 for update 会怎样?

有人可能会问:如果不用 for update,直接用普通 select 查询,是不是就不会死锁了?

确实,普通 select 在可重复读隔离级别下通常不加锁,因此不会产生上述死锁。但它会带来另一个问题:幻读导致重复插入。

假设两个并发请求同时插入相同的订单号 1007,两个事务都先查询,发现不存在,然后都执行插入。由于没有加锁,两个事务都可能成功插入,最终产生两条重复订单。

这正是当初使用 for update 的原因:防止并发插入重复数据。

四、如何系统化避免死锁?

死锁无法完全杜绝,但可以通过合理的设计大幅降低发生概率。以下方法按优先级从高到低排列。

4.1 优先使用唯一索引做幂等

如果业务上要求某个字段唯一,最直接的做法就是给该字段加唯一索引。比如订单表的 order_no 应该设计为唯一索引。

有了唯一索引后,就不再需要 select ... for update 做幂等校验。直接执行插入,如果违反唯一约束,再根据错误码做相应处理即可。这样既避免了间隙锁,也避免了死锁。

4.2 优化索引,减少间隙锁范围

如果无法使用唯一索引,那么要尽量让查询走更精确的索引,缩小间隙锁的范围。

例如,如果订单号是递增的,且查询条件带有明确的区间,就不会像全表后段那样出现大范围间隙锁。减少锁的粒度,能有效降低并发冲突。

4.3 统一加锁顺序

很多死锁并不是因为单条 SQL 写错了,而是因为多个事务以不同的顺序访问资源。

例如事务 A 先锁订单 1007,再锁 1008;事务 B 先锁 1008,再锁 1007。当两个事务交叉执行时,就容易形成死锁环。

解决方法是:所有事务都按照相同的顺序访问资源。比如都按订单号从小到大进行锁定。

4.4 缩短事务执行时间

事务越长,持有锁的时间就越久,与其他事务冲突的概率就越大。

应尽量避免在事务中执行耗时操作,比如远程调用、文件处理、复杂计算等。将必要的数据处理放到事务外,事务内只保留最小范围的数据库操作。

4.5 合理拆分大事务

如果一个事务要处理大量数据,可以考虑将其拆分为多个小事务。每个小事务只处理一部分数据,提交后再处理下一部分。

这样可以减少单次锁定的数据量,降低锁冲突概率,同时也能减少长事务对数据库的压力。

4.6 捕获死锁并自动重试

即使做了上述优化,死锁仍然可能发生。因此,业务代码中必须对死锁进行捕获和重试。

MySQL 在检测到死锁时,会回滚其中一个事务,并返回错误码。应用层可以捕获该错误,等待短暂时间后重试整个事务。

  1. 死锁错误码:1213
  2. 错误信息示例:Deadlock found when trying to get lock; try restarting transaction

重试机制是保证系统可用性的最后一道防线,必须实现。

五、总结

死锁是数据库并发控制中的常见问题,但往往不是数据库本身的缺陷,而是由不合理的 SQL 设计、索引缺失、事务范围过大或加锁顺序混乱导致的。

本次案例中,死锁的根源在于:在非唯一索引上使用 for update 做存在性校验,触发了间隙锁,而多个事务对同一间隙加锁后又互相等待插入,形成了死锁环。

避免死锁的核心思路可以概括为:用唯一索引替代手动加锁、缩小锁粒度、统一加锁顺序、缩短事务、拆分大事务,并在应用层做好死锁重试。

理解锁机制的本质,比记住某些“经验规则”更重要。只有真正明白每条 SQL 在存储引擎内部做了什么,才能设计出更健壮的并发控制方案。