记一次线上并发出现分布式锁失效的问题
背景:前些天公司的测试工程师说有用户反馈预约系统有问题,大概说是放出50个预约号会存在51个人预约的情况。
听到后第一时间想到的是:竟然我们公司的系统也有一定的并发量,赶紧先看一下数据。
看了数据发现确实存在2条数据在比较接近时间内插入成功。
记得自己有加分布式锁,难道是锁没加对位置,赶紧看一下代码。
public class DayEntity implements java.io.Serializable {
/**主键*/
private String id;
/**预约额度*/
private Integer totalNum;
/**已预约人数*/
private Integer bookNum;
}
public class AppointmentUserEntity implements java.io.Serializable {
/**主键*/
private String id;
private Date createDate;
/**预约时段*/
private String appointmentTime;
}
DayEntity dayEntity = this.get(DayEntity.class, dayId);//从库中找数据
if(RedissonLockUtil.tryLock(CacheKeys.BOOK_SYNC_LOCK+dayId , 300, 500)){//分布式锁
setAppointmentByDay(entity,dayEntity);//业务逻辑:会更改DayEntity 的bookNum值,并在AppointmentUserEntity表新建一条数据
尴尬了,认真一看锁的位置确实不太对,从数据库拿锁后再加锁,并发情况下,下次库中拿到的数据dayEntity有可能是旧的。于是改下这段代码再试试
if(RedissonLockUtil.tryLock(CacheKeys.BOOK_SYNC_LOCK+dayId , 300, 500)){//分布式锁
DayEntity dayEntity = this.get(DayEntity.class, dayId);//从库中找数据
setAppointmentByDay(entity,dayEntity);//业务逻辑:会更改DayEntity 的bookNum值,并在AppointmentUserEntity表新建一条数据
private UserEntity setAppointmentByDay(UserEntity entity, DayEntity dayEntity) {
//已预约人数 小于 预约额度
if(dayEntity.getTotalNum > dayEntity.getBookNum ){
dayEntity.setBookNum(dayEntity.getBookNum()+1);//预约额度 +1
dayService.update(dayEntity);
this.save(entity);//预约用户加一
}
//...其他逻辑
return entity;
}
这次本地在idea启动多一个服务试试,发现移出去后确保了第一个线程完成之前,第二个线程才能获取值(保证获取到的是新bookNum值)。
以为这样就好了后 这次决定用压测工具试下,彻底点解决整个问题。(之前以为并发量不高,后来听说用户都是集中在一个时间内预约,处理业务逻辑消耗比较久的话想想确实会有一定问题),于是上JMeter。
发现当并发上来竟然会有问题,还是出现了50个号,但是预约人数超过了50人。
![在这里插入图片描述](https://img-blog.csdnimg.cn/9d53669e541f48aa81a7fc3107c4e67f.png
有点吃惊,难道是分布式锁不生效,于是打印一些log看看:
把线程名称和获取到的已预约数量拿出来,发觉不同的线程会拿到DayEntity旧的已预约人数bookNum的值。
线程会拿到DayEntity旧的已预约人数bookNum的值,究竟是分布式锁没生效还是 数据库的缓存问题还是框架自身一些原因导致的呢(这个系统已经比较老旧用的是jeecg框架二次开发的, orm是用hibernate)。
- 去掉分布式锁后,超预约的人数更多
- 并发情况下这里相当于立刻更新了DayEntity.bookNum,然后立马取出来判断,以为数据库刷盘有缓存的原因导致
- 用下面代码测试:获取到锁后让程序sleep下 让这种方式尝试让数据库恢复最新值
if(RedissonLockUtil.tryLock(CacheKeys.BOOK_SYNC_LOCK+dayId , 300, 500)){
Thread.sleep(30);//refresh db 让这种方式尝试让数据库恢复最新值
DayEntity dayEntity = this.get(DayEntity.class, dayId);
setAppointmentByDay(entity,dayEntity);
鉴于以上两点并测试发现睡眠适量时间后并发情况的数据超预约问题可以解决,以为分布式锁成功只是并发情况下更新了数量立马读出来。于是尝试了另外一个思路,既然数据库io性能比较慢 那么何不换redis 来记录每次的已预约记录数DayEntity.bookNum。
于是尝试用redis记录已预约人数DayEntity.bookNum,每次有人预约后 往redis加数据,判断是否预约满也从redis取。
private UserEntity setAppointmentByDay(UserEntity entity, DayEntity dayEntity) {
//已预约人数 小于 预约额度
if(dayEntity.getTotalNum > redisValue ){ //这里每次判断从redis拿值判断
dayEntity.setBookNum(dayEntity.getBookNum()+1);//预约额度 +1
dayService.update(dayEntity);
userService.save(entity);//预约用户加一
}
//...其他逻辑
return entity;
}
这样每次对比库存中的值是否满了都从redis拿redisValue来判断,对redis的key的操作是单线程的保证拿到的值是唯一并且是最新的。通过引入redis后解决了并发问题,JMeter无论调多高都能正确返回数据了。
但是本着对技术寻根问底的态度还是继续研究了下到底分布式锁是不是并没有生效。
怀疑是跟事务有关系 ,首先重新写了一个例子,重新看代码测试一下。
短点后直接进入@Transactional的切面
if (txAttr == null || !(tm instanceof CallbackPreferringPlatformTransactionManager)) {
// Standard transaction demarcation with getTransaction and commit/rollback calls.
TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);
Object retVal = null;
try {
// This is an around advice: Invoke the next interceptor in the chain.
// This will normally result in a target object being invoked.
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
// target invocation exception
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}
finally {
cleanupTransactionInfo(txInfo);
}
commitTransactionAfterReturning(txInfo);
return retVal;
}
进入切面后代码后突然恍然大悟了,定位到Spring源码org.springframework.transaction.interceptor.TransactionAspectSupport#invokeWithinTransaction, 可以发现锁在事务提交前释放了。
子类继承父类后也有事务,相当于加了@Transactional,在spring的@Transactional切面源码中看到它是先执行切面代码 最后提交事务,执行切面代码时分布式锁已经释放 而因为事务还没提交数据库的值还没发生改变,所以其他线程能够拿到旧值 所以并发情况下有数据不一致问题,解决办法:把锁放在事务外层
解决方案:所以最合适的解决方案应该是:为了避免锁在事务提交前释放,我们应该在事务外层使用锁。
由于这个类继承的一个公共类加了一个@Transaction方法了(这个jeecg框架的公共类层面加了感觉粒度有点大),所以其实这个子类也有事务了的,上面截图等价于:
@Transactional
@Override
public ResultVo test() throws BaseBizException {
所以
还有一种方法有个@Order注解 让分布式锁也写成注解,但是分布式锁的@Order 后于@Transaction的Order就行
第三种方法可以用事件来解耦通知事务是否提交或者回滚
- 操作业务代码的最后发布事件
- 添加监听:
其实这是分布式锁失效的常见场景,除此以外分布式锁失效还有两种场景
- 业务未执行完,锁超时释放
/**
* 尝试获取锁
*
* @param lockKey
* @param waitTime 最多等待时间
* @param leaseTime 上锁后自动释放锁时间
* @return
*/
public static boolean tryLock(String lockKey, int waitTime, int leaseTime) {
return redissLock.tryLock(lockKey, TimeUnit.SECONDS, waitTime, leaseTime);
}
解决方案为可以把锁失效时间设置长一点
或者可以采用类似Redisson的watchdog机制给锁续命
- Redis节点主从切换