logo

MySQL死锁成因深度解析与规避策略

作者:很菜不狗2026.11.06 20:55浏览量:0

简介:本文从一个订单幂等校验场景出发,剖析非唯一索引加间隙锁导致的死锁成因,讲解InnoDB加锁规则、死锁检测机制与常见误区,并给出索引设计、事务拆分、加锁顺序、重试策略等可落地的规避方案,帮助开发者写出更稳健的高并发事务代码。

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 条记录,订单号分别为 1001 至 1006。此时两个事务同时进入:事务 A 要插入订单 1007,事务 B 要插入订单 1008。二者都先执行 SELECT ... FOR UPDATE 判断订单是否存在,再执行 INSERT。

在没有开启死锁检测或检测被关闭的前提下,这两个事务会双双进入等待状态,形成死锁:它们各自持有对方所需要的锁,又都在等待对方释放锁。问题的根源,就藏在非唯一索引的加锁语义里。

二、死锁的本质:锁的循环等待

死锁的经典定义是:两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而形成循环等待。InnoDB 的死锁并不神秘,它需要同时满足四个条件:互斥、请求与保持、不可剥夺、循环等待。前三个条件是锁机制固有的,真正可以被工程手段打破的,是”循环等待”。

在 InnoDB 中,死锁一般通过两种方式暴露:

  1. 死锁检测:innodb_deadlock_detect 开启时,InnoDB 会在加锁等待发生时主动构建等待图,发现环路后选择一个”代价较小”的事务回滚,报错 Deadlock found when trying to get lock。
  2. 锁等待超时:死锁检测关闭时,只能依赖 innodb_lock_wait_timeout 超时兜底,表现为事务长时间挂起后才失败,对业务体验和连接池的伤害都更大。

因此,”关掉死锁检测”并不是解决死锁的办法,只是把死锁变成了慢等待。真正要做的,是理解锁是怎么被加上的。

三、非唯一索引下的间隙锁与 Next-Key Lock

InnoDB 在可重复读(RR)隔离级别下,为了解决幻读,引入了间隙锁(Gap Lock)和 Next-Key Lock(记录锁 + 间隙锁的组合)。关键规则如下:

  • 唯一索引等值查询且记录存在:退化为记录锁,只锁住该行。
  • 唯一索引等值查询且记录不存在:加间隙锁,锁住记录可能插入的间隙。
  • 非唯一索引等值查询:既要锁住匹配的记录,还要锁住相邻的间隙,即 Next-Key Lock,且往往还会向后多锁一部分范围。

回到订单场景。order_no 是普通索引,当两个事务分别执行:

  1. -- 事务 A
  2. SELECT * FROM t_order WHERE order_no = 1007 FOR UPDATE;
  3. INSERT INTO t_order(order_no, create_date) VALUES (1007, NOW());
  4. -- 事务 B
  5. SELECT * FROM t_order WHERE order_no = 1008 FOR UPDATE;
  6. INSERT INTO t_order(order_no, create_date) VALUES (1008, NOW());

由于 1007 和 1008 都不存在,SELECT ... FOR UPDATE 会在非唯一索引上申请间隙锁。而这两个值落在同一个间隙区间(例如 1006 到正无穷之间的区间)内,两个事务申请的间隙锁在范围上发生重叠,彼此都需要对方释放,于是互相等待。随后任一事务执行 INSERT 时需要插入意向锁(Insert Intention Lock),而插入意向锁与间隙锁互斥,等待链条进一步闭合,死锁就此形成。

可以用一张简化的时序表说明:

时刻 事务 A 事务 B
T1 SELECT ... WHERE order_no=1007 FOR UPDATE 成功,持有间隙锁 —
T2 — SELECT ... WHERE order_no=1008 FOR UPDATE 成功,持有重叠间隙锁
T3 INSERT 1007 需要插入意向锁,被 B 的间隙锁阻塞 —
T4 — INSERT 1008 需要插入意向锁,被 A 的间隙锁阻塞
T5 等待 B 释放 等待 A 释放 → 死锁

这就解释了一个反直觉的现象:两个事务插入的并不是同一条记录,却依然会死锁。原因不在插入的行本身,而在它们为了保护”插入不幻读”而申请的间隙锁范围重叠。

四、为什么不能简单去掉 FOR UPDATE

有观点认为,既然 FOR UPDATE 会带来死锁,那就改成普通 SELECT。这同样不可取。普通快照读不加锁,两个并发请求会同时读到”订单不存在”,然后各自插入,最终产生两条重复订单,这既是幻读问题,也是实打实的数据一致性问题。

所以矛盾在于:不加锁会重复插入,加锁又可能死锁。解决思路不是二选一,而是从”锁的粒度”和”业务约束”两个层面同时下手。

五、可落地的规避方案

1. 用唯一索引把幂等约束下沉到数据库

最彻底的方案是为 order_no 建立唯一索引,让数据库来保证唯一性:

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

有了唯一索引后,插入重复订单会直接报唯一键冲突,业务层捕获该错误并转为”订单已存在”的正常返回即可。更重要的是,唯一索引上的等值查询在记录不存在时加的是间隙锁,但插入冲突由唯一性检查兜底,业务上不再需要长时间持有 FOR UPDATE 的间隙锁,锁冲突范围显著缩小。

2. 缩短事务,把查询放到事务外

很多死锁的诱因不是锁本身,而是事务太长。若把”是否存在”的查询放在事务外做一次快速预判,仅在插入阶段开启短事务,锁持有时间会大幅缩短,环路形成的概率随之下降。需要注意,预判结果只能作为优化手段,最终一致性仍要依靠唯一索引或冲突检测来保证。

3. 统一加锁顺序,消除循环等待

当业务必须对多行或多张表加锁时,应约定全局一致的加锁顺序,例如按主键升序加锁。循环等待被打破,死锁自然无从形成。这是成本最低、收益最直接的一条工程纪律。

4. 合理选择隔离级别

在满足业务前提下,可以考虑使用读已提交(RC)隔离级别。RC 下 InnoDB 不保留间隙锁(外键约束与唯一性检查场景除外),间隙锁引发的死锁会大幅减少。代价是幻读需要由业务或唯一约束来兜底,因此这一条要与第 1 条配合使用。

5. 业务层重试与降级

即使设计再完善,分布式环境下仍可能出现死锁。应用层应对死锁错误码做有限次数的指数退避重试,并做好幂等,确保重试不会产生副作用。同时保留超时兜底,避免连接被长期占用拖垮连接池。

6. 监控与定位

开启 innodb_print_all_deadlocks 可将死锁信息输出到错误日志,配合 SHOW ENGINE INNODB STATUS 中的 LATEST DETECTED DEADLOCK 段落,可以还原持锁与等待关系。把死锁日志纳入日常监控,才能从”偶发报错”走向”可观测、可治理”。

六、小结

订单表的这个例子说明:死锁往往不是”两个事务抢同一行”这么简单,非唯一索引上的间隙锁重叠、插入意向锁互斥、事务过长,都可能让看似无关的操作形成环路。理解 InnoDB 的加锁规则是前提,而真正有效的治理路径是:用唯一约束把一致性交给数据库,用短事务和统一加锁顺序减少锁冲突,用重试与监控兜住残余风险。做到这几点,绝大多数死锁都可以被消除在发生之前。

发表评论

活动