MyBatis‑Plus saveOrUpdate + MySQL 间隙锁死锁问题

一、问题

高并发下两个事务同时执行 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 和 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 查询,没有竞态窗口;缺点:依赖异常流,业务代码不优雅。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容