最近线上钉钉群告警 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(PRE_PAY_RECORD_KEY + ao.getOrderId()); try { //1. 预支付 加锁 boolean result = lock.tryLock(5, 10, TimeUnit.SECONDS); if (!result) { log.info("获取预支付锁失败orderId:{}", ao.getOrderId()); throw new XzllBusinessException(PRE_PAY_FAIL_MSG); } //2. 查询是否有该笔订单是否有预支付,为了期间不被修改(虽然有分布式锁,但是这个表有好几个地方都有 读和写 数据),所以这里加了X类型的行锁 LambdaQueryWrapper<PrePayOrderRecordDO> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(PrePayOrderRecordDO::getOrderId, ao.getOrderId()).last(" for update "); List<PrePayOrderRecordDO> 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<Long> deadLock(@RequestBody PrePayOrderAo ao) { List<Long> 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 order_dead_lock_test ( id bigint auto_increment comment '主键' primary key, orderId int not null comment '订单id', channelId int not null comment '渠道id', orderPrice int not null comment '订单金额单位分', status int not null comment '订单状态', createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间', update
文章评论