在多线程共享临界资源的场景下,分布式锁是一种非常重要的组件。
许多库使用不同的方式使用redis实现一个分布式锁管理。
其中有一部分简单的实现方式可靠性不足,可以通过一些简单的修改提高其可靠性。
这篇文章介绍了一种指导性的redis分布式锁算法RedLock,RedLock比起单实例的实现方式更加安全。
在介绍RedLock算法之前,我们列出了一些已经实现了分布式锁的类库供大家参考。
Redlock-rb (Ruby 实现).
Redlock-py (Python 实现)
Redlock-php (PHP 实现)
PHPRedisMutex (further PHP 实现)??
Redsync.go (Go 实现)
Redisson (Java 实现)
Redis::DistLock (Perl 实现)
Redlock-cpp (C++ 实现)
Redlock-cs (C#/.NET 实现)
RedLock.net (C#/.NET 实现
ScarletLock (C# .NET 实现)
node-redlock (NodeJS 实现)
分布式锁应该具有的特性(Safety & Liveness)
我们将从三个特性的角度出发来设计RedLock模型:
安全性(Safety):在任意时刻,只有一个客户端可以获得锁(排他性)。
避免死锁:客户端最终一定可以获得锁,即使锁住某个资源的客户端在释放锁之前崩溃或者网络不可达。
容错性:只要Redsi集群中的大部分节点存活,client就可以进行加锁解锁操作。
故障切换(failover)实现方式的局限性
通过Redis为某个资源加锁的最简单方式就是在一个Redis实例中使用过期特性(expire)创建一个key, 如果获得锁的客户端没有释放锁,那么在一定时间内这个Key将会自动删除,避免死锁。
这种做法在表面上看起来可行,但分布式锁作为架构中的一个组件,为了避免Redis宕机引起锁服务不可用, 我们需要为Redis实例(master)增加热备(slave),如果master不可用则将slave提升为master。
这种主从的配置方式存在一定的安全风险,由于Redis的主从复制是异步进行的, 可能会发生多个客户端同时持有一个锁的现象。
此类场景是非常典型的竞态模型:
Client A 获得在master节点获得了锁
在master将key备份到slave节点之前,master宕机
slave 被提升为master
Client B 在新的master节点处获得了锁,Client A也持有这个锁。
如何正确实现单实例的锁
在单redis实例中实现锁是分布式锁的基础,在解决前文提到的单实例的不足之前,我们先了解如何在单点中正确的实现锁。
如果你的应用可以容忍偶尔发生竞态问题,那么单实例锁就足够了。
我们通过以下命令对资源加锁
SET resource_name my_random_value NX PX 30000
SET NX 命令只会在Key不存在的时给key赋值,PX 命令通知redis保存这个key 30000ms。
my_random_value必须是全局唯一的值。这个随机数在释放锁时保证释放锁操作的安全性。
通过下面的脚本为申请成功的锁解锁:
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
如果key对应的Value一致,则删除这个key。
通过这个方式释放锁是为了避免client释放了其他client申请的锁。
例如:
Client A 获得了一个锁,
当尝试释放锁的请求发送给Redis时被阻塞,没有及时到达Redis。
锁定时间超时,Redis认为锁的租约到期,释放了这个锁。
client B 重新申请到了这个锁
client A的解锁请求到达,将Client B锁定的key解锁
Client C 也获得了锁
Client B client C 同时持有锁。
通过执行上面脚本的方式释放锁,Client的解锁操作只会解锁自己曾经加锁的资源。
官方推荐通从 /dev/urandom/中取20个byte作为随机数或者采用更加简单的方式, 例如使用RC4加密算法在/dev/urandom中得到一个种子(Seed),然后生成一个伪随机流。
也可以用更简单的使用时间戳+客户端编号的方式生成随机数,
这种方式的安全性较差一些,但是对于绝大多数的场景来说也已经足够安全了。
PX 操作后面的参数代表的是这key的存活时间,称作锁过期时间。
当资源被锁定超过这个时间,锁将自动释放。
获得锁的客户端如果没有在这个时间窗口内完成操作,就可能会有其他客户端获得锁,引起争用问题。
通过上面的两个操作,我们可以完成获得锁和释放锁操作。如果这个系统不宕机,那么单点的锁服务已经足够安全,接下来我们开始把场景扩展到分布式系统。
RedLock算法介绍
下面例子中的分布式环境包含N个Redis Master节点,这些节点相互独立,无需备份。这些节点尽可能相互隔离的部署在不同的物理机或虚拟机上(故障隔离)。
节点数量暂定为5个(在需要投票的集群中,5个节点的配置是比较合理的最小配置方式)。获得锁和释放锁的方式仍然采用之前介绍的方法。
一个Client想要获得一个锁需要以下几个操作:
得到本地时间
Client使用相同的key和随机数,按照顺序在每个Master实例中尝试获得锁。在获得锁的过程中,为每一个锁操作设置一个快速失败时间(如果想要获得一个10秒的锁, 那么每一个锁操作的失败时间设为5-50ms)。
这样可以避免客户端与一个已经故障的Master通信占用太长时间,通过快速失败的方式尽快的与集群中的其他节点完成锁操作。
客户端计算出与master获得锁操作过程中消耗的时间,当且仅当Client获得锁消耗的时间小于锁的存活时间,并且在一半以上的master节点中获得锁。才认为client成功的获得了锁。
如果已经获得了锁,Client执行任务的时间窗口是锁的存活时间减去获得锁消耗的时间。
如果Client获得锁的数量不足一半以上,或获得锁的时间超时,那么认为获得锁失败。客户端需要尝试在所有的master节点中释放锁, 即使在第二步中没有成功获得该Master节点中的锁,仍要进行释放操作。
RedLock能保证锁同步吗?
这个算法成立的一个条件是:即使集群中没有同步时钟,各个进程的时间流逝速度也要大体一致,并且误差与锁存活时间相比是比较小的。实际应用中的计算机也能满足这个条件:各个计算机中间有几毫秒的时钟漂移(clock drift)。
失败重试机制
如果一个Client无法获得锁,它将在一个随机延时后开始重试。使用随机延时的目的是为了与其他申请同一个锁的Client错开申请时间,减少脑裂(split brain)发生的可能性。
三个Client同时尝试获得锁,分别获得了2,2,1个实例中的锁,三个锁请求全部失败。
一个client在全部Redis实例中完成的申请时间越短,发生脑裂的时间窗口越小。所以比较理想的做法是同时向N个Redis实例发出异步的SET请求。
当Client没有在大多数Master中获得锁时,立即释放已经取得的锁时非常必要的。(PS.当极端情况发生时,比如获得了部分锁以后,client发生网络故障,无法再释放锁资源。
那么其他client重新获得锁的时间将是锁的过期时间)。
无论Client认为在指定的Master中有没有获得锁,都需要执行释放锁操作。
RedLock算法安全性分析
我们将从不同的场景分析RedLock算法是否足够安全。首先我们假设一个client在大多数的Redis实例中取得了锁,
那么:
每个实例中的锁的剩余存活时间相等为TTL。
每个锁请求到达各个Redis实例中的时间有差异。
第一个锁成功请求最先在T1后返回,最后返回的请求在T2后返回。(T1,T2都小于最大失败时间)
并且每个实例之间存在时钟漂移CLOCK_DRIFT(Time Drift)。
于是,最先被SET的锁将在TTL-(T2-T1)-CLOCK_DIRFT后自动过期,其他的锁将在之后陆续过期。
所以可以得到结论:所有的key这段时间内是同时被锁住的。
在这段时间内,一半以上的Redis实例中这个key都处在被锁定状态,其他的客户端无法获得这个锁。
锁的可用性分析(Liveness)
分布式锁系统的可用性主要依靠以下三种机制
锁的自动释放(key expire),最终锁将被释放并且被再次申请。
客户端在未申请到锁以及申请到锁并完成任务后都将进行释放锁的操作,所以大部分情况下都不需要等待到锁的自动释放期限,其他client即可重新申请到锁。
假设一个Client在大多数Redis实例中申请锁请求所成功花费的时间为Tac。那么如果某个Client第一次没有申请到锁,需要重试之前,必须等待一段时间T。T需要远大于Tac。 因为多个Client同时请求锁资源,他们有可能都无法获得一半以上的锁,导致脑裂双方均失败。设置较久的重试时间是为了减少脑裂产生的概率。
如果一直持续的发生网络故障,那么没有客户端可以申请到锁。分布式锁系统也将无法提供服务直到网络故障恢复为止。
性能,故障恢复与文件同步
用户使用redis作为锁服务的主要优势是性能。其性能的指标有两个
加锁和解锁的延迟
每秒可以进行多少加锁和解锁操作
所以,在客户端与N个Redis节点通信时,必须使用多路发送的方式(multiplex),减少通信延时。
为了实现故障恢复还需要考虑数据持久化的问题。
我们还是从某个特定的场景分析:
Redis实例的配置不进行任何持久化,集群中5个实例 M1,M2,M3,M4,M5
client A获得了M1,M2,M3实例的锁。
此时M1宕机并重启。
由于没有进行持久化,M1重启后不存在任何KEY
client B获得M4,M5和重启后的M1中的锁。
此时client A 和Client B 同时获得锁
如果使用AOF的方式进行持久化,情况会稍好一些。例如我们可以向某个实例发送shutdown和restart命令。即使节点被关闭,EX设置的时间仍在计算,锁的排他性仍能保证。
但当Redis发生电源瞬断的情况又会遇到有新的问题出现。如果Redis配置中的进行磁盘持久化的时间是每分钟进行,那么会有部分key在重新启动后丢失。
如果为了避免key的丢失,将持久化的设置改为Always,那么性能将大幅度下降。
另一种解决方案是在这台实例重新启动后,令其在一定时间内不参与任何加锁。在间隔了一整个锁生命周期后,重新参与到锁服务中。这样可以保证所有在这台实例宕机期间内的key都已经过期或被释放。
延时重启机制能够保证Redis即使不使用任何持久化策略,仍能保证锁的可靠性。但是这种策略可能会牺牲掉一部分可用性。
例如集群中超过半数的实例都宕机了,那么整个分布式锁系统需要等待一整个锁有效期的时间才能重新提供锁服务。
使锁算法更加可靠:锁续约
如果Client进行的工作耗时较短,那么可以默认使用一个较小的锁有效期,然后实现一个锁续约机制。
当一个Client在工作计算到一半时发现锁的剩余有效期不足。可以向Redis实例发送续约锁的Lua脚本。如果Client在一定的期限内(耗间与申请锁的耗时接近)成功的续约了半数以上的实例,那么续约锁成功。
为了提高系统的可用性,每个Client申请锁续约的次数需要有一个最大限制,避免其不断续约造成该key长时间不可用。