MySQL死锁成因深度解析与规避策略
作者:很菜不狗2026.11.06 20:55浏览量:0简介:本文从一个订单幂等校验场景出发,剖析非唯一索引加间隙锁导致的死锁成因,讲解InnoDB加锁规则、死锁检测机制与常见误区,并给出索引设计、事务拆分、加锁顺序、重试策略等可落地的规避方案,帮助开发者写出更稳健的高并发事务代码。
MySQL 死锁成因深度解析与规避策略
一、从一个订单幂等场景说起
在高并发交易系统中,”幂等校验”几乎是一道必答题:同一个订单号可能因为用户重复点击、网络重试或消息重复投递而被提交多次,业务上必须保证最终只有一条记录落库。常见做法是先查询订单是否存在,不存在再插入。为了让”查询—插入”这个组合具备原子性,很多开发者会使用 SELECT ... FOR UPDATE 对查询记录加锁,以防止并发事务在间隙中插入新记录造成幻读。
先看一张典型的订单表:
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 条记录,订单号分别为 1001 至 1006。此时两个事务同时进入:事务 A 要插入订单 1007,事务 B 要插入订单 1008。二者都先执行 SELECT ... FOR UPDATE 判断订单是否存在,再执行 INSERT。
在没有开启死锁检测或检测被关闭的前提下,这两个事务会双双进入等待状态,形成死锁:它们各自持有对方所需要的锁,又都在等待对方释放锁。问题的根源,就藏在非唯一索引的加锁语义里。
二、死锁的本质:锁的循环等待
死锁的经典定义是:两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而形成循环等待。InnoDB 的死锁并不神秘,它需要同时满足四个条件:互斥、请求与保持、不可剥夺、循环等待。前三个条件是锁机制固有的,真正可以被工程手段打破的,是”循环等待”。
在 InnoDB 中,死锁一般通过两种方式暴露:
- 死锁检测:
innodb_deadlock_detect开启时,InnoDB 会在加锁等待发生时主动构建等待图,发现环路后选择一个”代价较小”的事务回滚,报错Deadlock found when trying to get lock。 - 锁等待超时:死锁检测关闭时,只能依赖
innodb_lock_wait_timeout超时兜底,表现为事务长时间挂起后才失败,对业务体验和连接池的伤害都更大。
因此,”关掉死锁检测”并不是解决死锁的办法,只是把死锁变成了慢等待。真正要做的,是理解锁是怎么被加上的。
三、非唯一索引下的间隙锁与 Next-Key Lock
InnoDB 在可重复读(RR)隔离级别下,为了解决幻读,引入了间隙锁(Gap Lock)和 Next-Key Lock(记录锁 + 间隙锁的组合)。关键规则如下:
- 唯一索引等值查询且记录存在:退化为记录锁,只锁住该行。
- 唯一索引等值查询且记录不存在:加间隙锁,锁住记录可能插入的间隙。
- 非唯一索引等值查询:既要锁住匹配的记录,还要锁住相邻的间隙,即 Next-Key Lock,且往往还会向后多锁一部分范围。
回到订单场景。order_no 是普通索引,当两个事务分别执行:
-- 事务 ASELECT * FROM t_order WHERE order_no = 1007 FOR UPDATE;INSERT INTO t_order(order_no, create_date) VALUES (1007, NOW());-- 事务 BSELECT * FROM t_order WHERE order_no = 1008 FOR UPDATE;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 建立唯一索引,让数据库来保证唯一性:
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 的加锁规则是前提,而真正有效的治理路径是:用唯一约束把一致性交给数据库,用短事务和统一加锁顺序减少锁冲突,用重试与监控兜住残余风险。做到这几点,绝大多数死锁都可以被消除在发生之前。

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