读已提交级别下 注解事务+分布式锁结合引起的事故--活动购买机会
背景:
我们这里有个限购活动可以对某些商品进行机会限购,用户可以通过积极参与平台游戏或者购物等获取购买机会。今天突然收到系统告警,有大量异常错误码。
事故现象:
看了下记录是给17万用户每人加了两次购买机会,而且业务侧给每个人加机会不是一次加够,而是业务测采用每调一次接口加一次机会的形式...业务层分了8万组数据,每组一个用户,每组并发调两次机会增加接口,事故造成该商家17万会员里的350余名会员用户无法正常下单,受损用户比较少,商家还没发现问题就有告警中心邮件发来了,然后在客诉之前解决了问题;
事故大概原因:
排查了一下,发现这是一场由Mysql Read COMMIT级别
+注解事务
+分布式锁
,当系统收到极端高并发情况(μs级)下引起的事故。 三个结合在一起产生的特殊bug。
下面由我细细道来
一. 业务简单伪代码贴一下:
/**
* 机会增加接口
XXXXXXX等符号是我手动打码行为
*/
@Transactional(rollbackFor = Exception.class) //注意,就是这里有问题
@PostMapping("chanceAdd")
public XxxDto chanceAdd(@RequestBody xxxReq req) {
// 快速去重\快速失败机制(借鉴AQS的addWaiter)----除此之外后面还有数据库唯一键做保底持久去重
if (!redisUtils.setExNx(REPEAT_CHECK_PRE +XXX orderNo XXXXX)) {// 业务订单号判重,同一笔交易只能增加一次机会
throw new CommonException(ApplicationCode.REPEAT_SUBMIT,"重复添加机会");
}
//按人+商家+活动申请一把锁
RLock lock = redissonClient.getLock(REPEAT_CHECK_PRE +XXX人,商家id,活动idXXXXX);
lock.lock();
try {
//活动添加记录增加
final boolean saveRes = extChanceAddRecordService.save(ExtChanceAddRecordMapping.INSTANCE.toQuotaAddRecordPojo(req));
if (saveRes) {
//该人总机会增加,查询是否已经存在用户总机会记录
UserExtChance userExtChance = service.getUserExtChance(req.getUserId(), req.getMallId(), req.getActivityId());
if (userExtChance==null){//如果用户购买记录不存在
//生成用户对该活动的总机会记录
}else {//已存在
//对已有机会记录做增加
}
}
} catch (Exception e) {
log.error("chanceAdd,data:{},errorMsg:{}",req.toString(),e.getMessage());
throw new CommonException(ApplicationCode.REPEAT_SUBMIT);
} finally {
lock.unlock();
}
return new XxxDto();
}
二.错误原因分析
我们按照代码线分析,模拟异常情况
- 事务开启没有问题
- 这里的红锁也可以保障分布式情况下对单人单商家单活动添加机会的串行化
- 但是假如有两个线程A,B并发去调这个接口,可能出现A释放锁未提交事务,B获取锁由于A未提交的事务,获取的是A提交之前的快照,因此做出了错误判断
- 至此 A,B均对于同一用户生成了两条总机会记录。或者出现了数据覆盖的问题(其他可能情况)。 错误流程模拟,分析
三.总结
本次错误原因是虽然我们用红锁保障了特定机会((用户,商家,活动)维度)增加的串行化,但是我们这里事务是用的注解事务导致事务在方法结束之后才提交,因此Read COMMIT级别下,并发情况可能读到了未变更的数据,导致做出错误判断
四.解决
改成声明式事务,在业务结束后提交事务或者异常回滚事务,重点要在串行化结束之前(这里是获取到红锁之前)完成整个事务的操作;
多亏系统各种告警配置....在用户还没发现之前就把问题暴露出来了,一天内完成了问题暴露,找到原因,测试复现,开发解决,发布测试,上线,刷数据,复测验证整个流程;
建议只有极简单的事务用注解事务,复杂业务还是手动比较好。
另外注意只要我方主动加锁的一般都是咱们知道这里肯定有潜在并发问题,在测试人员测试时候必须让测试人员多测几十组,确保咱们的防并发没问题;
我们这个业务之前也让测试人员测试了,用了30组 30qps的并发,但是由于这里确实比较偶发,所以没出现问题...这次是线上出现了1W多组并发出现了问题;