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

news2024/10/10 12:22:03

背景

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,那么代表任务结束,这把锁的状态才会变成“释放”。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2195463.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

分层解耦-05.IOCDI-DI详解

一.依赖注入的注解 在我们的项目中&#xff0c;EmpService的实现类有两个&#xff0c;分别是EmpServiceA和EmpServiceB。这两个实现类都加上Service注解。我们运行程序&#xff0c;就会报错。 这是因为我们依赖注入的注解Autowired默认是按照类型来寻找bean对象的进行依赖注入…

基于Qt的速度仪表盘控件实现

本文将详细讲解一个基于Qt的速度仪表盘控件的实现过程&#xff0c;并对代码进行详细的注释说明。该控件可以模拟汽车仪表盘的外观&#xff0c;并通过滑动条动态改变速度显示。本文将从代码结构、绘制组件到实现细节进行讲解&#xff0c;帮助您理解如何使用Qt框架自定义绘制控件…

CSRF | GET 型 CSRF 漏洞攻击

关注这个漏洞的其他相关笔记&#xff1a;CSRF 漏洞 - 学习手册-CSDN博客 0x01&#xff1a;GET 型 CSRF 漏洞攻击 —— 理论篇 GET 型 CSRF 漏洞是指攻击者通过构造恶意的 HTTP GET 请求&#xff0c;利用用户的登录状态&#xff0c;在用户不知情的情况下&#xff0c;诱使浏览器…

Cortex-M3/M4/M7 芯片 Fault 分析原理与实战

目录 一、简介1、异常类型2、异常优先级3、同步异步问题4、异常具体类型 二、Fault exception registers1、Control registers1.1 CCR1.2 SHP1.3 SHCSR 2、Status and address registers2.1 HardFault Status Register——HSFR2.2 Configurable Fault Status Register——CFSR2…

《Linux从小白到高手》进阶实操篇:用户及权限有关的实际工作场景应用

List item 本篇为《Linux从小白到高手》进阶实操篇的第一篇&#xff0c;主要介绍分享一些用户及权限有关的实际工作场景应用。 场景1&#xff1a; 实际工作中你一定会碰到如下图所示的情景&#xff1a;本部门有5个组&#xff0c;分别为&#xff1a;①Root组&#xff1a;用户…

Python中对象obj类型确定最pythonic的方式——isinstance()函数

python中确定对象obj的类型&#xff0c;isinstance函数最是优雅&#xff0c;type、issubclass等函数也可以&#xff0c;但终究“曲折”。 (笔记模板由python脚本于2024年10月07日 19:42:38创建&#xff0c;本篇笔记适合喜欢python的coder翻阅) 【学习的细节是欢悦的历程】 Pyth…

Vue2电商项目(七)、订单与支付

文章目录 一、交易业务Trade1. 获取用户地址2. 获取订单信息 二、提交订单三、支付1. 获取支付信息2. 支付页面--ElementUI(1) 引入Element UI(2) 弹框支付的业务逻辑(这个逻辑其实没那么全)(3) 支付逻辑知识点小总结 四、个人中心1. 搭建二级路由2. 展示动态数据(1). 接口(2).…

【Kubernetes】常见面试题汇总(六十)

目录 131. pod 一直处于 pending 状态&#xff1f; 132. helm 安装组件失败&#xff1f; 特别说明&#xff1a; 题目 1-68 属于【Kubernetes】的常规概念题&#xff0c;即 “ 汇总&#xff08;一&#xff09;~&#xff08;二十二&#xff09;” 。 题目 69-113 属于…

企业经营异常怎么解除

经营异常是怎么回事&#xff1f;是什么意思&#xff1f;了解异常原因&#xff1a;我们到所属工商营业执照异常的具体原因。原因可能包括未按时提交年报、未履行即时信息公示义务、公示信息隐瞒真实情况或弄xu作jia、失联等。纠正违规行为&#xff1a;查到了异常原因&#xff0c…

洛谷P5723、P5728、P1428、P1319 Python解析

P5723 完整代码 def is_prime(y):if y < 2:return Falsefor i in range(2, int(y**0.5) 1):if y % i 0:return Falsereturn Truen int(input()) sum_primes 0 x 0if n < 2:print("0") elif n 2:print("2\n1") else:for i in range(2, n 1):i…

计数原理与组合 - 离散数学系列(三)

目录 1. 计数原理的基本概念 加法原理&#xff08;Rule of Sum&#xff09; 乘法原理&#xff08;Rule of Product&#xff09; 2. 排列与组合 排列&#xff08;Permutation&#xff09; 组合&#xff08;Combination&#xff09; 日常生活中的例子 3. 二项式定理 4. 实…

Mysql锁机制解读(敲详细)

目录 锁的概念 全局锁 表级锁 表锁 元数据锁 意向锁 锁的概念 全局锁 表级锁 表锁 元数据锁 主要是对未提交事务&#xff0c;修改表结构造成表结构混乱&#xff0c;进行控制。 在不涉及表结构变化的情况下,元素锁可以忽略。 意向锁 避免有行级锁影响加表级锁&#xff0…

Mysql(六) --- 聚合函数,分组和联合查询

文章目录 前言1.聚合函数1.1.常用的函数1.2.COUNT()1.3.SUM()1.4.AVG()1.5.MIN()、MAX() 2.GROUP BY 分组查询2.1.语法2.2.示例2.3.HAVING 子句 3.联合查询3.1.为什么要进行联合查询3.2.那么是如何进行联合查询的3.3.示例&#xff1a;一个完整的联合查询的过程3.4.内连接3.5.外…

Error:WPF项目中使用oxyplot,错误提示命名空间中不存在“Plot”名称

在OxyPlot中&#xff0c;<oxy:PlotView>和<oxy:Plot>都是用来显示图表的控件&#xff0c;在WPF项目中使用oxyplot之前&#xff0c;先通过NuGet安装依赖包&#xff1a;OxyPlot.Wpf。 <oxy:PlotView>和<oxy:Plot>使用示例&#xff1a; <oxy:PlotVie…

【算法】双指针(续)

一、盛最多水的容器 11. 盛最多水的容器 - 力扣&#xff08;LeetCode&#xff09; 给定一个长度为 n 的整数数组 height 。有 n 条垂线&#xff0c;第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。 找出其中的两条线&#xff0c;使得它们与 x 轴共同构成的容器可以容纳最多…

OJ在线评测系统 微服务 OpenFeign调整后端下 nacos注册中心配置 不给前端调用的代码 全局引入负载均衡器

OpenFeign内部调用二 4.修改各业务服务的调用代码为feignClient 开启nacos注册 把Client变成bean 该服务仅内部调用&#xff0c;不是给前端的 将某个服务标记为“内部调用”的目的主要有以下几个方面&#xff1a; 安全性: 内部API通常不对外部用户公开&#xff0c;这样可以防止…

Nginx05-基础配置案例

零、文章目录 Nginx05-基础配置案例 1、案例需求 &#xff08;1&#xff09;有如下访问 http://192.168.119.161:8081/server1/location1 访问的是&#xff1a;index_sr1_location1.htmlhttp://192.168.119.161:8081/server1/location2 访问的是&#xff1a;index_sr1_loca…

慢接口分析与优化总结

文章目录 1. 慢接口优化的意义2. 接口耗时构成3. 优化技巧3.1. 内部代码逻辑异步执行[异步思想]并行优化拒绝阻塞等待预分配与循环使用[池化思想]线程池合理设计锁粒度避免过粗优化程序结构 3.2. 缓存恰当引入缓存[空间换时间思想]缓存延迟优化提前初始化缓存[预取思想] 3.3. 数…

工具函数(截取文本第一个字为图片)

const subStringToImage (params) > {const { str ,color #FFF,background #4F54FF,size 60,fontSize 20 } paramsif(str.length < 0) return console.error(字符不能为空!)const text str.slice(0, 1)const canvas document.createElement(canvas)const ctx …

github 国内文件加速下载

参看;https://www.cnblogs.com/ting1/p/18356265 在源网址前加上 https://hub.gitmirror.com/ 或https://mirror.ghproxy.com/&#xff0c;例如&#xff1a; https://hub.gitmirror.com/https://github.com/t1m0thyj/WinDynamicDesktop/releases/download/v5.4.1/WinDynamicD…