详解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: 111111nodes: 192.1.0.11:7001redisson:nodes: redis://192.1.0.11:27001,redis://192.1.0.12:27001,redis://192.1.0.13:7001:27003sentinel: nodes: 192.1.0.11:27001,192.1.0.12:27001,192.1.0.13:7001:27003master: master1subscriptionsPerConnection: 50 #分布式锁必设此参数可以考虑放大它占用redis连接subscriptionConnectionPoolSize: 200 #分布式锁必设此参数可以考虑放大它占用redis连接

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

  • subscriptionsPerConnection
  • subscriptionConnectionPoolSize

生产典型问题

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

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

具体场景

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

需求

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

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

实际有问题代码

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

//使用RedissonClient锁
@Service
public class ExportService{@Autowiredprivate RedissonClient redissonSentinel;@Asyncpublic 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方法的完整运行机制是怎么样的?

 @Asyncpublic 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{@Autowiredprivate RedissonClient redissonSentinel;@Asyncpublic 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.xdnf.cn/news/1556873.html

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

相关文章

JavaEE之多线程进阶-面试问题

一.常见的锁策略 锁策略不是指某一个具体的锁&#xff0c;所有的锁都可以往这些锁策略中套 1.悲观锁与乐观锁 预测所冲突的概率是否高&#xff0c;悲观锁为预测锁冲突的概率较高&#xff0c;乐观锁为预测锁冲突的概率更低。 2.重量级锁和轻量级锁 从加锁的开销角度判断&am…

【Docker】03-自制镜像

1. 自制镜像 2. Dockerfile # 基础镜像 FROM openjdk:11.0-jre-buster # 设定时区 ENV TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 拷贝jar包 COPY docker-demo.jar /app.jar # 入口 ENTRYPOINT ["ja…

【强训笔记】day26

NO.1 思路&#xff1a;只需判断长度为2和3的回文子串。 代码实现&#xff1a; #include<iostream> #include<string>using namespace std;string s;int main() {cin>>s;int ns.size(),ret-1;for(int i0;i<n;i){if(i1<n&&s[i]s[i1]){ret2;}i…

笔试题总结

1.对于线性表的描述&#xff1a;存储空间不一定是连续&#xff0c;且各元素的存储顺序是任意的 2.虚函数的定义&#xff1a;函数的返回值参数不定&#xff0c; 声明&#xff1a; 类型&#xff0c;返回这类型 名字&#xff08;&#xff09;&#xff1b; 例如声明一个虚函数&a…

57.对称二叉树

迭代 class Solution {public boolean isSymmetric(TreeNode root) {if(rootnull){return true;}Deque<TreeNode> denew LinkedList<>();TreeNode l,r;int le;de.offer(root.left);de.offer(root.right);while(!de.isEmpty()){lde.pollFirst();rde.pollLast();if(…

二、图解C#教程

一、方法 {}块&#xff0c;里面的是方法体 二、Var关键字 推断出等号右边的实际类型 三、局部常量 1、声明时必须初始化 2、声明后不能改变

高效医疗:Spring Boot医院管理解决方案

1系统概述 1.1 研究背景 如今互联网高速发展&#xff0c;网络遍布全球&#xff0c;通过互联网发布的消息能快而方便的传播到世界每个角落&#xff0c;并且互联网上能传播的信息也很广&#xff0c;比如文字、图片、声音、视频等。从而&#xff0c;这种种好处使得互联网成了信息传…

【Nacos入门到实战十四】Nacos配置管理:集群部署与高可用策略

个人名片 &#x1f393;作者简介&#xff1a;java领域优质创作者 &#x1f310;个人主页&#xff1a;码农阿豪 &#x1f4de;工作室&#xff1a;新空间代码工作室&#xff08;提供各种软件服务&#xff09; &#x1f48c;个人邮箱&#xff1a;[2435024119qq.com] &#x1f4f1…

代码随想录 | Day29 | 回溯算法:电话号码的字母组合组合总和

代码随想录 | Day29 | 回溯算法&#xff1a;电话号码的字母组合&&组合总和 关于这个章节&#xff0c;大家最好是对递归函数的理解要比较到位&#xff0c;听着b站视频课可能呢才舒服点&#xff0c;可以先去搜一搜关于递归函数的讲解&#xff0c;理解&#xff0c;再开始…

黑神话悟空盘丝洞

《黑神话悟空》第四章盘丝岭地图包含盘丝洞、黄花观等地图&#xff0c;其中包含很多的隐藏要素。下面请看由“oklaoliu13”带来的《黑神话悟空》第四章全收集跑图路线指引&#xff0c;希望对大家有用。 盘丝洞1①兰喜村朱家大院&#xff08;搜刮&#xff09;→②打Boss二姐 &a…

win10服务器启动且未登录时自动启动程序

场景&#xff1a;公司服务器安装了几个程序&#xff0c;当服务器断电重启之后希望程序能自动打开&#xff0c;而不需要手动登录服务器打开。 因为软件是自己开发的所以安全方面这里没有考虑。 1.打开服务器管理器&#xff0c;点击工具&#xff0c;选择任务计划程序 2.在任务计…

OJ在线评测系统 微服务技术入门 单体项目改造为微服务 用Redis改造单机分布式锁登录

单体项目改造为微服务 什么是微服务 服务&#xff1a;提供某类功能的代码 微服务&#xff1a;专注于提供某类特定功能的代码 而不是把所有的代码放到同一个项目里 会把一个大的项目按照一定的功能逻辑进行划分 拆分成多个子模块 每个子模块可以独立运行 独立负责一类功能 …

UDP协议【网络】

文章目录 UDP协议格式 UDP协议格式 16位源端口号&#xff1a;表示数据从哪里来。16位目的端口号&#xff1a;表示数据要到哪里去。16位UDP长度&#xff1a;表示整个数据报&#xff08;UDP首部UDP数据&#xff09;的长度。16位UDP检验和&#xff1a;如果UDP报文的检验和出错&…

代码随想录--字符串--重复的子字符串

题目 给定一个非空的字符串&#xff0c;判断它是否可以由它的一个子串重复多次构成。给定的字符串只含有小写英文字母&#xff0c;并且长度不超过10000。 示例 1: 输入: "abab" 输出: True 解释: 可由子字符串 "ab" 重复两次构成。示例 2: 输入: "…

小米路由器ax1500+DDNS+公网IP+花生壳实现远程访问

有远程办公的需求&#xff0c;以及一些其他东西。 为什么写&#xff1f; ax1500路由器好像没搜到相关信息。以及其中有一点坑。 前置 公网ip Xiaomi路由器 AX1500 MiWiFi 稳定版 1.0.54 实现流程 花生壳申请壳域名https://console.hsk.oray.com/ 这里需要为域名实名认证 …

Sleuth、Zipkin学习

系列文章目录 JavaSE基础知识、数据类型学习万年历项目代码逻辑训练习题代码逻辑训练习题方法、数组学习图书管理系统项目面向对象编程&#xff1a;封装、继承、多态学习封装继承多态习题常用类、包装类、异常处理机制学习集合学习IO流、多线程学习仓库管理系统JavaSE项目员工…

【Qt】控件概述(7)—— 布局管理器

布局管理器 1. 布局管理器2. QVBoxLayout——垂直布局3. QHBoxLayout——水平布局4. QGridLayout——网格布局5. QFormLayout——表单布局6. QSpacer 1. 布局管理器 在我们之前值ui界面进行拖拽设置控件时&#xff0c;都是通过手动的控制控件的位置的。同时每个控件的位置都是…

aws(学习笔记第三课) AWS CloudFormation

aws(学习笔记第三课) 使用AWS CloudFormation 学习内容&#xff1a; AWS CloudFormation的模板解析使用AWS CloudFormation启动ec2 server 1. AWS CloudFormation 的模版解析 CloudFormation模板结构 CloudFormation是AWS的配置管理工具&#xff0c;属于Infrastructure as Co…

鸽笼原理与递归 - 离散数学系列(四)

目录 1. 鸽笼原理 鸽笼原理的定义 鸽笼原理的示例 鸽笼原理的应用 2. 递归的定义与应用 什么是递归&#xff1f; 递归的示例 递归与迭代的对比 3. 实际应用 鸽笼原理的实际应用 递归的实际应用 4. 例题与练习 例题1&#xff1a;鸽笼原理应用 例题2&#xff1a;递归…

Nginx02-安装

零、文章目录 Nginx02-安装 1、Nginx官网 Nginx官网地址&#xff1a;http://nginx.org/ 2、Nginx下载 &#xff08;1&#xff09;Nginx下载 下载页地址&#xff1a;http://nginx.org/en/download.html &#xff08;2&#xff09;更老版本下载 下载页地址&#xff1a;http…