mybatisPlus的saveOrUpdate有死锁问题?

一、前言

博观而约取,厚积而薄发

最近遇到个死锁报警,翻看日志堆栈,发现是调用mybatisPlus的saveOrUpdate方法报的。有点纳闷,这组件很成熟了,咋会出这个死锁问题。

在网上查,发现好多文章也报过这个问题, 如下图:

俗话说自动动手,丰衣足食,我也动手复现下。

二、实验环境

1、版本信息

复制代码
mysql版本:8.0.2
mybatis-plus-boot-starter版本:3.4.3.1

2、docker-compose配置:

yaml 复制代码
version: '3.3'
services:
  ### MySQL Container
  mysql:
    image: mysql:8.0.21  # mysql数据库及版本
    container_name: mysql8 # 容器名
    environment:
      MYSQL_ROOT_PASSWORD: 123456 #root管理员用户密码
      TZ: Asia/Shanghai
    ports:
      - "3306:3306"

3、建表语句

sql 复制代码
CREATE TABLE `t_msg_account` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',
  `msg_id` int DEFAULT NULL COMMENT '消息id',
  `tag` varchar(50) DEFAULT NULL COMMENT '消息id',
  PRIMARY KEY (`id`),
  KEY `idx_msg_id` (`msg_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1000 DEFAULT CHARSET=utf8mb4 COMMENT='消息账户表';

INSERT INTO `t_msg_account` (`id`, `msg_id`, `tag`)
VALUES
	('1000', '0', NULL),
	('1005', '5', NULL),
	('1008', '8', NULL),
	('10010', '10', NULL),
	('10015', '15', NULL);

4、调用代码

ini 复制代码
  @Test
    public void testSaveOrUpdate() {

        MsgAccount msgAccount = new MsgAccount();
        msgAccount.setTag("1");

        QueryWrapper<MsgAccount> wrapper = new QueryWrapper<>();
        wrapper.eq("msg_id", 5);

        msgAccountService.saveOrUpdate(msgAccount, wrapper);
        
    }

5、mybatisPlus源码

typescript 复制代码
    /**
     * <p>
     * 根据updateWrapper尝试更新,否继续执行saveOrUpdate(T)方法
     * 此次修改主要是减少了此项业务代码的代码量(存在性验证之后的saveOrUpdate操作)
     * </p>
     *
     * @param entity 实体对象
     */
    default boolean saveOrUpdate(T entity, Wrapper<T> updateWrapper) {
        return update(entity, updateWrapper) || saveOrUpdate(entity);
    }
    
    
      /**
     * 根据 whereEntity 条件,更新记录
     *
     * @param entity        实体对象
     * @param updateWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper}
     */
    default boolean update(T entity, Wrapper<T> updateWrapper) {
        return SqlHelper.retBool(getBaseMapper().update(entity, updateWrapper));
    }
    。。。。。。
    
        /**
     * TableId 注解存在更新记录,否插入一条记录
     *
     * @param entity 实体对象
     * @return boolean
     */
    @Transactional(rollbackFor = Exception.class)
    @Override
    public boolean saveOrUpdate(T entity) {
        if (null != entity) {
            TableInfo tableInfo = TableInfoHelper.getTableInfo(this.entityClass);
            Assert.notNull(tableInfo, "error: can not execute. because can not find cache of TableInfo for entity!");
            String keyProperty = tableInfo.getKeyProperty();
            Assert.notEmpty(keyProperty, "error: can not execute. because can not find column for id from entity!");
            Object idVal = tableInfo.getPropertyValue(entity, tableInfo.getKeyProperty());
            return StringUtils.checkValNull(idVal) || Objects.isNull(getById((Serializable) idVal)) ? save(entity) : updateById(entity);
        }
        return false;
    }

源码相对简单,有两个分支,1个分支是update,根据条件更新,如果影响行数大于等于1则成功,否则有id则更新,没有id则保存。

三、死锁原因

1、可能性分析

  • 可能死锁的操作1:update 成功触发死锁
  • 可能死锁的操作2:update 失败,insert保存死锁

2、理论准备

  • 不同操作加锁方式关系(网上资料多如牛毛,找了个截图)

本次操作只有update和insert,因此主要看排他锁

  • 数据间隙分析

    由于锁具体实现方式是record lock,gap lock等,而且msg_id是非等值索引,因此需要分析数据间隙

id msg_id Tag
1000 0
1005 5
1008 8
10010 10
10015 15

根据间隙锁生成原理,msg_id间隙如下

(-∞, 0]
(0,5]
(5,8]
(8,10]
(10,15]
(15, +∞]

3、场景复现

场景1、update成功

当mysql通过B+查询msg_id时只会查询到一条或者多条记录,比如1005,所以只会锁住msg_id=1005的数据一条或者多条,不同的msg_id没有冲突,只考虑相同msg_id的数据更新即可。操作如下:

时间点 事务A 事务B 动作
1 开始事务 开始事务
2 执行 update set tag=1 where msg_id=5 事务A申请意向排他锁IX作用于表上,接着申请到了排他锁X作用于区间,可能有多个5,Gap lock, 因此加锁范围: [5,8)
3 执行 update set tag=1 where msg_id=5 事务B申请意向排他锁IX作用于表上,接着申请到了排他锁X作用于区间,与事务A的Gap Lock冲突,等待事务A的Gap Lock释放。
4 检测到等待超时,Lock wait timeout exceeded

mysql截图如下(最后等待超时):

步骤2,通过SELECT * FROM performance_schema.data_locks;查询加锁情况如下:

IX: 意向排他锁,跟其他意向锁都兼容,当其他事务要对全表的数据进行加锁时,那么就不需要判断每一条数据是否被加锁了。

X排他锁,锁住了id=1005,数据为5这一行,其他事务就不能再获取该行的其他锁

X,REC_NOT_GAP:X代表排他锁REC_NOT_GAP代表行锁。综合起来就是对这条数据(索引项)添加了行级排他锁;

X,GAP: X代表排他锁;GAP代表间隙锁(前开后开,即当前的lock_data中的索引值对应的id最近的上一个id值之间的空隙被锁定了)表示(1005,1008)之间被锁定了

步骤3加锁明细如下

IX: 意向排他锁,跟事务A意向锁都兼容

X:waiting,等待,表示没有获取到排他锁

综上所述,没有互相持有的情况,update不会死锁,但是会有锁等待的情况。

场景2、update失败,save保存
时间点 事务A 事务B 动作
1 begin begin
2 update t_msg_account set tag = 1 where msg_id=16; 事务A申请意向排他锁IX作用于表上,接着申请到了排他锁X作用于区间,数据不存在,nextkey lock, 因此加锁范围: (15,+∞]
3 update t_msg_account set tag = 1 where msg_id=17; 事务B申请意向排他锁IX作用于表上,接着申请到了排他锁X作用于区间,数据不存在,nextkey lock, 因此加锁范围: (15,+∞]
4 INSERT INTO t_msg_account (msg_id, tag) VALUES ('16', NULL); 由于X排他锁不兼容, 事务A等待事务B释放排他锁
5 INSERT INTO t_msg_account (msg_id, tag) VALUES ('17', NULL); 事务B等待事务A释放排他锁,检测到互相持有和等待,报死锁:ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

现象如下:

步骤2: SELECT * FROM performance_schema.data_locks; 加锁情况如下

步骤3: 查看加锁情况如下

IX作用不在赘述了 。

LOCK_DATA 是 supremum pseudo-record,表示的是 +∞。然后锁范围的最左值是表中最后一个记录的值,也就是15。因此,执行完2和3后,事务A和事务Bnext-key锁的范围都是 (15, +∞]。

步骤4: 插入数据加锁如下

事务 A 的插入操作生成了一个插入意向锁(LOCK_MODE: X,INSERT_INTENTION),锁的状态是等待状态,,意味着事务 A 并没有成功获取到插入意向锁,因此事务 A 发生阻塞。

步骤5:

同理事务B插入也生成了插入意向锁,触发了死锁检测机制

show engine innodb status查看死锁原因:

明显看到事务2097和事务2096都在等待锁释放,但是他们锁的范围都是(15,+∞], 也就是互相等待,触发了死锁条件:相互等待、相互僵持。

那案子破了,结论就是:并发情况,非等值索引场景,saveOrUpdate操作时,update会锁住后面的空间,插入新数据有可能触发死锁问题。 另外有兴趣的可以试试唯一索引是什么结果。

四、怎么解决

1、增加唯一索引。先insert,如果报唯一索引异常则更新数据。

csharp 复制代码
   try {
          insertxxxx    
        } catch (SQLIntegrityConstraintViolationException e) {
            System.out.println("违反唯一性约束: " + e.getMessage());
            updatexxx # 更新数据
        } catch (SQLException e) {
            e.printStackTrace(); // 处理其他SQL异常
        } catch (Exception e) {
            e.printStackTrace(); // 处理其他通用异常
        }

2、使用分布式锁,如redis等,对相同数据加锁,防止更新相同记录。

本人公众号大鱼七成饱,历史文章会在上面同步

相关推荐
佚名涙32 分钟前
go中锁的入门到进阶使用
开发语言·后端·golang
草捏子6 小时前
从CPU原理看:为什么你的代码会让CPU"原地爆炸"?
后端·cpu
嘟嘟MD6 小时前
程序员副业 | 2025年3月复盘
后端·创业
胡图蛋.6 小时前
Spring Boot 支持哪些日志框架?推荐和默认的日志框架是哪个?
java·spring boot·后端
无责任此方_修行中6 小时前
关于 Node.js 原生支持 TypeScript 的总结
后端·typescript·node.js
吃海鲜的骆驼7 小时前
SpringBoot详细教程(持续更新中...)
java·spring boot·后端
迷雾骑士7 小时前
SpringBoot中WebMvcConfigurer注册多个拦截器(addInterceptors)时的顺序问题(二)
java·spring boot·后端·interceptor
uhakadotcom8 小时前
Thrift2: HBase 多语言访问的利器
后端·面试·github
Asthenia04128 小时前
Java 类加载规则深度解析:从双亲委派到 JDBC 与 Tomcat 的突破
后端
方圆想当图灵8 小时前
从 Java 到 Go:面向对象的巨人与云原生的轻骑兵
后端·代码规范