MySQL 死锁案例剖析:并发下"先 delete 再 insert",两个事务双双卡死
最近线上又报死锁,翻出日志看到 Deadlock found when trying to get lock,我基本不用猜就知道是哪段逻辑,又是先 delete 再 insert 的写法。业务本身不复杂:并发任务处理时,多个线程对同一张表按某个索引字段先删旧记录、再插新记录,删的键值还不一定存在。就这种看起来人畜无害的写法,在可重复读(RR)隔离级别下,能让两个事务把自己活活锁死。
公司有信息管控,内部表结构和业务细节没法贴。下面用一张等价的简化表,把死锁从复现、原理到排查完整过一遍。看懂这个模型,下次在 show engine innodb status 里遇到同类问题,你一眼就能认出来。
一、事故现场:死锁日志长什么样
先看现象。应用日志里报错,翻译过来是:
Deadlock found when trying to get lock;
try restarting transaction
配上 LATEST DETECTED DEADLOCK 那段日志,关键行长这样:
*** (1) TRANSACTION: ... lock_mode X locks gap before rec insert intention waiting
*** (2) TRANSACTION: ... lock_mode X locks gap before rec insert intention waiting
*** (2) HOLDS THE LOCK(S): ... lock_mode X locks gap before rec
*** WE ROLL BACK TRANSACTION (2)
几个词反复出现:gap(间隙锁)、insert intention(插入意向锁)、waiting。这类死锁的日志基本就长这个样。下面把它的生成过程完整还原一遍。
二、极简复现:一张表、两条 SQL
MySQL 版本:Percona MySQL Server 5.7.19(5.6/5.7/8.x 行为一致)
隔离级别:可重复读(RR),InnoDB 默认
表结构,重点在二级索引:
CREATE TABLE `tb` (
`order_id` int(11) DEFAULT NULL,
KEY `idx_order_id` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
表中已有数据:
mysql> select * from tb;
+----------+
| order_id |
+----------+
| 10 |
| 20 |
+----------+
业务逻辑(事务内):
BEGIN;
DELETE FROM tb WHERE order_id = 15; -- 注意:15 这条记录不存在
INSERT INTO tb (order_id) VALUES (15);
COMMIT;
两个会话(两个并发线程)按同样的流程执行:
| 步骤 | 会话 A | 会话 B |
|---|---|---|
| 1 | BEGIN |
BEGIN |
| 2 | DELETE ... order_id=15(成功) |
DELETE ... order_id=15(成功) |
| 3 | INSERT ... 15(阻塞等待) |
INSERT ... 15(死锁,被回滚) |
想看到完整锁信息,先打开这个参数再看日志:
SET GLOBAL innodb_status_output_locks = ON;
死锁日志核心片段(SHOW ENGINE INNODB STATUS):
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index idx_order_id of table `db`.`tb`
lock_mode X locks gap before rec insert intention waiting
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS ... index idx_order_id of table `db`.`tb`
lock_mode X locks gap before rec
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index idx_order_id of table `db`.`tb`
lock_mode X locks gap before rec insert intention waiting
两个事务都握着 gap 锁,又都在等插入意向锁,到这里证据链就齐了。
三、前置知识:InnoDB 的四种锁和两种"隐形锁"
要读懂上面的日志,先把锁的类型理清。
3.1 四种基本锁
| 锁 | 全称 | 级别 | 说明 |
|---|---|---|---|
| S | 共享锁 | 行级 | 读锁,可共存 |
| X | 排他锁 | 行级 | 写锁,互斥 |
| IS | 意向共享锁 | 表级 | 表明表内有行被加 S 锁 |
| IX | 意向排他锁 | 表级 | 表明表内有行被加 X 锁 |
兼容性一句话记忆:S/S 兼容,X 和谁都不兼容;意向锁(IS/IX)之间互不冲突,表级锁和行级锁是两套体系。加行锁必须先持有对应的意向锁。
3.2 两种"隐形锁",死锁的真正主角
- 间隙锁(Gap Lock):锁住索引记录之间的空隙,目的是阻止其他事务往这个区间插入记录,是 RR 级别防幻读的核心机制。注意,gap 锁只防插入,不防读。
- 插入意向锁(Insert Intention Lock):insert 之前必须先拿到的入场券,一种特殊的间隙锁。它与已存在的 gap 锁冲突。
两条规则记牢,本案例就懂了一大半:
- gap 锁与 gap 锁之间不冲突,两个事务可以同时持有同一段间隙的 gap 锁;
- 插入意向锁与已存在的 gap 锁冲突,别人占着间隙,你的 insert 就得等。
四、抽丝剥茧:死锁是怎么一步步形成的
用上面的规则把复现过程逐帧拆开:
4.1 第 1 步:delete 一条不存在的记录,为什么也加锁?
这是整件事第一个反直觉的地方。order_id=15 明明不存在,DELETE 却拿到了两把锁:
2 lock struct(s), 1 row lock(s)
TABLE LOCK ... lock mode IX -- ① 表级意向排他锁
RECORD LOCKS ... lock_mode X locks gap before rec -- ② 间隙锁
原因在 RR 级别。InnoDB 用 next-key lock(记录锁 + 前向间隙锁)防幻读。DELETE WHERE order_id=15 时,即使 15 不存在,也必须锁住 15 所在的间隙,也就是 (10, 20) 这个区间,否则并发下别的事务插一条 15 进来,当前事务就会"幻读"到自己删除的记录。防幻读靠的是间隙锁,跟记录存不存在无关。
4.2 第 2 步:为什么两个事务能同时拿到 gap 锁?
因为 gap 锁与 gap 锁兼容。间隙锁的职责是阻止插入,不是互斥读;两个事务都只声明"这区间别插数据",互不妨碍,于是 A、B 各拿各的,都顺利通过。
死锁的种子就埋在这里:同一段间隙上,同时存在了 A 和 B 两把 gap 锁。
4.3 第 3、4 步:insert 为什么必须等?
现在 A 执行 INSERT ... 15。InnoDB 的 insert 流程是:先拿插入意向锁,再写记录。而插入意向锁与已存在的 gap 锁冲突,B 还握着 (10,20) 的 gap 锁,所以 A 卡住,等 B 释放。
几乎同时,B 也执行 INSERT ... 15,同样被 A 的 gap 锁挡住。
于是 A 持有 gap 锁、等 B 的 gap 锁释放;B 持有 gap 锁、等 A 的 gap 锁释放。双方都不会先放手,死锁闭环形成。InnoDB 的死锁检测器秒级介入,回滚代价较小的事务(日志里的 WE ROLL BACK TRANSACTION (2)),应用侧收到 Deadlock found when trying to get lock。
五、更隐蔽的变体:order_id 不同也会死锁
以为只要线程处理的 order_id 不一样就没事?天真了。看这个变体:
| 步骤 | 会话 A | 会话 B |
|---|---|---|
| 1 | BEGIN |
BEGIN |
| 2 | DELETE ... order_id=15 |
DELETE ... order_id=16 |
| 3 | INSERT ... 16(阻塞等待) |
INSERT ... 15(死锁) |
15 和 16 不同,为什么还是死了?因为它们落进的是同一个间隙 (10, 20)。A 的 delete 15 锁了 (10,20),B 的 delete 16 也锁了 (10,20),gap 锁与 gap 锁兼容,双双得手;随后 A 想插 16、B 想插 15,都要穿越对方握着的同一段间隙,插入意向锁全部被堵。死锁再上演一遍。
这类死锁跟键值是否相同无关,只跟间隙是否重叠有关。间隙锁是区间级的,天然比单点更容易打架。
六、解决方案:四招,按代价从低到高
6.1 先查后删:确认记录存在再删(改动最小)
BEGIN;
SELECT id FROM tb WHERE order_id = 15 FOR UPDATE; -- 或普通 SELECT 后判断
-- 记录存在才 DELETE;不存在则跳过删除,直接走 INSERT
DELETE FROM tb WHERE order_id = 15;
INSERT INTO tb (order_id) VALUES (15);
COMMIT;
为什么有效:当记录存在时,DELETE 命中真实记录,加的是记录锁(next-key lock 中的 record 部分),而不是纯间隙锁。两个事务抢同一行记录锁时,后到者会阻塞等待先到者提交释放,是排队,不是互等,死锁闭环被打破。
代价与局限:多一次查询,而且它不是银弹。如果两个事务各自删不同的已存在记录、又互相插入对方间隙里的值,仍可能死锁。但针对"删不存在的键"这个最常见的病灶,够用且最便宜。
6.2 用 upsert 替代"先删后插"(推荐)
业务允许的前提下,把"删了再插"改成一句 upsert,彻底绕开间隙锁博弈:
INSERT INTO tb (order_id, ...) VALUES (15, ...)
ON DUPLICATE KEY UPDATE col1 = VALUES(col1), ...;
前提是给 order_id 建唯一索引,唯一索引冲突才会触发 update 分支。从两段式事务变成单条语句,锁持有时间最短,死锁面急剧收窄。
6.3 隔离级别降到 RC(提交读)
SET GLOBAL transaction_isolation = 'READ-COMMITTED';
-- 或对会话/连接设置
SET SESSION transaction_isolation = 'READ-COMMITTED';
RC 级别下 InnoDB 关闭了 gap 锁(仅保留外键/唯一性检查所需的最小间隙锁),"先删后插"的间隙锁死锁基本绝迹。代价是可能产生幻读,而且得确认业务是否依赖 RR 的快照一致性,比如长时间事务内多次查询要求结果一致。这是挪配置换语义的方案,上线前必须过一轮业务语义评审。
6.4 从并发控制下手:别让两个线程同时处理同一个 key
- 分布式锁:对
order_id(或业务键)加锁,串行化处理; - 队列化:把任务按 key 路由到固定消费者,天然串行;
- 兜底重试:应用层捕获死锁异常,重试 2~3 次。死锁是被检测器主动回滚的,重试是官方推荐的常规兜底手段。
再配合老三条:事务要短、锁持有要短、where 条件要能用到索引。走不到索引的 delete/update 会升级为全表加锁,那是另一种灾难。
七、排查套路:3 分钟从死锁日志定位问题
下次再遇到死锁,按这个顺序看日志:
看日志就抓三样:
- 先找到
LATEST DETECTED DEADLOCK段,别被大段历史锁信息带偏; - 对比
HOLDS THE LOCK(S)和WAITING FOR THIS LOCK TO BE GRANTED:谁握着什么、在等什么,画一张"等待环",环闭合即死锁; - 看锁类型关键词:
locks gap、insert intention属于间隙锁系死锁,优先检查"先删后插""批量 insert""范围 update";lock_mode X加rec是行锁争抢;TABLE LOCK是表级问题。
八、写在最后
把整个案例压成一句话:RR 级别下,删除不存在的记录会留下间隙锁;间隙锁防得住插入,却防不住第二个间隙锁;当两个事务各持一把 gap 锁、又都想去插同一段间隙时,死锁就闭环了。
这类问题我第一次遇到也是懵的,后来在日志里多对了几次,慢慢就摸出规律了。几个点值得记住:
- delete 的记录不存在,不代表不加锁,RR 下间隙锁照加不误;
- 间隙锁的敌人是 insert,插入意向锁与 gap 锁互斥,这是死锁的扳机;
- "先查后删"能破局,但治本还是得靠少用先删后插、事务做短、并发做控,必要时降隔离级别,再加异常重试兜底。
比起死锁本身,更烦人的是看不懂日志、每次只能靠重启碰运气。把这个锁模型装进脑子里,下次再看到 insert intention waiting,至少能少慌几分钟,然后按第七节的套路慢慢查。
本文案例参考自公开的 MySQL 死锁分析文章(先 delete 再 insert 导致死锁的经典场景),结合同等条件在本地复现验证;因企业信息管控,内部业务细节已做脱敏与简化处理。