highlight: androidstudio
theme: juejin
最近线上钉钉群告警 mysql.jdbc.exception异常,这种db层面的异常一般都需要重视起来,于是抓紧排查和bugfix,没想到居然是一个死锁,于是有了这篇文章。
前提说明:
- mysql版本: 8.0.27
- 隔离级别: REPEATABLE-READ
- 事务自动提交:是
- 死锁检测机制:开启
- 数据库引擎:InnoDB
1、现象
1.1、钉钉群报警:
1.2、查看elk日志发现有死锁异常
2、复现 + 排查过程
2.1、业务以及代码逻辑说明
在说问题前,先把什么场景,干了什么事,代码逻辑说明一下,要不然会比较懵。
1、接口是干啥的?: 是预支付接口, 保存预支付记录,逻辑比较简单,直接贴项目真实代码感觉不好 (我这人保密意识比较强) ,所以我直接在我的项目 模拟了下主流程(模拟代码中 省略了些 非重要逻辑),复现了一下,主流程代码如下:
2、代码一览 (show code ~ ~): ```java /** * 模拟用户预支付业务逻辑 * * @param ao */ @Override @Transactional(rollbackFor = Exception.class) public void prePayOrder(PrePayOrderAo ao) { log.info("用户预支付-入参:{}", JSONUtil.toJsonStr(ao)); RLock lock = redissonClient.getLock(PREPAYRECORDKEY + ao.getOrderId()); try { //1. 预支付 加锁 boolean result = lock.tryLock(5, 10, TimeUnit.SECONDS); if (!result) { log.info("获取预支付锁失败orderId:{}", ao.getOrderId()); throw new XzllBusinessException(PREPAYFAILMSG); } //2. 查询是否有该笔订单是否有预支付,为了期间不被修改(虽然有分布式锁,但是这个表有好几个地方都有 读和写 数据),所以这里加了X类型的行锁 LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(PrePayOrderRecordDO::getOrderId, ao.getOrderId()).last(" for update "); List prePayOrderRecordDOS = prePayOrderRecordMapper.selectList(queryWrapper);
//3. 检查该订单是否有预支付过
if (!CollectionUtils.isEmpty(prePayOrderRecordDOS) && prePayOrderRecordDOS.stream().anyMatch(item -> Objects.equals(ORDER_STATUS_SUCCESS, item.getStatus()))) {
log.info("已预支付成功:" + ao.getOrderId() + "订单信息:" + JSONUtil.toJsonStr(prePayOrderRecordDOS));
throw new XzllBusinessException("已预支付成功无需再次支付");
}
//4. 插入该订单的预支付记录
PrePayOrderRecordDO prePayOrderRecordDO = new PrePayOrderRecordDO();
BeanUtils.copyProperties(ao, prePayOrderRecordDO);
Date date = new Date();
prePayOrderRecordDO.setCreateTime(date);
prePayOrderRecordDO.setUpdateTime(date);
int insert = prePayOrderRecordMapper.insert(prePayOrderRecordDO);
log.info("插入成功影响行数:{}", insert);
} catch (InterruptedException e) { log.error("获取预支付记录锁失败"); } finally { lock.unlock(); } } ``` 注意的是 for update 查询即步骤2, 实际项目中是个小方法,有很多地方调用这个方法。所以就算这个预支付有分布式锁,但是你其实无法真正的防止并发查询。
代码逻辑比较简单,注释很清晰,我们不再过多讨论。下边开始复现下死锁。
2.2、本地项目复现死锁
光有service不行,得写个 controller ,然后postman
调用下,controller 代码如下:
```java @Slf4j @RestController @RequestMapping("/mysql") public class MysqlDeadLockController {
@Autowired private ThreadPoolTaskExecutor taskExecutor; @Autowired private PrePayOrderRecordService prePayOrderRecordService;
@PostMapping("/deadLock/rangeGap") public List deadLock(@RequestBody PrePayOrderAo ao) { List add = Lists.newArrayList();
//模拟并发 预支付
int i = ao.getBegin();
for (int j = i; j <= ao.getEnd(); j++) {
PrePayOrderAo prePayOrderAo = new PrePayOrderAo();
prePayOrderAo.setChannelId(10);
prePayOrderAo.setStatus(1);
prePayOrderAo.setOrderPrice(200);
prePayOrderAo.setOrderId(j);
taskExecutor.execute(()->{
//********* 用户预支付 ************
prePayOrderRecordService.prePayOrder(prePayOrderAo);
});
}
return add;
} } ```
接口调用之前有2条数据: 表结构如下:(注意该表有普通二级索引即非唯一二级索引:
idx_orderId_status
,这个索引比较关键 后续分析都会围绕他
,需要关注一下。 ) ```sql create table orderdeadlocktest ( id bigint autoincrement comment '主键' primary key, orderId int not null comment '订单id', channelId int not null comment '渠道id'