mybatisPlus的saveOrUpdate有死锁问题?

452 阅读6分钟

一、前言

博观而约取,厚积而薄发

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

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

image-20250330103245626.png

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

image-20250331081753755.png

二、实验环境

1、版本信息

mysql版本:8.0.2
mybatis-plus-boot-starter版本:3.4.3.1

2、docker-compose配置:

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、建表语句

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、调用代码

  @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源码

    /**
     * <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则保存。

三、死锁原因

image-20250331083022101.png

1、可能性分析

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

2、理论准备

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

image-20250331082342828.png

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

  • 数据间隙分析

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

idmsg_idTag
10000
10055
10088
1001010
1001515

根据间隙锁生成原理,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截图如下(最后等待超时): image-20250330133113554.png

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

image-20250330212643515.png

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

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

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

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

步骤3加锁明细如下

image-20250330212953264.png

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

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

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

场景2、update失败,save保存
时间点事务A事务B动作
1beginbegin
2update t_msg_account set tag = 1 where msg_id=16;事务A申请意向排他锁IX作用于表上,接着申请到了排他锁X作用于区间,数据不存在,nextkey lock, 因此加锁范围: (15,+∞]
3update t_msg_account set tag = 1 where msg_id=17;事务B申请意向排他锁IX作用于表上,接着申请到了排他锁X作用于区间,数据不存在,nextkey lock, 因此加锁范围: (15,+∞]
4INSERT INTO t_msg_account (msg_id, tag) VALUES ('16', NULL);由于X排他锁不兼容, 事务A等待事务B释放排他锁
5INSERT 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

现象如下: image-20250330134947844.png

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

image-20250330215628921.png

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

image-20250330215721779.png

IX作用不在赘述了 。

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

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

image-20250330220026309.png

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

步骤5:

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

show engine innodb status查看死锁原因:

image-20250330220420053.png

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

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

四、怎么解决

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

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

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

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

image.png