MySQL 空表高并发插入必现死锁!间隙锁超详细解析 + 2个最优不影响业务方案
前言
本文基于真实生产场景:空表高并发插入,一条成功、一条死锁,重试后 id 跳号。从原理到实战方案一次性讲透,新手也能看懂、生产直接落地。
一、问题现场描述
生产环境遇到一个典型 MySQL 死锁问题:
- 表结构:普通业务表,id 自增主键
- 初始状态:空表
- 并发场景:同一时刻,两个请求同时执行 insert 插入
- 现象:
- 一条插入成功,id=1
- 另一条报死锁异常:
Deadlock found when trying to get lock; try restarting transaction - 重试后插入成功,id=3
- id=2 消失(自增 id 跳号)
总结:
空表 + 并发插入两条数据 = 必现间隙锁死锁
二、什么是间隙锁(Gap Lock)
在 MySQL 默认隔离级别 RR(REPEATABLE READ,可重复读) 下,InnoDB 为了防止幻读,在加锁时,不仅仅锁定已存在的数据行,还会锁定数据与数据之间的“空白区间”,防止别的事务在这个区间里插入数据——因为如果不锁定这个空白区间,其他事务插入新数据后,当前事务再次查询时,就会出现“原本没有的数据突然出现”的幻读现象,破坏事务的隔离性。这个锁定空白区间的锁,就叫:
间隙锁(Gap Lock)
1. 间隙锁锁的是什么?
- 锁的是不存在的空间,不是锁数据行,也不是锁主键,而是锁“两个值之间的空隙”。
- 举个例子:
- 空表时,主键没有任何值,间隙就是
(-∞, +∞)(负无穷到正无穷),整个主键范围都是空白区间; - 表中有 id=1 的数据后,间隙就被拆分成两个:
(-∞, 1)和(1, +∞); - 表中有 id=1、id=5 的数据后,间隙就变成三个:
(-∞, 1)、(1, 5)、(5, +∞)。
- 空表时,主键没有任何值,间隙就是
2. 插入意向锁 Insert Intention Lock
并发插入时,事务不会直接加普通的间隙锁,而是会加一种插入意向锁——它本质上也是一种间隙锁,但有特殊的兼容规则:
- 多个事务可以对同一个间隙加插入意向锁(理论上兼容,不会直接互斥);
- 但在空表/少数据量场景下,多个事务同时申请同一个间隙的插入意向锁时,会因为 MySQL 的锁等待排队机制,形成“循环等待”(A 等 B 释放锁,B 等 A 释放锁),最终触发死锁。
3. 间隙锁什么时候生效?
- 隔离级别:必须是 RR(可重复读) ——这是 MySQL 的默认隔离级别,也是间隙锁生效的前提;
- 触发语句:insert、update、delete、select for update(普通 select 不会加锁);
- 触发条件:语句命中的索引范围存在空白区间(空表、少数据量表最容易满足)。
4. 间隙锁的核心特点
- 不阻塞读:其他事务可以对间隙锁锁定的区间执行普通 select(无锁读);
- 阻塞插入:其他事务不能在间隙锁锁定的区间插入数据;
- 锁释放时机:只有事务提交或回滚,间隙锁才会释放——这也是“长事务会放大死锁概率”的关键。
三、为什么空表/少数据表插入更容易死锁?(核心)
1. 空表只有一个间隙
空表的主键索引区间只有一个:(-∞, +∞),所有并发插入请求,都会争抢同一个超大间隙,锁范围 100% 重叠,冲突的基础就存在。
2. 并发插入同一间隙 → 循环等待 → 死锁(完整流程)
结合生产场景,一步一步拆解:
- 事务 A 执行插入操作:
- 申请
(-∞, +∞)间隙的插入意向锁; - 申请自增 id,拿到 id=1;
- 准备插入数据,此时锁处于持有状态。
- 申请
- 事务 B 同一时刻执行插入操作:
- 同样申请
(-∞, +∞)间隙的插入意向锁; - 申请自增 id,拿到 id=2;
- 发现该间隙已被事务 A 持有锁,进入等待状态。
- 同样申请
- 事务 A 执行过程中,也会检测到事务 B 持有该间隙的锁,同样进入等待状态;
- 两个事务互相等待对方释放锁,形成“循环等待”,触发 MySQL 死锁检测机制;
- MySQL 为了打破死锁,会主动回滚其中一个事务(比如事务 B);
- 被回滚的事务,已申请的自增 id=2 不会回收(MySQL 自增计数器独立于事务,一旦分配就不会退回),导致 id 跳号;
- 事务 A 解除等待,提交成功,表中出现 id=1 的数据;
- 客户端检测到事务 B 死锁,发起重试,新事务申请自增 id 时,会跳过已分配的 id=2,直接拿到 id=3,插入成功。
这就是生产中遇到的:一条成功、一条死锁、重试 id=3 的完整原因。
3. 关键误区:数据多了为什么不容易死锁?
很多人会疑惑:“表里有 1 万条数据,最大 id=10000,插入 10001、10002,间隙不还是 (10000,+∞) 吗?为什么不会死锁?”。答案很简单:间隙确实一样,但锁持有时间完全不同:
- 空表/初始化场景:事务里常包含 RPC 远程调用、参数计算、业务初始化等耗时操作,导致事务执行时间长,间隙锁持有时间可达几百 ms;
- 数据量大后:插入操作多为常规业务,事务里只有单纯的 insert 语句,执行速度极快,间隙锁持有时间不足 10ms。
锁持有时间越长,并发请求在这段时间内争抢锁的概率就越高;反之,锁持有时间极短,即使间隙相同,也几乎不会出现循环等待,自然不会死锁。
四、2 个最优、不阻断业务的解决方案
下面两个方案,完全不影响现有业务流程、不降低并发、不用引入分布式锁,生产环境可直接落地,新手也能快速部署。
方案 1:降级隔离级别为 READ COMMITTED(RC)【最推荐】
原理
READ COMMITTED(读已提交)隔离级别下,InnoDB 会自动关闭间隙锁,只对已存在的数据行加行锁,不再对空白间隙加锁——从根源上杜绝了间隙锁和插入意向锁导致的死锁。
很多人会担心:降级隔离级别会影响业务吗?答案是:99% 的业务场景完全无感知。
- RC 级别仅会放弃“防止幻读”的特性,但绝大多数业务(如普通插入、更新、查询),对幻读的需求极低;
- 反而能减少锁冲突,提升数据库并发性能。
配置(永久生效,推荐)
修改 MySQL 配置文件(my.cnf 或 my.ini),添加以下配置,重启 MySQL 即可生效:
[mysqld]
# 降级隔离级别为 READ-COMMITTED
transaction-isolation = READ-COMMITTED
# 配套配置:主从复制需设为 ROW 格式(避免数据不一致)
binlog_format = ROW
临时生效(无需重启,测试环境可用)
执行以下 SQL,临时修改全局和当前会话的隔离级别,重启 MySQL 后失效:
-- 修改全局隔离级别
SET GLOBAL transaction_isolation = 'READ-COMMITTED';
-- 修改当前会话隔离级别(当前连接生效)
SET SESSION transaction_isolation = 'READ-COMMITTED';
优点
- 死锁直接消失,从根源解决问题;
- 数据库并发性能提升(减少锁竞争);
- 无需修改任何业务代码,99% 业务无感知;
- 配置简单,生产落地成本极低。
方案 2:最小事务原则,缩短锁持有时间【代码级最优】
如果你的项目有严格的数据库配置管控,不能修改隔离级别,这是第二最优解——核心是“缩短间隙锁的持有时间”,让冲突概率趋近于 0。
核心思想
间隙锁的持有时间 = 事务的执行时间。只要把事务拆到最小,只保留核心的数据库操作,移除所有非必要耗时操作,就能大幅缩短锁持有时间,从而减少冲突。
反例(长事务 = 死锁根源)
很多项目的死锁,都是因为事务包含了非数据库操作,导致锁持有时间过长:
@Transactional
public void submit(DTO dto) {
// 1. RPC 远程调用(耗时 500ms,无数据库操作)
RpcResponse response = rpcService.submitTransaction(dto);
if (!response.isSuccess()) {
throw new RuntimeException("远程调用失败");
}
// 2. 业务参数计算(耗时 50ms,无数据库操作)
Record record = convertToRecord(dto);
// 3. 真正的数据库插入(仅耗时 10ms)
mapper.insert(record);
}
此时,间隙锁的持有时间 = 500ms + 50ms + 10ms = 560ms,高并发下必现死锁。
正例(最小事务优化)
把所有非数据库操作(远程调用、参数计算、校验等)移出事务,只把核心的 insert 操作放在极小事务中:
// 无事务:先执行所有非数据库操作(不持有任何锁)
public void submit(DTO dto) {
// 1. 远程调用(移出事务,无锁)
RpcResponse response = rpcService.submitTransaction(dto);
if (!response.isSuccess()) {
throw new RuntimeException("远程调用失败");
}
// 2. 业务参数计算、校验(移出事务,无锁)
checkParam(dto);
Record record = convertToRecord(dto);
// 3. 仅把插入操作放在极小事务中
doInsert(record);
}
// 极小事务:仅包含数据库插入,添加超时控制
@Transactional(timeout = 3, rollbackFor = Exception.class)
public void doInsert(Record record) {
// 仅这一行核心数据库操作,耗时 < 10ms
mapper.insert(record);
}
关键补充
timeout = 3:设置事务超时时间为 3 秒,避免因代码异常导致事务卡死,锁长期持有;rollbackFor = Exception.class:确保所有异常都能触发事务回滚,避免锁泄漏;- 二次校验:如果有唯一键,可在事务外提前校验(如查询数据是否已存在),进一步减少事务内操作。
效果
- 间隙锁持有时间从 500+ ms,缩短到 10ms 以内;
- 高并发下,间隙锁冲突概率几乎为 0;
- 完全不影响业务逻辑,不用修改业务流程,代码改动量极小。
五、总结(一句话记住,避免踩坑)
- 空表并发插入死锁 = 间隙锁(RR 级别) + 长事务;
- 间隙锁的核心作用是防止幻读,只在 RR 隔离级别下存在,RC 级别会自动关闭;
- 最根治、最省心的方案:将数据库隔离级别改为 RC;
- 不能改配置的方案:最小事务原则,把间隙锁持有时间压到最短。
这两个方案都具备以下优势,生产直接可用:
✅ 不阻塞业务流程
✅ 不降低系统并发
✅ 不用引入分布式锁
✅ 改动成本低、落地快
更多推荐
所有评论(0)