「每天一个知识点」如何正确实现Redis分布式锁
mhr18 2024-10-22 12:36 19 浏览 0 评论
点击上方"java全栈技术"关注,每天学习一个java知识点
分布式锁的实现方式一般有三种:
- 数据库乐观锁
- 基于Redis的分布式锁
- 基于ZooKeeper的分布式锁
本篇将介绍第二种方式,基于Redis实现分布式锁,试图提供一个使用Redis实现分布式锁的规范算法。
redis命令:SETNX key value
将 key 的值设为 value ,当且仅当 key 不存在。若给定的 key 已经存在,则SETNX不做任何动作。
SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。
时间复杂度:O(1)。返回值:设置成功,返回 1 。设置失败,返回 0 。
安全和活性失效保障
为了确保分布式锁可用,至少要确保锁的实现满足以下三个特性:
- 安全属性(Safety property): 独享(相互排斥)。在任意一个时刻,只有一个客户端持有锁。
- 活性A(Liveness property A): 无死锁。即便持有锁的客户端崩溃(crashed)或者网络被分裂(gets partitioned),锁仍然可以被获取。
- 活性B(Liveness property B): 容错。 只要大部分Redis节点都活着,客户端就可以获取和释放锁.
为什么基于故障转移的实现还不够
为了更好的理解可以改进的方面,我们先分析一下当前大多数基于Redis的分布式锁现状和实现方法.
实现Redis分布式锁的最简单的方法就是在Redis中创建一个key,这个key有一个失效时间(TTL),以保证锁最终会被自动释放掉(这个对应特性2)。当客户端释放资源(解锁)的时候,会删除掉这个key。
从表面上看,似乎效果还不错,但是这里有一个问题:这个架构中存在一个严重的单点失败问题。如果Redis挂了怎么办?你可能会说,可以通过增加一个slave节点解决这个问题。但这通常是行不通的。这样做,我们不能实现资源的独享,因为Redis的主从同步通常是异步的。
在这种场景(主从结构)中存在明显的竞态:
- 客户端A从master获取到锁
- 在master将锁同步到slave之前,master宕掉了。
- slave节点被晋级为master节点
- 客户端B取得了同一个资源被客户端A已经获取到的另外一个锁。安全失效!
有时候程序就是这么巧,比如说正好一个节点挂掉的时候,多个客户端同时取到了锁。如果你可以接受这种小概率错误,那用这个基于复制的方案就完全没有问题。否则的话,我们建议你实现下面描述的解决方案。
单Redis实例实现分布式锁的正确方法
在尝试克服上述单实例设置的限制之前,让我们先讨论一下在这种简单情况下实现分布式锁的正确做法,实际上这是一种可行的方案,尽管存在竞态,结果仍然是可接受的,另外,这里讨论的单实例加锁方法也是分布式加锁算法的基础。
获取锁使用命令:
SET resource_name my_random_value NX PX 30000
这个命令仅在不存在key的时候才能被执行成功(NX选项),并且这个key有一个30秒的自动失效时间(PX属性)。这个key的值是“my_random_value”(一个随机值),这个值在所有的客户端必须是唯一的,所有同一key的获取者(竞争者)这个值都不能一样。value的值必须是随机数主要是为了更安全的释放锁(java中可以使用UUID.randomUUID().toString()方法生成)。
对应java实现:
释放锁的时候使用脚本告诉Redis:只有key存在并且存储的值和我指定的值一样才能告诉我删除成功。
通过以下Lua脚本实现:
使用这种方式释放锁可以避免删除别的客户端获取成功的锁。
举个例子:客户端A取得资源锁,但是紧接着被一个其他操作阻塞了,当客户端A运行完毕其他操作后要释放锁时,原来的锁早已超时并且被Redis自动释放,并且在这期间资源锁又被客户端B再次获取到。如果仅使用DEL命令将key删除,那么这种情况就会把客户端B的锁给删除掉。使用Lua脚本就不会存在这种情况,因为脚本仅会删除value等于客户端A的value的key(value相当于客户端的一个签名)。
key的失效时间,被称作“锁定有效期”。它不仅是key自动失效时间,而且还是一个客户端持有锁多长时间后可以被另外一个客户端重新获得。
对应java实现:
第二行代码,我们将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。
为什么要使用Lua语言来实现。因为要确保上述操作是原子性的。
那么为什么执行eval()方法可以确保原子性。源于Redis的特性,下面是官网对eval命令的部分解释:
简单来说,就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。
截至到目前,我们已经有较好的方法获取锁和释放锁。基于Redis单实例,假设这个单实例总是可用,这种方法已经足够安全。
现在让我们扩展一下,假设Redis没有总是可用的保障。
Redlock算法
在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。之前我们已经描述了在Redis单实例下怎么安全地获取和释放锁。我们确保将在每(N)个实例上使用此方法获取和释放锁。在这个样例中,我们假设有5个Redis master节点,这是一个比较合理的设置,所以我们需要在5台机器上面或者5台虚拟机上面运行这些实例,这样保证他们不会同时都宕掉。
为了取到锁,客户端应该执行以下操作:
- 获取当前Unix时间,以毫秒为单位。
- 依次尝试从N个实例,使用相同的key和随机值获取锁。在步骤2,当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例。
- 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
- 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)。
这个算法是异步的么?
算法基于这样一个假设:虽然多个进程之间没有时钟同步,但每个进程都以相同的时钟频率前进,时间差相对于失效时间来说几乎可以忽略不计。这种假设和我们的真实世界非常接近:每个计算机都有一个本地时钟,我们可以容忍多个计算机之间有较小的时钟漂移。
从这点来说,我们必须再次强调我们的互相排斥规则:只有在锁的有效时间(在步骤3计算的结果)范围内客户端能够做完它的工作,锁的安全性才能得到保证(锁的实际有效时间通常要比设置的短,因为计算机之间有时钟漂移的现象)。.
失败时重试
当客户端无法取到锁时,应该在一个随机延迟后重试,防止多个客户端在同时抢夺同一资源的锁(这样会导致脑裂,没有人会取到锁)。同样,客户端取得大部分Redis实例锁所花费的时间越短,脑裂出现的概率就会越低(必要的重试),所以,理想情况一下,客户端应该同时(并发地)向所有Redis发送SET命令。
需要强调,当客户端从大多数Redis实例获取锁失败时,应该尽快地释放(部分)已经成功取到的锁,这样其他的客户端就不必非得等到锁过完“有效时间”才能取到(然而,如果已经存在网络分裂,客户端已经无法和Redis实例通信,此时就只能等待key的自动释放了,等于被惩罚了)。
释放锁
释放锁比较简单,向所有的Redis实例发送释放锁命令即可,不用关心之前有没有从Redis实例成功获取到锁.
安全争议
这个算法安全么?我们可以从不同的场景讨论一下。
让我们假设客户端从大多数Redis实例取到了锁。所有的实例都包含同样的key,并且key的有效时间也一样。然而,key肯定是在不同的时间被设置上的,所以key的失效时间也不是精确的相同。我们假设第一个设置的key时间是T1(开始向第一个server发送命令前时间),最后一个设置的key时间是T2(得到最后一台server的答复后的时间),我们可以确认,第一个server的key至少会存活 MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT。所有其他的key的存活时间,都会比这个key时间晚,所以可以肯定,所有key的失效时间至少是MIN_VALIDITY。
当大部分实例的key被设置后,其他的客户端将不能再取到锁,因为至少N/2+1个实例已经存在key。所以,如果一个锁被(客户端)获取后,客户端自己也不能再次申请到锁(违反互相排斥属性)。
然而我们也想确保,当多个客户端同时抢夺一个锁时不能两个都成功。
如果客户端在获取到大多数redis实例锁,使用的时间接近或者已经大于失效时间,客户端将认为锁是失效的锁,并且将释放掉已经获取到的锁,所以我们只需要在有效时间范围内获取到大部分锁这种情况。在上面已经讨论过有争议的地方,在MIN_VALIDITY时间内,将没有客户端再次取得锁。所以只有一种情况,多个客户端会在相同时间取得N/2+1实例的锁,那就是取得锁的时间大于失效时间(TTL time),这样取到的锁也是无效的.
如果你能提供关于现有的类似算法的一个正式证明(指出正确性),或者是发现这个算法的bug? 我们将非常感激.
活性争议
系统的活性安全基于三个主要特性:
- 锁的自动释放(因为key失效了):最终锁可以再次被使用。
- 客户端通常会将没有获取到的锁删除,或者锁被取到后,使用完后,客户端会主动(提前)释放锁,而不是等到锁失效另外的客户端才能取到锁。
- 当客户端重试获取锁时,需要等待一段时间,这个时间必须大于从大多数Redis实例成功获取锁使用的时间,以最大限度地避免脑裂。
然而,当网络出现问题时系统在失效时间(TTL)内就无法服务,这种情况下我们的程序就会为此付出代价。如果网络持续的有问题,可能就会出现死循环了。 这种情况发生在当客户端刚取到一个锁还没有来得及释放锁就被网络隔离.
如果网络一直没有恢复,这个算法会导致系统不可用.
性能,崩溃恢复和Redis同步
很多用户把Redis当做分布式锁服务器,使用获取锁和释放锁的响应时间,每秒钟可用执行多少次 acquire / release 操作作为性能指标。为了达到这一要求,增加Redis实例当然可用降低响应延迟(没有钱买硬件的”穷人”,也可以在网络方面做优化,使用非阻塞模型,一次发送所有的命令,然后异步的读取响应结果,假设客户端和redis服务器之间的RTT都差不多。
然而,如果我们想使用可以从备份中恢复的redis模式,有另外一种持久化情况你需要考虑,.
我们考虑这样一种场景,假设我们的redis没用使用备份。一个客户端获取到了3个实例的锁。此时,其中一个已经被客户端取到锁的redis实例被重启,在这个时间点,就可能出现3个节点没有设置锁,此时如果有另外一个客户端来设置锁,锁就可能被再次获取到,这样锁的互相排斥的特性就被破坏掉了。
如果我们启用了AOF持久化,情况会好很多。我们可用使用SHUTDOWN命令关闭然后再次重启。因为Redis到期是语义上实现的,所以当服务器关闭时,实际上还是经过了时间,所有(保持锁)需要的条件都没有受到影响. 没有受到影响的前提是redis优雅的关闭。停电了怎么办?如果redis是每秒执行一次fsync,那么很有可能在redis重启之后,key已经丢弃。理论上,如果我们想在Redis重启地任何情况下都保证锁的安全,我们必须开启fsync=always的配置。这反过来将完全破坏与传统上用于以安全的方式实现分布式锁的同一级别的CP系统的性能.
然而情况总比一开始想象的好一些。当一个redis节点重启后,只要它不参与到任意当前活动的锁,没有被当做“当前存活”节点被客户端重新获取到,算法的安全性仍然是有保障的。
为了达到这种效果,我们只需要将新的redis实例,在一个TTL时间内,对客户端不可用即可,在这个时间内,所有客户端锁将被失效或者自动释放.
使用延迟重启可以在不采用持久化策略的情况下达到同样的安全,然而这样做有时会让系统转化为彻底不可用。比如大部分的redis实例都崩溃了,系统在TTL时间内任何锁都将无法加锁成功。
使算法更加可靠:锁的扩展
如果你的工作可以拆分为许多小步骤,可以将有效时间设置的小一些,使用锁的一些扩展机制。在工作进行的过程中,当发现锁剩下的有效时间很短时,可以再次向redis的所有实例发送一个Lua脚本,让key的有效时间延长一点(前提还是key存在并且value是之前设置的value)。
客户端扩展TTL时必须像首次取得锁一样在大多数实例上扩展成功才算再次取到锁,并且是在有效时间内再次取到锁(算法和获取锁是非常相似的)。
这样做从技术上将并不会改变算法的正确性,所以扩展锁的过程中仍然需要达到获取到N/2+1个实例这个要求,否则活性特性之一就会失效。
相关推荐
- 使用 Docker 部署 Java 项目(通俗易懂)
-
前言:搜索镜像的网站(推荐):DockerDocs1、下载与配置Docker1.1docker下载(这里使用的是Ubuntu,Centos命令可能有不同)以下命令,默认不是root用户操作,...
- Spring Boot 3.3.5 + CRaC:从冷启动到秒级响应的架构实践与踩坑实录
-
去年,我们团队负责的电商订单系统因扩容需求需在10分钟内启动200个Pod实例。当运维组按下扩容按钮时,传统SpringBoot应用的冷启动耗时(平均8.7秒)直接导致流量洪峰期出现30%的请求超时...
- 《github精选系列》——SpringBoot 全家桶
-
1简单总结1SpringBoot全家桶简介2项目简介3子项目列表4环境5运行6后续计划7问题反馈gitee地址:https://gitee.com/yidao620/springbo...
- Nacos简介—1.Nacos使用简介
-
大纲1.Nacos的在服务注册中心+配置中心中的应用2.Nacos2.x最新版本下载与目录结构3.Nacos2.x的数据库存储与日志存储4.Nacos2.x服务端的startup.sh启动脚...
- spring-ai ollama小试牛刀
-
序本文主要展示下spring-aiollama的使用示例pom.xml<dependency><groupId>org.springframework.ai<...
- SpringCloud系列——10Spring Cloud Gateway网关
-
学习目标Gateway是什么?它有什么作用?Gateway中的断言使用Gateway中的过滤器使用Gateway中的路由使用第1章网关1.1网关的概念简单来说,网关就是一个网络连接到另外一个网络的...
- Spring Boot 自动装配原理剖析
-
前言在这瞬息万变的技术领域,比了解技术的使用方法更重要的是了解其原理及应用背景。以往我们使用SpringMVC来构建一个项目需要很多基础操作:添加很多jar,配置web.xml,配置Spr...
- 疯了!Spring 再官宣惊天大漏洞
-
Spring官宣高危漏洞大家好,我是栈长。前几天爆出来的Spring漏洞,刚修复完又来?今天愚人节来了,这是和大家开玩笑吗?不是的,我也是猝不及防!这个玩笑也开的太大了!!你之前看到的这个漏洞已...
- 「架构师必备」基于SpringCloud的SaaS型微服务脚手架
-
简介基于SpringCloud(Hoxton.SR1)+SpringBoot(2.2.4.RELEASE)的SaaS型微服务脚手架,具备用户管理、资源权限管理、网关统一鉴权、Xss防跨站攻击、...
- SpringCloud分布式框架&分布式事务&分布式锁
-
总结本文承接上一篇SpringCloud分布式框架实践之后,进一步实践分布式事务与分布式锁,其中分布式事务主要是基于Seata的AT模式进行强一致性,基于RocketMQ事务消息进行最终一致性,分布式...
- SpringBoot全家桶:23篇博客加23个可运行项目让你对它了如指掌
-
SpringBoot现在已经成为Java开发领域的一颗璀璨明珠,它本身是包容万象的,可以跟各种技术集成。本项目对目前Web开发中常用的各个技术,通过和SpringBoot的集成,并且对各种技术通...
- 开发好物推荐12之分布式锁redisson-sb
-
前言springboot开发现在基本都是分布式环境,分布式环境下分布式锁的使用必不可少,主流分布式锁主要包括数据库锁,redis锁,还有zookepper实现的分布式锁,其中最实用的还是Redis分...
- 拥抱Kubernetes,再见了Spring Cloud
-
相信很多开发者在熟悉微服务工作后,才发现:以为用SpringCloud已经成功打造了微服务架构帝国,殊不知引入了k8s后,却和CloudNative的生态发展脱轨。从2013年的...
- Zabbix/J监控框架和Spring框架的整合方法
-
Zabbix/J是一个Java版本的系统监控框架,它可以完美地兼容于Zabbix监控系统,使得开发、运维等技术人员能够对整个业务系统的基础设施、应用软件/中间件和业务逻辑进行全方位的分层监控。Spri...
- SpringBoot+JWT+Shiro+Mybatis实现Restful快速开发后端脚手架
-
作者:lywJee来源:cnblogs.com/lywJ/p/11252064.html一、背景前后端分离已经成为互联网项目开发标准,它会为以后的大型分布式架构打下基础。SpringBoot使编码配置...
你 发表评论:
欢迎- 一周热门
- 最近发表
- 标签列表
-
- oracle位图索引 (63)
- oracle批量插入数据 (62)
- oracle事务隔离级别 (53)
- oracle 空为0 (50)
- oracle主从同步 (55)
- oracle 乐观锁 (51)
- redis 命令 (78)
- php redis (88)
- redis 存储 (66)
- redis 锁 (69)
- 启动 redis (66)
- redis 时间 (56)
- redis 删除 (67)
- redis内存 (57)
- redis并发 (52)
- redis 主从 (69)
- redis 订阅 (51)
- redis 登录 (54)
- redis 面试 (58)
- 阿里 redis (59)
- redis 搭建 (53)
- redis的缓存 (55)
- lua redis (58)
- redis 连接池 (61)
- redis 限流 (51)