一、前言
博观而约取,厚积而薄发
最近遇到个死锁报警,翻看日志堆栈,发现是调用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等,对相同数据加锁,防止更新相同记录。
本人公众号大鱼七成饱,历史文章会在上面同步