详解Redis分布式锁在SpringBoot的@Async方法中没锁住的坑

背景

Redis分布式锁很有用处,在秒杀、抢购、订单、限流特别是一些用到异步分布式并行处理任务时频繁的用到,可以说它是一个BS架构的应用中最高频使用的技术之一。

但是我们经常会碰到这样的一个问题,那就是我们都按照标准做了但有时运行着、运行着就是没锁住的问题。

一旦出了这样的问题特别难调试以及排查,因为在异步并行的环境下计算机代码的执行是乱序的,而且有一个“概率”问题。往往测10次结果都是对的。此时测试团队以为这次交付没有问题了于是布署上线,而上了线后会产生:要么一次都不对或者前10次对的第11次就是不对的。

要知道,锁的问题出了事不是小事。一旦出事对用户来说就和“死机”一样,死活无法操作了,亦或者时操作的结果乱通知、乱扣钱、随机不能下单,此时后台唯有找到锁键值,然后人为的把这个键值给“剁”掉才能解决。

因此,今天就借着刚排查的2个生产问题我们把锁的机制彻底的了解一下。

Redis锁的正确使用方式

//使用RedissonClient锁

@Autowired
private RedissonClient redissonSentinel;

//申明锁
RLock lock = null; 
lock = redissonSentinel.getLock(lockKey);

if (lock != null && lock.isLocked()) {
  //已经有一个任务在进行了,因此不能执行此时系统需要根据业务逻辑或进入等待或者return
}
try{
  lock.tryLock(0, TimeUnit.SECONDS);// 用自续约锁上锁
  接着下面就是做某事了
}catch(Exception e){
  
}finally { //直接按此解锁,永不出错,也不要在finally里去判断,而是强制在finally里关闭-大厂佳实践
  try {
    lock.unlock();
  } catch (Exception e) {
  }
}

以上是一个标准的Redis分布式锁的标准公式,下面给出配置

redis:
    password: 111111
    nodes: 192.1.0.11:7001
    redisson:
      nodes: redis://192.1.0.11:27001,redis://192.1.0.12:27001,redis://192.1.0.13:7001:27003
    sentinel: 
      nodes: 192.1.0.11:27001,192.1.0.12:27001,192.1.0.13:27001
      master: master1
      subscriptionsPerConnection: 50 #分布式锁必设此参数可以考虑放大它占用redis连接
      subscriptionConnectionPoolSize: 200 #分布式锁必设此参数可以考虑放大它占用redis连接

千万不要忘了这两关键字,很多人不设的话那么会出现生产上订单、并发一多直接会抛出redis锁连接不够用的错:

  • subscriptionsPerConnection
  • subscriptionConnectionPoolSize

生产典型问题

下面我们就来看自以为锁住了但是在生产上随机的“飘”的问题,要么锁死要么就没锁住的具体案例来讲解Redis分布式锁的一些坑吧。

每个用户只可以有一个文件导出没但没锁住

具体场景

每个用户在一个数据展式面板里查看数据,看到了自己要的数据就可以选择1万条做导出,导出时用户可以关闭当前页面甚至退出,后台任务导完后会以消息形式通知到用户,用户在自己的个人头像上可以看到一个小红点闪出。

需求

根据需求,这是一个云上的SAAS应用,我们对普通用户只提供同时只可以有一个导出任务在后台运行的机制。

当后台己有一条任务正在导出时用户此时在数据面板里就算点几十次“导出”都因该提醒用户“当前您有一个导出任务正在进行中”。

实际有问题代码

我看了一下代码,还挺公整的,它是这么判断的。

//使用RedissonClient锁
@Service
public class ExportService{

  @Autowired
  private RedissonClient redissonSentinel;

  @Async
  public void exportTask(){
  
    String redisLock=EXPORT_TASK_LOCK_KEY+":"+companyId+":"+loginId
    //申明锁
    RLock lock = null; 
    lock = redissonSentinel.getLock(lockKey);

    if (lock != null && lock.isLocked()) {
      //已经有一个任务在进行了,因此不能执行此时系统需要根据业务逻辑或进入等待或者return
    }
    try{
      lock.tryLock(0, TimeUnit.SECONDS);// 用自续约锁上锁
      接着下面就是做某事了
    }catch(Exception e){
    }finally { //直接按此解锁,永不出错,也不要在finally里去判断,而是强制在finally里关闭-大厂佳实践
        try {
          lock.unlock();
        } catch (Exception e) {
        }
    }
  }
}

对锁认识上的误区

我一眼就看出了问题,但我没有声张,我让开发和他的Leader以及我们的架构师一起来看。我这么提出问题让他们自己开动脑筋去想这个问题。

1. 首先,我们看到这个锁用companyId+loginId的确是可以做到锁的这个key唯一;

2. 但实际是没锁住因为前端用户在一个任务没有导完后再点按钮有时可以并发出两条导出任务有时只能并发出一条这是事实,那么肯定不是这个key唯一的问题;

3. 我们一起打开redis客户端用命令来查看服务器在导出任务时锁产生的情况,的确是看到产生的这个锁的key对于不同的人是唯一的key;

我的问题是:锁的key是唯一的就一定会被锁住吗?

三个人搔搔头回答我:可能吧

哈哈,问题就出在这。

以为只要锁的key是唯一,这个key被锁住了那另一个操作带着同样的lock key进来获取到的状态就一定是“已经上锁”

这个认知上错误了!!!

对于Redis分布式锁正确的认知

锁是存在于服务器上的,它不存在于客户端,同一个key来锁固然没错,但是我们看到了这个方法是一个被标为@Async的。

于是同一个客户点击一次就会生成一个Service类的exportTask进程。再点击一次又生成了一个Service类的exportTask进程。

当有10个exportTask进程时,我们虽然用的都是同一个lock key

    String redisLock=EXPORT_TASK_LOCK_KEY+":"+companyId+":"+loginId

但是别忘了,这个Service方法的完整运行机制是怎么样的?

 @Async
  public void exportTask(){
  
    String redisLock=EXPORT_TASK_LOCK_KEY+":"+companyId+":"+loginId
    //申明锁
    RLock lock = null; 
    lock = redissonSentinel.getLock(lockKey);

    if (lock != null && lock.isLocked()) {
      //已经有一个任务在进行了,因此不能执行此时系统需要根据业务逻辑或进入等待或者return
    }
    try{
      lock.tryLock(0, TimeUnit.SECONDS);// 用自续约锁上锁

看到没?

每次都要RLock lock=null,再lock = redissonSentinel.getLock(lockKey)一下。

此时后台有10个exportTask,这10个exportTask彼此都在实例化自己的锁、锁语句。这下好玩了,因为是异步的,是乱序的,所以此时发生了这么一件肉眼不可见的事:

  • exportTask1刚锁住正在操作导出还没有操作完时。
  • exportTask2进程被创建时把这个锁的状态给置成“初始化状态了“。
  • exportTask2于是在exportTask1还没有完成任务和释放锁时就又可以接着执行了。

这就是我们说的“没锁住”。

嘿嘿,这一切是@Async惹的祸。

如何改?

但我们又必须让这个方法是一个@Async的,因此怎么办?

把这个锁“上浮”到controller层。

在@RestController层的public ResponseBean exportDataAPI方法里如下申明

	RLock uploadLock = null;
	uploadLock = exportService.canLock(companyId);
	if (uploadLock != null && !uploadLock.isLocked()) {
		message = "导出中";
		exportService.exportTask(ut, data, uploadLock);
    }else{
        logger.info(">>>>>>有一个任务已经在导出,当前步骤不执行")
    }

在Service方法中放入一个canLock方法如下

    public RLock canLock(int companyId) {
        StringBuilder lockKeySB = new StringBuilder();
        lockKeySB.append(FoodTrainLLMConstants.LLM_UPLOAD_LOCK).append(companyId);
        RLock lock = redissonSentinel.getLock(lockKeySB.toString());
        return lock;
    }

然后我们在Service中如此改写原有逻辑

//使用RedissonClient锁
@Service
public class ExportService{

  @Autowired
  private RedissonClient redissonSentinel;

  @Async
  public void exportTask(Rlock lock){
    try{
      lock.tryLock(0, TimeUnit.SECONDS);// 用自续约锁上锁
      接着下面就是做某事了
    }catch(Exception e){
    }finally { //直接按此解锁,永不出错,也不要在finally里去判断,而是强制在finally里关闭-大厂佳实践
        try {
          lock.unlock();
        } catch (Exception e) {
        }
    }
  }
}

这就是把锁“上浮”,让其真正把一整个“进程”给锁住,于是这个问题就可以被解决了。

小结

这种错误一般在于非@Async中就算一开始写成了错误的那种写法你也发现不了,这是因为一切都是同步的。

而一旦但有了@Async后,上述生产问题就发生了。

多线程中希望每个用户只可以存在一个查询任务但实际没有锁住

这是另一个场景,但是其也发生在一个@Async方法中。

即在一个@Asynce标注的方法中还有一个while,而要锁是锁的while中的步骤。

于是我们看到了这样的代码

@Async
public void backendQueryImageTask(String ut, int companyId, String loginId, String userInputPrompt, String taskId) throws Exception { 
  while (System.currentTimeMillis() - startTime < timeoutMillis) {
    String lockKey = Query_Image_Redis_Lock_PREFIX + companyId + ":" + uid;
    RLock lock = null; 
    lock = redissonSentinel.getLock(lockKey);
    if (lock != null && lock.isLocked()) {
      logger.info(">>>>>>当前是backendQueryImageTask,图片还在生成中另一个任务在查询中,当前任务不进行");
      continue;
    }
    try {
      lock.tryLock(0, TimeUnit.SECONDS);// 上锁
。。。。。。
    catch (Exception e) {
      logger.error("Error in backendQueryImageTask", e);
      return;
    } finally {
      try {
          lock.unlock();
      } catch (Exception e) {
      }
    }

实际代码问题

这个问题和第一个问题其实是一样,因为锁是运行在服务器端的,它的状态不是维持在客户端。因此当这个方法如果是@Asynce时代表着后台会存在若干进程,而我们这次的需求是在每一个进程里再有一个while,而在while中运行时必须锁住。

但实际没有锁住也正是因为每一次循环时另一个进程把同一把本己锁住正在执行任务的锁的状态给连续的做了这样的操作:

RLock lock=null - > redissonSentinel.getLock(lockKey);

这就破坏了还在上锁的服务器上的同一把锁的状态导致了这个锁失效。

如何改?

        String lockKey = Query_Image_Redis_Lock_PREFIX + companyId + ":" + uid;
        RLock lock = null; 
        lock = redissonSentinel.getLock(lockKey);
        while (System.currentTimeMillis() - startTime < timeoutMillis) {
            if (lock != null && lock.isLocked()) {
                logger.info(">>>>>>当前是backendQueryImageTask,图片还在生成中另一个任务在查询中,当前任务不进行");
                continue;
            }
            try {
                lock.tryLock(0, TimeUnit.SECONDS);// 上锁
               //do something
            } catch (Exception e) {
                logger.error("Error in backendQueryImageTask", e);
                return;
            } finally {
                try {
                    lock.unlock();
                } catch (Exception e) {
                }
            }
        }

把锁的申明外置到while循环外即可成功达到我们的要求。

总结

锁一定是存在于服务器的,锁要锁的这个范围本身是异步运行的,因为如果是同步操作也没有这个锁的必要了。正因为是异步,所以服务器上的锁的对象是同一个,而当这个对象在异步并行时是乱序的,因此就会存在一个子进程“污染”到了另一个子进程里的锁对象。

为了成功把一组进程、业务原子方法锁住,这个锁的范围必须控制在其外层且这个锁的初始状态只可以被初始化一次。

此处我们考虑第一个例子中为什么把锁放在controller方法中这个锁就可以成功锁住Service里的方法呢?

这是因为每次用户在Controller方法中就算RLock lock=null时此时初始化的lock不是同一个对象,这是Controller方法的特性。

而只有当redissonSentinel.getLock(lockKey);时才会去拿服务端的锁,而此时这把锁的状态如果还没有被释放那么就一定是被锁住的。

附:redisson自续约锁的概念

当我们这样操作时

 lock.tryLock(0, TimeUnit.SECONDS);// 上锁

很多人会习惯性的在参数里把这个0改成30或者60。这样做反而是画蛇添足、错误的做法。

因为加上了一个确切的数字后就会有问题!你怎么知道这个方法正好执行了30秒或者是60秒就一定完成了呢?如果这个方法是需要62秒怎么办?你不是把方法给打断了。

因此,Redisson特有的自续约锁就是把这个值设成0。于是在后台Redisson锁会先给到锁30秒时间。

当第20多秒还没有碰到有用户调用finally里的unlock时它会再给这个key延续30秒。。。再没执行完再给它30秒。

直到碰到finally块里被显示的调用了unlock,那么代表任务结束,这把锁的状态才会变成“释放”。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

TGITCIC

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值