前言

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

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

  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,说明拿锁失败,被其他线程占用,如下是模拟截图:

注意,这里在设置值的时候,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() 方法释放锁,否则不执行。

如下是使用「固定字符串」模拟的问题截图:

两次加锁成功的时间间隔为11秒,不足20秒,显然不是一个进程的用户。

而在 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 文件


文件内容如下:

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() 方法,测试结果如下:

最后总结

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

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

Redis分布式锁—SETNX+Lua脚本实现篇相关推荐

  1. 集群部署中解决定时任务重复执行的问题-redis分布式锁应用

    背景描述 有小伙伴私信我,关于存在定时任务的项目在集群环境下部署如何解决重复执行的问题,PS:定时任务没有单独拆分. 概述:之前的项目都是单机器部署,所以定时任务不会重复消费,只会执行一次.而在集群环 ...

  2. 得物技术浅谈深入浅出的Redis分布式锁

    一.什么是分布式锁 1.1 分布式锁介绍 分布式锁是控制不同系统之间访问共享资源的一种锁实现,如果不同的系统或同一个系统的不同主机之间共享了某个资源时,往往需要互斥来防止彼此干扰来保证一致性. 1.2 ...

  3. Redis分布式锁 Spring Schedule实现任务调度

    一看到标题就知道,这一篇博客又是总结分布式工作环境中集群产生的问题,个人觉得分布式没有那么难以理解,可能也是自己见识比较浅,对我来说,分布式只是一种后端业务演进时的一种工作方式,而真正实现这种工作方式 ...

  4. redis分布式锁解决方案

    一.什么是分布式锁? 线程锁:主要用来给方法.代码块加锁.当某个方法或代码使用锁,在同一时刻仅有一个线程执行该方法或该代码段.线程锁只在同一JVM中有效果,因为线程锁的实现在根本上是依靠线程之间共享内 ...

  5. 说一说redis分布式锁的几种实现及优缺点

    基于Jedis setnx.expire实现分布式锁(存在问题,作为错误示范) 先引入相关依赖(jedis 2.3.0后支持redis集群模式,2.4.2后支持jedisCluster多线程处理,2. ...

  6. Redis教程--redis分布式锁+企业解决方案+redis实战

    Redis,目前全国甚至是全球最常用的缓存中间件之一,在现在公司的开发中,可以说是离不开Redis. 在企业越来越注重用户体验的今天,Redis因具有高性能.高响应的特性,大大提升应用的响应速度和用户 ...

  7. 使用redis分布式锁+lua脚本实现分布式定时任务控制demo

    2019独角兽企业重金招聘Python工程师标准>>> 分布式系统经常要遇到定时任务执行的问题,不能重复执行,但很多时候又不能统一到一个微服务里面,因为这样就失去了微服务的意义.由于 ...

  8. 深度剖析:Redis分布式锁到底安全吗?看完这篇文章彻底懂了!

    ‍‍‍‍‍‍‍‍‍‍‍‍阅读本文大约需要 20 分钟. 大家好,我是 Kaito. 这篇文章我想和你聊一聊,关于 Redis 分布式锁的「安全性」问题. Redis 分布式锁的话题,很多文章已经写烂了 ...

  9. redis setnx 分布式锁_手写Redis分布式锁

    分布式锁使用场景 现在的系统都是集群部署,每个服务都不是单节点的了.比如库存服务,可能部署到3台机器上分别命名为节点1,节点2,节点3.库存服务需要扣减库存,扣减库存肯定需要锁吧,如果使用Lock或者 ...

最新文章

  1. 《自然语言处理中的因果推理》综述论文,以色列理工谷歌等13位NLP大牛阐述因果推理NLP的估计、预测、解释和超越...
  2. 链路负载均衡: 高性能和高安全的同时实现
  3. rust核电分解机在哪里_腐蚀RUST个工厂地点位置介绍 腐蚀如何找到相应的工厂
  4. oracle将查询结果声明为伪表,Oracle的伪列和伪表
  5. 用python写helloworld_Python基于Tkinter的HelloWorld入门实例
  6. CorelDRAW哪个版本好用实用强x4/X8/2019/2020/2021
  7. 微软商店常见的几个问题
  8. 浅析EDA技术应用于电子设计竞赛的可行性
  9. recy拖动item
  10. Docker Secrets
  11. Python:PrettyTable格式化输出数据
  12. android3d画廊自动切换,Android实例(一)—— 3D画廊
  13. 赵小楼《天道》《遥远的救世主》问答集(5)每一条评论都是一个独立意识所展现的相,在这是用文字问答的形式呈现出来的相
  14. 使用Android 虚拟机运行
  15. window cmd 创建文本文件
  16. 电脑英寸,怎么看自己显示器尺寸_怎么查看电脑屏幕多少英寸
  17. 一文带你深入理解【Java基础】· 枚举类
  18. C++迭代器之'反向迭代器'
  19. python跳到微软商城解决 cocos 所有指令没有反应解决方法
  20. unity3d新手笔记

热门文章

  1. [jQuery] 你有写过jQuery的扩展吗?都有哪些写法?
  2. [css] 使用css实现蒙版的效果
  3. 工作78:获取本地数据
  4. 前端学习(1845)vue之电商管理系统项目实战的学习目标
  5. 前端学习(1683):前端系列实战课程之让蛇吃食物变长
  6. spring mvc学习(24):配置maven环境和创建maven项目(建议收藏,超全超详细)
  7. spring mvc学习(5):IDEA修改背景颜色大全
  8. 计算机操作系统(2):OS的发展过程
  9. 树莓派使用STEP1:装系统
  10. [机器学习笔记]Note15--大规模机器学习