Redis分布式锁—SETNX+Lua脚本实现篇

前言

平时的工作中,由于生产环境中的项目是需要部署在多台服务器中的,所以经常会面临解决分布式场景下数据一致性的问题,那么就需要引入分布式锁来解决这一问题。

针对分布式锁的实现,目前比较常用的就如下几种方案:

  1. 基于数据库实现分布式锁
  2. 基于Redis实现分布式锁 【本文】
  3. 基于Zookeeper实现分布式锁

接下来这个系列文章会跟大家一块探讨这三种方案,本篇为Redis实现分布式锁篇。

Redis分布式环境搭建推荐:基于Docker的Redis集群搭建

Redis分布式锁一览

说到 redis 锁,能搜到的,或者说常用的无非就下面这两个:

  • setNX + Lua脚本 【本文】
  • redisson + RLock可重入锁

接下来我们一一探索这两个的实现,本文为 setNX + Lua脚本 实现篇。

1、setNX

完整语法:SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]

必选参数说明:

  • SET:命令
  • key:待设置的key
  • value:设置的key的value,最好为随机字符串

可选参数说明:

  • NX:表示key不存在时才设置,如果存在则返回 null

  • XX:表示key存在时才设置,如果不存在则返回NULL

  • PX millseconds:设置过期时间,过期时间精确为毫秒

  • EX seconds:设置过期时间,过期时间精确为秒

注意:其实我们常说的通过 Redis 的 setnx 命令来实现分布式锁,并不是直接使用 Redis 的 setnx 命令,因为在老版本之前 setnx 命令语法为「setnx key value」,并不支持同时设置过期时间的操作,那么就需要再执行 expire 过期时间的命令,这样的话加锁就成了两个命令,原子性就得不到保障,所以通常需要配合 Lua 脚本使用,而从 Redis 2.6.12 版本后,set 命令开始整合了 setex 的功能,并且 set 本身就已经包含了设置过期时间,因此常说的 setnx 命令实则只用 set 命令就可以实现了,只是参数上加上了 NX 等参数。

大致说一下用 setnx 命令实现分布式锁的流程:

在 Redis 2.6.12 版本之后,Redis 支持原子命令加锁,我们可以通过向 Redis 发送 「set key value NX 过期时间」 命令,实现原子的加锁操作。比如某个客户端想要获取一个 key 为 niceyoo 的锁,此时需要执行 「set niceyoo random_value NX PX 30000」 ,在这我们设置了 30 秒的锁自动过期时间,超过 30 秒自动释放。

如果 setnx 命令返回 ok,说明拿到了锁,此时我们就可以做一些业务逻辑处理,业务处理完之后,需要释放锁,释放锁一般就是执行 Redis 的 del 删除指令,「del niceyoo」

如果 setnx 命令返回 nil,说明拿锁失败,被其他线程占用,如下是模拟截图:
image-20200920003133502
注意,这里在设置值的时候,value 应该是随机字符串,比如 UUID,而不是随便用一个固定的字符串进去,为什么这样做呢?

value 的值设置为随机数主要是为了更安全的释放锁,释放锁的时候需要检查 key 是否存在,且 key 对应的 value 值是否和指定的值一样,是一样的才能释放锁。

感觉这样说还是不清晰,举个例子:例如进程 A,通过 setnx 指令获取锁成功(命令中设置了加锁自动过期时间30 秒),既然拿到锁了就开始执行业务吧,但是进程 A 在接下来的执行业务逻辑期间,程序响应时间竟然超过30秒了,锁自动释放了,而此时进程 B 进来了,由于进程 A 设置的过期时间一到,让进程 B 拿到锁了,然后进程 B 又开始执行业务逻辑,但是呢,这时候进程 A 突然又回来了,然后把进程 B 的锁得释放了,然后进程 C 又拿到锁,然后开始执行业务逻辑,此时进程 B 又回来了,释放了进程 C 的锁,套娃开始了…

总之,有了随机数的 value 后,可以通过判断 key 对应的 value 值是否和指定的值一样,是一样的才能释放锁。

接下来我们把 setnx 命令落地到项目实例中:

代码环境:SpringBoot2.2.2.RELEASE + spring-boot-starter-data-redis + StringRedisTemplate

StringRedisTemplate 或者 RedisTemplate 下对应的 setnx 指令的 API 方法如下:

/*** Set {@code key} to hold the string {@code value} if {@code key} is absent.** @param key must not be {@literal null}.* @param value* @see <a href="http://redis.io/commands/setnx">Redis Documentation: SETNX</a>*/
Boolean setIfAbsent(K key, V value);

这个地方再补充一下,使用 jedis 跟使用 StringRedisTemplate 对应的 senx 命令的写法是有区别的,jedis 下就是 set 方法,而 StringRedisTemplate 下使用的是 setIfAbsent 方法 。

1)Maven 依赖,pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.2.2.RELEASE</version><relativePath/> </parent><groupId>com.example</groupId><artifactId>demo-redis</artifactId><version>0.0.1-SNAPSHOT</version><name>demo-redis</name><description>Demo project for Spring Boot</description><properties><java.version>1.8</java.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- Redis--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><!-- Lombok --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.10</version></dependency><!-- Gson --><dependency><groupId>com.google.code.gson</groupId><artifactId>gson</artifactId><version>2.8.6</version></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>

这里引入了 gson、redis 依赖。

2)application.yml 配置文件
server:port: 6666servlet:context-path: /spring:redis:host: 127.0.0.1password:# 数据库索引 默认0database: 0port: 6379# 超时时间 Duration类型 3秒timeout: 3S# 日志
logging:# 输出级别level:root: infofile:# 指定路径path: redis-logs# 最大保存天数max-history: 7# 每个文件最大大小max-size: 5MB

这里设置的服务端口为 6666,大家可以根据自己环境修改。

3)测试的 Controller
@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {@Resourceprivate RedisTemplate<String,Object> redisTemplate;@PostMapping(value = "/addUser")public String createOrder(User user) {String key = user.getUsername();// 如下为使用UUID、固定字符串,固定字符串容易出现线程不安全String value = UUID.randomUUID().toString().replace("-","");// String value = "123";/** setIfAbsent <=> SET key value [NX] [XX] [EX <seconds>] [PX [millseconds]]* set expire time 5 mins*/Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value, 20000, TimeUnit.MILLISECONDS);if (flag) {log.info("{} 锁定成功,开始处理业务", key);try {// 模拟处理业务逻辑Thread.sleep(1000 * 30);} catch (InterruptedException e) {e.printStackTrace();}// 判断是否是key对应的valueString lockValue = redisTemplate.opsForValue().get(key);if (lockValue != null && lockValue.equals(value)) {redisTemplate.delete(key);log.info("{} 解锁成功,结束处理业务", key);}return "SUCCESS";} else {log.info("{} 获取锁失败", key);return "请稍后再试...";}}}

大致流程就是,通过 RedisTemplate 的 setIfAbsent() 方法获取原子锁,并设置了锁自动过期时间为 20秒,setIfAbsent() 方法返回 true,表示加锁成功,加锁成功后模拟了一段业务逻辑处理,耗时30秒,执行完逻辑之后调用 delete() 方法释放锁。

问题来了,由于锁自动过期时间为 20秒,而业务逻辑耗时为 30秒,在不使用 random_value(随机字符串)下,如果有多进程操作的话就会出现前面提到的套娃骚操作…

所以在删除锁之前,我们先再次通过 get 命令获取加锁 key 的 value 值,然后判断 value 跟加锁时设置的 value 是否一致,这就看出 UUID 的重要性了,如果一致,就执行 delete() 方法释放锁,否则不执行。

如下是使用「固定字符串」模拟的问题截图:
image-20200920011841816
两次加锁成功的时间间隔为11秒,不足20秒,显然不是一个进程的用户。
image-20200920012013118
而在 value 使用 UUID 随机字符串时没有出现上述问题。

但随机字符串就真的安全了吗?

不安全…

因为还是无法保证 redisTemplate.delete(key); 的原子操作,在多进程下还是会有进程安全问题。

就有小伙伴可能钻牛角尖,怎么就不能原子性操作了,你在删除之前不都已经判断了吗?

再举个例子,比如进程 A 执行完业务逻辑,在 redisTemplate.opsForValue().get(key); 获得 key 这一步执行没问题,同时也进入了 if 判断中,但是恰好这时候进程 A 的锁自动过期时间到了(别问为啥,就是这么巧),而另一个进程 B 获得锁成功,然后还没来得及执行,进程 A 就执行了 delete(key) ,释放了进程 B 的锁…

我操?那你上边巴拉巴拉那么多,说啥呢?

咳咳,解锁正确删除锁的方式之一:为了保障原子性,我们需要用 Lua 脚本进行完美解锁。

Lua脚本

可能有小伙伴不熟悉 Lua,先简单介绍一下 Lua 脚本:

Lua 是一种轻量小巧的脚本语言,用标准 C 语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

Lua 提供了交互式编程模式。我们可以在命令行中输入程序并立即查看效果。

lua脚本优点:

  • 减少网络开销:原先多次请求的逻辑放在 redis 服务器上完成。使用脚本,减少了网络往返时延
  • 原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入(想象为事务)
  • 复用:客户端发送的脚本会永久存储在Redis中,意味着其他客户端可以复用这一脚本而不需要使用代码完成同样的逻辑

先大致了解一下,后面我会单独写一篇 Lua 从入门到放弃的文章。。

如下是Lua脚本,通过 Redis 的 eval/evalsha 命令来运行:

-- lua删除锁:
-- KEYS和ARGV分别是以集合方式传入的参数,对应上文的Test和uuid。
-- 如果对应的value等于传入的uuid。
if redis.call('get', KEYS[1]) == ARGV[1] then -- 执行删除操作return redis.call('del', KEYS[1]) else -- 不成功,返回0return 0 
end

好了,看到 Lua 脚本了,然后代码中如何使用?

为了让大家更清楚,我们在 SpringBoot 中使用这个 Lua 脚本
1)在 resources 文件下创建 niceyoo.lua 文件

image-20200920123914568
文件内容如下:

if redis.call('get', KEYS[1]) == ARGV[1]thenreturn redis.call('del', KEYS[1])elsereturn 0
end
2)修改 TestController

在 SpringBoot中,是使用 DefaultRedisScript 类来加载脚本的,并设置相应的数据类型来接收 Lua 脚本返回的数据,这个泛型类在使用时设置泛型是什么类型,脚本返回的结果就是用什么类型接收。

@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {@Resourceprivate RedisTemplate<String,Object> redisTemplate;private DefaultRedisScript<Long> script;@PostConstructpublic void init(){script = new DefaultRedisScript<Long>();script.setResultType(Long.class);script.setScriptSource(new ResourceScriptSource(new ClassPathResource("niceyoo.lua")));}@PostMapping(value = "/addUser")public String createOrder(User user) {String key = user.getUsername();String value = UUID.randomUUID().toString().replace("-","");/** setIfAbsent <=> SET key value [NX] [XX] [EX <seconds>] [PX [millseconds]]* set expire time 5 mins*/Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value, 20000, TimeUnit.MILLISECONDS);if (flag) {log.info("{} 锁定成功,开始处理业务", key);try {// 模拟处理业务逻辑Thread.sleep(1000 * 10);} catch (InterruptedException e) {e.printStackTrace();}// 业务逻辑处理完毕,释放锁String lockValue = redisTemplate.opsForValue().get(key).toString();if (lockValue != null && lockValue.equals(value)) {System.out.println("lockValue========:" + lockValue);List<String> keys = new ArrayList<>();keys.add(key);Long execute = redisTemplate.execute(script, keys, lockValue);System.out.println("execute执行结果,1表示执行del,0表示未执行 ===== " + execute);log.info("{} 解锁成功,结束处理业务", key);}return "SUCCESS";} else {log.info("{} 获取锁失败", key);return "请稍后再试...";}}}
3)测试结果

Lua 脚本替换 RedisTemplate 执行 delete() 方法,测试结果如下:
image-20200920124635965

最后总结

1、所谓的 setnx 命令来实现分布式锁,其实不是直接使用 Redis 的 setnx 命令,因为 setnx 不支持设置自动释放锁的时间(至于为什么要设置自动释放锁,是因为防止被某个进程不释放锁而造成死锁的情况),不支持设置过期时间,就得分两步命令进行操作,一步是 setnx key value,一步是设置过期时间,这种情况的弊端很显然,无原子性操作。

2、 Redis 2.6.12 版本后,set 命令开始整合了 setex 的功能,并且 set 本身就已经包含了设置过期时间,因此常说的 setnx 命令实则只用 set 命令就可以实现了,只是参数上加上了 NX 等参数。

3、经过分析,在使用 set key value nx px xxx 命令时,value 最好是随机字符串,这样可以防止业务代码执行时间超过设置的锁自动过期时间,而导致再次释放锁时出现释放其他进程锁的情况(套娃)

4、尽管使用随机字符串的 value,但是在释放锁时(delete方法),还是无法做到原子操作,比如进程 A 执行完业务逻辑,在准备释放锁时,恰好这时候进程 A 的锁自动过期时间到了,而另一个进程 B 获得锁成功,然后 B 还没来得及执行,进程 A 就执行了 delete(key) ,释放了进程 B 的锁… ,因此需要配合 Lua 脚本释放锁,文章也给出了 SpringBoot 的使用示例。

至此,带大家一块查看了 setnx 命令如何实现分布式锁,但是下面还是要泼一下冷水…

经过测试,在单机 Redis 模式下,这种分布式锁,简直是无敌(求生欲:纯个人看法),咳咳,没错,你没看错,单机下的 Redis 无敌…

所以在那些主从模式、哨兵模式、或者是 cluster 模式下,可能会出现问题,出现什么问题呢?

setNX 的缺陷

setnx 琐最大的缺点就是它加锁时只作用在一个 Redis 节点上,即使 Redis 通过 Sentinel(哨岗、哨兵) 保证高可用,如果这个 master 节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况,下面是个例子:

  1. 在 Redis 的 master 节点上拿到了锁;
  2. 但是这个加锁的 key 还没有同步到 slave 节点;
  3. master 故障,发生故障转移,slave 节点升级为 master节点;
  4. 上边 master 节点上的锁丢失。

有的时候甚至不单单是锁丢失这么简单,新选出来的 master 节点可以重新获取同样的锁,出现一把锁被拿两次的场景。

锁被拿两次,也就不能满足安全性了…

尽管单机 Redis 下并不会出现如上问题,但毕竟我们在生产环境中,一般都是采用的集群模式,所以这本身也是 Redis 分布式锁的诟病。

缺陷看完了,怎么解决嘛~

然后 Redis 的作者就提出了著名远洋的 RedLock 算法…

下节讲。


在写这篇文章过程中,本来计划将 Redis 里的 setnx、redisson、redLock 一块写出来发一篇文章;

但由于文章中贴了一些代码片段,会让文章整体的节奏偏长,不适用于后面自己的复习,所以拆分成两篇文章,

下一篇我们一块探索 Redisson + RedLock 的分布式锁的实现。

2、Redisson + RedLock

跳转链接:https://www.cnblogs.com/niceyoo/p/13736140.html

博客园持续更新,订阅关注,未来,我们一起成长。

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

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

相关文章

Windows10远程报错:由于CredSSP加密Oracle修正

https://support.microsoft.com/zh-cn/help/4093492/credssp-updates-for-cve-2018-0886-march-13-2018 参照官方更新文件&#xff1a;查找办法 https://support.microsoft.com/zh-cn/help/4093492/&#xff0c;4093492是更新包kb后面的数字 修改办法&#xff1a;下图参照注册表…

Redis分布式锁—Redisson+RLock可重入锁实现篇

前言 平时的工作中&#xff0c;由于生产环境中的项目是需要部署在多台服务器中的&#xff0c;所以经常会面临解决分布式场景下数据一致性的问题&#xff0c;那么就需要引入分布式锁来解决这一问题。 针对分布式锁的实现&#xff0c;目前比较常用的就如下几种方案&#xff1a;…

angular安装记录

1. 安装node.js&#xff0c;下载地址&#xff1a;https://nodejs.org/en/download/&#xff0c;详细的安装教程参考这里&#xff1a;https://blog.csdn.net/u010255310/article/details/52205132 直接一路next就可以。安装好node后&#xff0c;会自动在path中配置了node的安装路…

Docker (1) 基本概念和安装

Docker简介 什么是容器&#xff1f; 一种虚拟化的方案&#xff0c;操作系统级别的虚拟化。容器是一个轻量的、独立的、可执行的包&#xff0c;包含了执行它所需要的所有东西&#xff1a;代码、运行环境、系统工具、系统库、设置。很长一段时间中&#xff0c;容器是专门用于Linu…

vue获取浏览器地址栏参数(?及/)路由+非路由实现方式

1、? 参数 浏览器参数形式&#xff1a;http://javam4.com/m4detail?id1322914793170014208 1.1、路由取参方式 this.$route.query.id前端跳转方式&#xff1a; 一、onclick方式 <a title"测试数据"click"test(row.id)"target"_blank"&g…

python3.X 使用pip 离线安装whl包(转载)

转载https://blog.csdn.net/wangyaninglm/article/details/54177720 0. 绪论 断网的环境下配置python开发环境非常讨厌&#xff0c;本文旨在优雅暴力的解决这一问题。 生产环境 &#xff1a; windows 7 windows10 python 3.5.2 pip 1.5.2 友情提示&#xff1a;出现问题时候&…

PHP文件下载

步骤 //流的方式发送给浏览器 header("Content-Type: application/octet-stream"); //按照字节的返回给浏览器 header("Accept-Ranges: bytes"); //告诉浏览器文件的大小 header("Accept-Length: ".filesize(文件地址)); //以附件的形式发送给浏…

面试官:了解二叉树吗,平衡二叉树,红黑树?

前言 面试过程中&#xff0c;多多少少会问一点数据结构&#xff08;二叉树&#xff09;的问题&#xff0c;今天我们来复习一下二叉树的相关问题&#xff0c;文末总结。 1. 二叉树的由来 在 jdk1.8 之前&#xff0c;HashMap 的数据结构由「数组链表」组成&#xff0c;数组是 …

Jenkins: QQ/Wechat机器人群消息通知Job构建结果

简介 Jenkins是持续化集成的一个核心部件&#xff0c;它上游从仓库&#xff08;gitlab&#xff09;等拉取代码&#xff0c;经编译构建&#xff0c;将应用发布至下游目标环境&#xff1b;构建结果通知的方式有很多&#xff0c;现成的插件有邮件和钉钉方式&#xff0c;但是就方便…

Java中的Set对象去重

前言部分 Set<T> 去重相信大家一定不陌生&#xff0c;尤其是在 Set<String>、Set<Integer> 等等&#xff0c;但是在使用 Set<实体> &#xff0c;在不重写 equals()、hashCode() 方法情况下&#xff0c;直接使用貌似并不能生效。 所以想要 Set<实体…

openfalcon架构及相关服务配置详解

一&#xff1a;openfalcon组件 1.falcon-agent 数据采集组件 agent内置了一个http接口&#xff0c;会自动采集预先定义的各种采集项&#xff0c;每隔60秒&#xff0c;push到transfer。 2.transfer agent与transfer建立长连接&#xff0c;将数据汇报给tarnsfer transfer默认监听…

DBeaver连接达梦|虚谷|人大金仓等国产数据库

前言 工作中有些项目可能会接触到「达梦、虚谷、人大金仓」等国产数据库&#xff0c;但通常这些数据库自带的连接工具使用并不方便&#xff0c;所以这篇文章记录一下 DBeaver 连接国产数据库的通用模版&#xff0c;下文以达梦为例&#xff08;其他国产数据库连接操作方式一样&…

Map<String,Object>接收参数,Long类型降级为Integer,报类型转换异常

前言 今天看群里小伙伴问了一个非常有意思的问题&#xff1a; 使用 Map<String,Object> 对象接收前端传递的参数&#xff0c;在后端取参时&#xff0c;因为接口文档中明确该字段类型为 Long &#xff0c;所以对接收的参数进行了强转&#xff0c;即 (Long)参数 &#xf…

adb无法连接安卓手机

确保已安装好手机驱动&#xff08;在设备管理器中能找到安卓的设备&#xff09;查看设备的VID信息找到你的模拟器存放的目录&#xff0c;<例如&#xff1a;C:\Users\Administrator\.android 下找到或新建一个adb_usb.ini文件。文档内容写入VID即可 在cmd上输入adb kill-serv…

SpringBoot中的Tomcat是如何启动的

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId> </dependency>添加如上 Web 的依赖&#xff0c;Spring Boot 就帮我们内置了 Servlet 容器&#xff0c;默认使用的是 Tomcat&a…

IDEA社区版(Community)和付费版(UItimate)的区别

比对类型Ultimate(终极版,付费)Community(社区版,免费)语言支持JavaJavaGroovyGroovyKotlinKotlinScala&#xff08;通过插件&#xff09;Scala&#xff08;通过插件&#xff09;Python 和 Jython&#xff08;通过插件&#xff09;Python 和 Jython&#xff08;通过插件&#x…

从使用传统Web框架到切换到Spring Boot后的总结

1、前言 其实我接触 Spring Boot 的时间并不长&#xff0c;所以还算一个初学者&#xff0c;这篇文章也算是我对 Spring Boot 学习以及使用过程中的复盘&#xff0c;如果文章出现描述错误或表达不清晰的地方&#xff0c;欢迎大家在评论区留言互动。 没想到 Spring Boot 这两年…

前端学习(2859):简单秒杀系统学习之前端界面布局

<html><head><meta charset"utf-8"><link rel"stylesheet" type"text/css" href"miao.min.css" charset"utf-8"><title>秒杀系统</title></head><body><div class"…

Spring Boot 项目瘦身指南,瘦到不可思议!129M->1.3M

之前在 从使用传统Web框架到切换到Spring Boot后的总结 中提到关于 Spring Boot 编译打包&#xff0c;Spring Boot 应用程序不用额外部署到外部容器中&#xff0c;可以直接通过 Maven 命令将项目编译成可执行的 jar 包&#xff0c;然后通过 java -jar 命令启动即可&#xff0c;…