前言

  本文基于真实生产场景:空表高并发插入,一条成功、一条死锁,重试后 id 跳号。从原理到实战方案一次性讲透,新手也能看懂、生产直接落地。

一、问题现场描述

生产环境遇到一个典型 MySQL 死锁问题:

  1. 表结构:普通业务表,id 自增主键
  2. 初始状态:空表
  3. 并发场景:同一时刻,两个请求同时执行 insert 插入
  4. 现象:
    • 一条插入成功,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. 并发插入同一间隙 → 循环等待 → 死锁(完整流程)

  结合生产场景,一步一步拆解:

  1. 事务 A 执行插入操作:
    • 申请 (-∞, +∞) 间隙的插入意向锁;
    • 申请自增 id,拿到 id=1;
    • 准备插入数据,此时锁处于持有状态。
  2. 事务 B 同一时刻执行插入操作:
    • 同样申请 (-∞, +∞) 间隙的插入意向锁;
    • 申请自增 id,拿到 id=2;
    • 发现该间隙已被事务 A 持有锁,进入等待状态。
  3. 事务 A 执行过程中,也会检测到事务 B 持有该间隙的锁,同样进入等待状态;
  4. 两个事务互相等待对方释放锁,形成“循环等待”,触发 MySQL 死锁检测机制;
  5. MySQL 为了打破死锁,会主动回滚其中一个事务(比如事务 B);
  6. 被回滚的事务,已申请的自增 id=2 不会回收(MySQL 自增计数器独立于事务,一旦分配就不会退回),导致 id 跳号;
  7. 事务 A 解除等待,提交成功,表中出现 id=1 的数据;
  8. 客户端检测到事务 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;
  • 完全不影响业务逻辑,不用修改业务流程,代码改动量极小。

五、总结(一句话记住,避免踩坑)

  1. 空表并发插入死锁 = 间隙锁(RR 级别) + 长事务;
  2. 间隙锁的核心作用是防止幻读,只在 RR 隔离级别下存在,RC 级别会自动关闭;
  3. 最根治、最省心的方案:将数据库隔离级别改为 RC;
  4. 不能改配置的方案:最小事务原则,把间隙锁持有时间压到最短。

这两个方案都具备以下优势,生产直接可用:
✅ 不阻塞业务流程
✅ 不降低系统并发
✅ 不用引入分布式锁
✅ 改动成本低、落地快

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐