一、问题
高并发下两个事务同时执行 saveOrUpdate 操作同一条不存在的记录,极容易触发 MySQL InnoDB 间隙锁导致死锁,这是线上真实高频坑点。
二、saveOrUpdate 完整源码逻辑(MP3.x)
saveOrUpdate(entity):先查,判断是否存在;存在则 update,不存在则 insert
// IService
default boolean saveOrUpdate(T entity) {
if (null == entity) {
return false;
}
Class<?> cls = entity.getClass();
TableInfo tableInfo = TableInfoHelper.getTableInfo(cls);
// 1. 根据主键查询这条记录
T exist = this.getById((Serializable)tableInfo.getFieldByJoinColumnValue(entity));
// 2. 查询到 → update;查不到 → insert
return null != exist ? this.updateById(entity) : this.save(entity);
}
执行时序:
1、select * from table where id = ?;
2、如果返回 null → 执行 insert into table(id,xxx) values(?,?);
3、如果返回实体 → 执行 update table set xxx where id=?。
三、为什么并发插入不存在的记录会出现间隙锁死锁
- 前提:InnoDB,RR 隔离级别(可重复读 MySQL 默认),主键索引。
- 场景:两个事务 T1、T2,都要插入 id=100,数据库里id=100 这条记录不存在。
执行时序:

关键点:
在 RR 隔离级别,普通 select(不加 for update)是快照读,不加锁,所以两个事务的 select 都返回 null,双方都认为记录不存在,都要 insert。
当执行 insert id=100 时:
InnoDB 插入前,会对要插入的位置加 间隙锁(gap lock),防止幻读。
- T1 尝试 insert,加 (99, 101) 间隙锁;
- T2 尝试 insert,同样要申请这个间隙锁;
- 间隙锁是共享的,多个事务可以同时持有同一个间隙锁。
- T1 持有间隙锁,等待 T2 释放;T2 持有间隙锁,等待 T1 释放;
- 两个事务都拿着间隙锁,都等着对方释放,同时都要插入加插入意向锁,直接触发 死锁,MySQL 直接检测死锁,回滚其中一个事务。
死锁日志特征:
RECORD LOCKS space id xx page no xx n bits xx index PRIMARY of table `xxx`.`t`
Gap lock before rec
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS ... Gap lock before rec
*** WE ROLL BACK TRANSACTION (2)
这就是典型并发 saveOrUpdate 插入不存在主键,间隙锁死锁。
重点补充:如果记录已经存在,不会出现这个死锁:select 查到记录,走 updateById,update 会加行锁,不会触发间隙锁死锁。只有记录不存在,双方都走到 insert 分支才会踩坑。
四、重点说明
- 1、saveOrUpdate 不是数据库原子操作,是 Java 代码:select (快照读) + 分支 (insert/update),中间存在时间窗口,并发下会有竞态。
- 2、普通 select 快照读不加锁,所以两个事务都能读到 null,不会互相阻塞。
- 3、间隙锁共享,多个事务可以同时拿同一个 gap 锁,等到 insert 申请插入意向锁的时候才冲突,产生死锁。
- 4、如果隔离级别改成 RC,间隙锁会关闭,这个死锁现象会消失,但 RC 会出现幻读问题,业务一般不建议随意改全局隔离级别。
五、几个容易困惑的问题
5.1 间隙锁的区间是怎么定的?
举例子,主键索引现有记录:10,20,30
B + 树索引上真实记录顺序:10 → 20 →30
划分出来的间隙有:
(-∞,10)、(10,20)、(20,30)、(30,+∞)
- 你要插入 15,落在现存记录 10 和 20 之间 → 锁住间隙 (10,20)
- 你要插入 25,落在现存记录 20 和 30 之间 → 锁住间隙 (20,30)
- 你要插入 35,落在 30 之后 → 锁住间隙 (30,+∞)
5.2 如果直接 insert 的 ID 相同,在并发下会死锁吗?(不会)
直接 insert 相同主键 ID:不会死锁,只会阻塞 + 报唯一键冲突
5.3 直接insert且id不同,在并发下会死锁吗?(不会)
情况一、如果要新增的id没有落在同一个间隙区间,结论:不会死锁,也不会阻塞排队。
情况二、如果要新增的id落在同一个间隙区间,结论:不会死锁,但会阻塞排队。
T1 insert (1500),T2 insert (1600),同时执行。
insert 这条 SQL 内部是连续两步:先拿 S‑gap,紧接着申请 IX。
- T1、T2 都拿到S‑gap 共享间隙锁(S‑gap 互相兼容,可以同时拿到)
- InnoDB 锁调度,T1 抢到 IX:T1 持有 S‑gap + IX,执行插入
- T2 拿 IX 失败:T2 手里只有 S‑gap,阻塞等待 T1 释放 S‑gap
- 只有 T2 等待 T1,T1 不等待任何人,单向等待,无死锁
5.4 saveOrUpdate 的数据的 id 不同(比如用雪花ID),在并发下会死锁吗?(会)
先说结论:saveOrUpdate并发,id 可以不相同,只要待插入索引值落在同一个间隙,就会产生死锁。
使用雪花 ID,雪花 ID 有空洞跳跃:
数据库已有 id:100000,200000
业务并发两条 saveOrUpdate,待插入雪花 ID:120000、150000
两个 id 不一样,但是落在同一个间隙,依然会触发死锁。
过程:
- T1:select * from t where id=120000 快照读 →无锁,返回 null
- T2:select * from t where id=150000 快照读 →无锁,返回 null
- 两个 select 都是快照读,不拿任何锁,Java 层都判定 “数据不存在”。中间存在应用代码时间窗口。
- T1 执行 insert id=120000 :B + 树定位到间隙(100000,200000),拿到S‑gap 共享间隙锁
- T2 执行 insert id=150000 :同样定位到间隙(100000,200000),拿到S‑gap 共享间隙锁
- S‑gap 是共享锁,两者可以同时持有,此时两个事务都握着同一个 gap 锁,还没有真正插入成功,没有生成任何行锁。
- T1 申请本间隙的 IX 插入意向锁:被 T2 持有的 S‑gap 阻挡,等待 T2 释放 S‑gap
- T2 申请本间隙的 IX 插入意向锁:被 T1 持有的 S‑gap 阻挡,等待 T1 释放 S‑gap
- 形成循环等待,死锁发生。
重点:两个 insert 的 id 120000 和 150000 是完全不同主键,并不是争抢同一行数据,仅仅落在同一个间隙,就死锁。
总结:死锁根源不是 id 相等,是:快照读无锁判断存在性 + 应用层时间窗口 + RR 下 gap 锁共享特性。
5.5 直接 insert 和 saveOrUpdate对比

死锁的元凶不是 insert 本身,是快照读与 insert 之间的应用层时间窗口,给了两个事务机会,全部拿到 S‑gap 之后,再同时去争抢 IX。
六、saveOrUpdate业务使用真实场景
业务场景描述:
1、每个用户在点赞/评论动态后,会自动触发获取本季度勋章任务。因此每次点赞/评论动态后,先查询当前用户是否已有本季度勋章任务数据,如果没有则insert一条任务数据,有了则给勋章任务更新积分。
2、压力测试,批量触发多个用户的点赞/评论事件,虽然是不同的并发用户(非同一个用户),但都要select 、insert 任务表,出现死锁问题。
七、线上解决方案(4 种方案,按推荐优先级)
7.1 方案 1:使用数据库原子语句 INSERT ... ON DUPLICATE KEY UPDATE(最优)
放弃 saveOrUpdate,直接使用数据库原生原子语句,整个操作在数据库内部执行,没有 Java 层查询间隙,不会产生该死锁。
说明:这是MySQL 特有语法,插入数据时,如果唯一索引 / 主键冲突,则执行更新,而不是报错,俗称「存在就更新,不存在就插入」,等价于 upsert。
前提:必须有唯一键(主键 / 唯一索引)。
<insert id="insertOrUpdate">
INSERT INTO t(id,col1,col2) VALUES(#{id},#{col1},#{col2})
ON DUPLICATE KEY UPDATE
col1=#{col1},col2=#{col2}
</insert>
整条 SQL 是原子,不会出现 “先查再插” 的竞态窗口,规避间隙锁死锁。
样例SQL(3种写法)
INSERT INTO user(id, name, age)
VALUES (1, '张三', 20)
ON DUPLICATE KEY UPDATE
name = '张三',
age = 21;
INSERT INTO user(id, name, age)
VALUES (1, '张三', 20)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
age = VALUES(age);
INSERT INTO user(id, name, age)
VALUES (1, '张三', 20) AS new
ON DUPLICATE KEY UPDATE
name = new.name,
age = new.age;
当 id=1 不存在:执行 INSERT,新增一行。
当 id=1 已经存在:不再插入,执行 UPDATE name='张三', age=21。
7.2 方案 2:查询的时候加排他锁,把快照读改为当前读
把 getById 改成 select ... for update,提前锁住间隙。
// 手动写,先加锁查询,这时候就会阻塞另一个事务
T entity = baseMapper.selectByIdForUpdate(id);
if(entity == null){
save();
}else{
updateById();
}
缺点:并发会变成串行,性能下降;依然会加间隙锁,只是不会死锁,会阻塞等待。
注意:记录不存在时,select ... for update 在 RR 下也会加间隙锁,T1 拿到间隙锁,T2 执行 for update 就阻塞,不会同时进入 insert 分支,避免死锁。
可以简单理解为:T1 和 T2 不会同时获得间隙锁(gap 锁),而是排队阻塞,所以不会死锁。
7.3 方案 3:业务层分布式锁
执行 saveOrUpdate 前,针对主键 id 加分布式锁(Redis),同一 id 同一时间只允许一个事务进入,简单粗暴,会损耗性能。
6.4 方案 4:直接无脑执行 insert,捕获 DuplicateKeyException,捕获到异常后再执行 update。
try{
save(entity);
}catch(DuplicateKeyException e){
updateById(entity);
}
优点:没有 select 查询,没有竞态窗口;缺点:依赖异常流,业务代码不优雅。