全面解析基于Redis的分布式锁方案

Posted 码神手记

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了全面解析基于Redis的分布式锁方案相关的知识,希望对你有一定的参考价值。

码神手记-资深攻城狮的私房笔记,微信公众平台/知乎/头条同步。

动动小手,点个关注!

全面解析基于Redis的分布式锁方案

这是一篇解析底层原理的文章,帮助你类比判断分布式锁组件的优劣,并不会详细讲解某个类库的使用方法,类库使用办法可自行上网查询。全文4312字,建议收藏细品。

参考的权威资料:Redis官网文档。


什么是分布式锁?当不同的进程必须以互斥地方式访问同一个共享资源时,就要用到分布式锁。


网上有一些比较简单的设计方案,其可靠性往往得不到很好的保证。本文会为大家介绍一种Redis官方推荐的更权威的算法:Redlock,用于在Redis上实现更加可靠的DLM(Distributed Lock Manager)。


很多语言都有Redlock分布式锁的实现,我们列举几个主流语言的实现:

Ruby Redlock-rb
Python
Redlock-py、Pottery、Aioredlock
php
Redlock-php、PHPRedisMutex、cheprasov/php-redis-lock、rtckit/react-redlock
Go
RedSync
Java
Redisson
NodeJS
node-redlock
C++
Redlock-cpp


1

分布式锁的最低要求

一个最小化、可有效使用的分布式锁至少需满足以下三个属性:

  1. 安全性:互斥。对于同一资源,在任何时刻,只有一个客户端可以持有锁。

  2. 活跃性A:死锁释放。当持有锁的客户端发生崩溃等异常而不能释放锁时,锁最终也能被其它客户端获取到。

  3. 活跃性B:容错。只要大多数(半数以上)Redis节点处于启动状态,客户端就可以获取和释放锁。

不能满足以上三个属性,则不是一个合格的分布式锁方案,其可靠性不足以在生产环境使用。在选择分布式锁方案时要牢记这三点。


2

简化版方案的现状

比较简单的实现方式是在一个Redis实例中创建一个带有过期时间的key,所以这个锁最终会被释放(满足活跃性A的要求)。当客户端需要释放锁时,主动删掉这个key就可以了。

表面上看起来还不错,但存在一个问题:单点失败。大多数公司在使用Redis时会采用主从模式(主要指一主一从),因为master到slave节点的数据复制是异步的,当master挂掉之后,互斥的安全性要求是无法得到满足

具体分析如下:

  1. 客户端A在master节点中获取了锁。

  2. 对应的key在被复制到slave节点之前,master节点挂了。

  3. slave节点被提升为master。

  4. 客户端B在新的master中获取到了A已经持有的相同资源的锁。违反了互斥的安全性要求!


所以,不要在主从模式的Redis环境中实现分布式锁,即便是后文中的Redlock方案也是一样,NO REPLICATION!在选择一些开源类库时也需要考察其是否对有副本的情况进行了合理地处理。事实上很难处理,可以认为这是基于Redis方案的缺陷。


3

单实例Redis的方案

核心要点:

  1. key不存在时设置key,value为全局唯一签名,一定时间后自动失效。

  2. 删除key时必须匹配签名。


既然一主一从的Redis环境不适合做分布式锁,那我们来看看只有一个实例的Redis环境怎么实现分布式锁。


设置一个当前不存在的key,并使用随机值签名,配置过期时间。

SET resource_name my_random_value NX PX 30000

只有当resource_name这个key不存在时,才设置key。对应的值是一个全局唯一的随机数值,作为key的签名,并且这个key将会在30000毫秒后过期。


验证签名匹配后方可删除。

对应的Lua脚本如下:

if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1])else return 0end

只有签名值对应上时才允许删除,以此实现安全地释放锁,避免删掉其它客户端创建的锁。在一些更加简陋的方案中是没有锁签名的,它们的可靠性就要更差一些了。


误删其它客户端的锁是不安全的,例如:

  1. 客户端A获取到一个锁。

  2. 客户端A长时间阻塞在某些操作上,阻塞的时间超过了锁的有效时间(通过PX参数设置的时间)。

  3. 操作完成后执行锁的DEL,但这个锁已经过期并且被客户端B获取到。DEL操作导致客户端B的锁被误删,客户端C此时可以获取B锁持有的锁。违反互斥的安全性要求!


我们使用全局唯一的随机值给锁进行签名,然后通过以上的脚本进行删除,就可以保证锁只能被创建者删除。通常可以使用当前毫秒时间,拼接上客户端id作为锁的签名值。

给key指定的生存时间被称为“锁有效时间”,它既是锁自动释放的时间,也是客户端执行操作必需的时间,需要比最大执行时间更大一些,此时并不违反互斥的安全性要求。但事实上我们无法保证每一次客户端操作的时间一定小于自动释放时间,就可能会出现操作还没有完成,锁已经自动过期,从而被其它客户端获取到。这里需要配合锁的续期才能确保安全性,文章的最后一部分会讲解续期机制。


方案优点:相对简单易实现。

方案缺点:可用性不高,一旦唯一的Redis节点挂掉,系统将完全不可用。

不建议在可用性要求较高的场景中使用该方案!


4

Redlock算法核心思想

核心要点:

  1. N个独立节点,无副本,互不依赖(非集群),N是奇数且N≥3。

  2. 在有限的时间内,客户端在半数以上节点成功设置key,则可以获取锁。

  3. 客户端需提前几毫秒完成工作,作为对时钟漂移的补偿。


单实例方案在面对单点故障时整个系统不可用,因此需要使用多实例来确保可用性,而单实例的方案无法直接套用在多实例环境,需要做一些改进

假设有一个N个Redis master节点。这些节点是独立的、互不依赖的,没有使用主从复制或者其它的协调机制。当N=5时,我们需要在不同的主机或者虚拟机上运行5个Redis master节点,以确保节点失效时独立失效,互不影响。

为了获取锁,客户端会执行以下操作:

  1. 获取当前毫秒时间。

  2. 尝试顺序地从所有实例中获取锁,在每个实例中都设置同样的key名称和随机值。在每个实例中设置锁时,客户端会有一个超时时间,这个时间比锁的有效时间更小。比如,自动释放时间是10秒,则超时时间可以是5~50毫秒。这可以防止客户端尝试从已经失效的Redis节点获取锁而长时间被阻塞。当一个Redis实例不可用时,要尽可能快地转移到下一个节点进行设置。

  3. 客户端会计算获取锁的过程消耗了多少时间。当且仅当客户端在半数以上的Redis实例中设置成功,且总耗时远小于锁的有效时间时,才会让锁最终成功被获取。

  4. 如果在某个Redis实例上设置成功,则会使用在步骤1中获取的时间,再加上已消耗的时间,作为过期时间。

  5. 如果设置锁失败了(未能成功锁定半数以上Redis实例或者有效时间是负的),将会尝试在所有Redis实例上解锁,删除对应的key。


该算法依赖于一种假设:尽管进程之间没有同步时钟,但每个进程中的本地时间仍然以大致相同的速度流动,其误差与锁的自动释放时间相比是很小的。这个假设非常接近事实:每台计算机都有一个本地时钟,通常这些计算机的时钟漂移是很小的,只有几毫秒。

基于这一点,只有当持有锁的客户端在锁有效时间达到之前完成工作,互斥性才会得到保证。提前的几毫秒用于补偿进程之间的时钟漂移。这就跟木桶原理一样,较短的一块板子决定了最大蓄水量,最早的过期时间决定了锁的实际最大生存时长。


5

失败时的重试

核心要点:

  1. 先延迟一个随机时间。

  2. 再使用指数退避法执行重试。


优秀的方案设计一定要充分考虑失败场景,即面向失败设计的思想!

当客户端不能成功获取到锁时,应该延迟一个随机时间后再重试。使用随机时间可以避免多个客户端同时争夺同一个资源的锁。大量客户端同时发起重试请求的情况称为惊群效应(thundering herd),会过多消耗你的服务器资源。如果要对一个分布式锁方案进行压力测试,我必须关注的一个指标就是:重试次数/成功次数,这个指标越低越好。


如何减少重试几率和时间?

客户端尝试在半数以上Redis实例上加锁的速度越快,竞争的时间窗口就会越小,因此最理想的情况是客户端采用多路复用的方式,同时向N个实例发送SET命令。对于无法成功获取半数以上锁的客户端,要尽快释放已获取到的锁,不需要等待key自动过期,以确保锁可以尽快被再次请求。


当重试发生时的最佳策略:使用随机延迟+指数退避可有效地分散重试请求,削弱惊群效应的影响。


指数退避(Exponential Backoff)

//retry=1代表当前第1次重试,最大重试次数是3。//随着重试次数的增加,延迟时间越来越大。第1次重试的延迟时间是20ms,第3次重试的延迟时间是80ms。long timemillsec = (long) (Math.pow(2, Math.min(retry, 3)) * 10);

可以使用递归调用+定时器进行重试操作,每一次重试后都计算出下一次重试的延迟时间,达到最大重试次数而依然没有成功则放弃重试,业务客户端要有对应的失败处理。


6

性能、故障恢复与持久化

核心要点:

  1. 多路复用,更快完成加锁/解锁,提高性能。

  2. 实时AOF或者无持久化、延迟重启,确保互斥。


多路复用,为了更快

之所以选择Redis做分布式锁服务,是因为想获得较高的性能,每秒能够执行大量的加锁和解锁操作。为了满足这个诉求,可以采用多路复用的方式,同时将所有命令发送到N个Redis节点上,并同时读取命令结果(假设所有Redis节点的响应时间是一致的)。


有持久化,实时AOF-重启后数据不丢失,确保互斥。

如果我们想要系统具备故障恢复能力,就需要考虑Redis的持久化策略。

假设我们配置了5个没有任何持久化策略的Redis实例,来看看会有什么问题。一个客户端在3个实例上成功设置了锁,其中一个实例被重启导致数据丢失,实际锁成功的节点只剩下2个(半数以下),此时其它客户端就能够获取到同一个资源的锁。违反了互斥的安全性要求!

当开启AOF持久化后,情况会有很大改善。例如:向Redis Server发送SHUTDOWN命令并重启,Redis会先进行持久化然后再重启,重启后从AOF文件中恢复数据。在Server关闭期间,锁的生存时间仍然在正常流逝,对锁的过期没有影响。这种情况下,没有任何问题。

但如果是突然断电呢?假设Redis按照默认配置,每秒进行一次AOF文件的写盘,则有可能因为来不及写盘而丢失数据。如果想在这种异常重启的情况下保证锁的安全性,就需要在持久化配置中把fsync设置为always,实时写盘。按照分布式系统的CAP理论,这是通过牺牲一定的可用性,保证了一致性和分区容错性。


无持久化,延迟重启-节点对应的锁全部过期自动失效,确保互斥。

如果一个Redis实例在崩溃后重启,而且该实例中所有的锁都不属于当前正在使用的锁(当前活动锁),则算法的安全性也是可以保证的。我们只需要在Redis实例崩溃后,延迟一段时间再启动就可以。延迟的时间要比最大的锁生存时间大一些,这样该实例中的锁在重启后已经全部失效并且会被自动释放。


当Redis实例没有配置任何持久化时,使用延迟重启可以在任何一种重启的情况下确保锁的安全。这也是一种牺牲可用性的方式,例如:当半数以上的Redis实例崩溃后,系统会变得完全不可用,持续的时间是最大锁生存时间,在这段时间里没有任何资源可以被锁定。


7

让算法更可靠:续期机制

核心要点:

  1. 进行基准测试,尽可能准确地预估客户端操作耗时,作为锁有效期的参考。

  2. 增加锁的续期机制,应对意外情况。


如果客户端操作的步骤很少,耗时很短,就可以使用更小的锁有效时间。但即便预估地多么准确,都无法避免意外,建议使用锁的续期机制对算法进行扩展,以应对不确定性。如果客户端正在执行操作,而锁的剩余生存时间已经很小,则可以通过Lua脚本对已存在的锁进行有效期延长。


8

小结

没有最完美的方案,只有最适合的。基于Redis也不是分布式锁方案的唯一选项,还有基于Zookeeper的方案也可以考虑,有时间可以再专门写一篇文章。实际选择中要考虑基础设施情况和业务要求,仔细权衡。如果你认为自己的业务对可靠性要求没那么高,可以接受相应的后果,当然也可以凑合用简单的方案。


点个关注再走呗!



扫描二维码关注


码神手记

关注码神手记,干货持续输出中~

以上是关于全面解析基于Redis的分布式锁方案的主要内容,如果未能解决你的问题,请参考以下文章

分布式锁三种解决方案

redis基于redis实现分布式并发锁

基于Redis实现分布式锁

REDIS10_Redission的入门案例多主案例搭建分布式锁进行加锁解锁底层源码解析

分布式锁三大技术方案实战——基于redis方式实现分布式锁(终极篇)

REDIS10_Redission的入门案例多主案例搭建分布式锁进行加锁解锁底层源码解析