本文主要是对官方文档的翻译和理解
安全性和活性保证
- 安全性:相互排斥,在任何时间点,只有一个客户端可以获取锁
- 活性属性A:不会发生死锁。客户端总可以获取锁,即使已经获取锁的客户端崩溃了 or 进行了分区
- 活性属性B:失败容忍, 只要大多数Redis节点是正常运行,客户端就可以获取和释放锁
为什么基于故障转移的实现是不够的?
使用Redis来锁定资源最简单的方法是在一个实例中创建一个key,通常它被创建在限制的时间内存活,使用Redis expires 特色,因此最终它将被释放。当客户端需要释放资源时,它被删除。
表面上它是工作的,但是这里有个问题:在我们的架构中存在单点故障。如果一个Redis master下线了会发生什么?我们可以添加一个从节点,然后在master无效的时候使用它。通过这个做法,我们不能实现我们的互相排斥安全属性,因为Redis复制是异步的(可能master执行了,但是slave还没有,所以当master失效的时候,slave不存在这个key,导致多个client获取到锁)。
这显然有个竞态条件:
- 客户端A在master获取锁
- master在写key到slave之前崩溃了
- slave被选举为master
- 客户端B获取相同的key在新的master.
单实例下的正确实现
单实例下获取锁的方式
SET resource_name my_random_value NX PX 30000
命令设置一个不存在key,过期时间再30000毫秒,key的值是"myrandomvalue",它必须在所有客户端和lock请求里面是唯一的。
随机值被用来安全释放lock,通过一个脚本告知Redis,移除key仅仅当它存在同时存在key里面的值等于期待的值的时候。具体操作通过如下Lua脚本:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这是重要的,为了避免移除其他客户端创建的锁。例如,一个客户端可能获取了锁,获取锁后执行一些操作的时间比lock的有效时间还要长,这段时间后key已经过期,在之后移除lock,此时的锁可能是其他客户端获取的。此时直接使用DEL时不安全的,通过上面的脚步,每个锁使用一个随机签名串,保证锁被正确移除。
RedLock算法
在分布式版本的算法中,我们假设我们有N个Redis masters。这些节点是完全独立的,因此我们不使用复制 or 任何其他协调系统。下面我们设置N=5,这个值是有原因的,因此我们运行5个实例在不同的主机or虚拟机上为了保证他们独立不会互相影响。
为了获取锁,客户端执行接下来的操作:
1. 获取当前毫秒时间
2. 尝试在N个实例序列里面获取锁,使用相同的Key和随机值在所有实例中。在第二阶段,当在每一个实例中设置Lock的时候,客户端使用一个超时相对锁自动释放时间更小的时间来获取。例如如果自动释放时间是10秒,超时时间可能是5-50毫秒的范围。这阻止客户端保持长时间阻塞状态来从一个宕机的Redis 节点获取锁;如果一个实例无效了,应该尝试下一个实例ASAP(估计是尽可能快的意思)
3. 客户端计算将有多少时间消耗用来获取锁,通过减少从步骤1中获取的时间戳。如果客户端可以从大多数实例中获取锁,同时总时间消耗小于锁有效时间,锁被认为正常获取。
4. 如果锁被获取到,它的有效时间被认为是初始有效时间减去3中消耗的时间。
5. 如果一个客户端获取锁失败了,它将尝试解锁所有实例,即使那些没有锁的。
Is the algorithm asynchronous?
算法依靠假设当这没有同步时钟贯穿所有过程,仍然每个进程的当前时间流动大约在相同的比例,伴随一个错误也是相对于锁的自动释放时间来说小的时间值。这个假设像真实世界的计算机:每个计算机有一个本地时钟并且我们通常可以相信不同计算机有小的时间漂移。
在这个观点上我们需要更好的指定我们的互相排斥规则:它被保证仅仅获取到锁的客户端可以终止它的工作在锁有效时间内,减少一些时间(仅仅少许毫秒为了弥补进程间的时钟漂移)。
For more information about similar systems requiring a bound clock drift, this paper is an interesting reference: Leases: an efficient fault-tolerant mechanism for distributed file cache consistency.
失败重试
当一个客户端不能获取到锁时,它应该在随机延迟后再次尝试为了尝试使同步多客户端试图获取相同资源在相同时间获取锁(这可能导致脑裂条件,没有谁会赢)。同时更快的客户端尝试在大多数实例中获取锁,更小的窗口时间作为脑裂的条件(同时需要重试),因此理想上客户端应该使用多路复用在相同时间尝试发送SET命令到N个实例。
客户端在获取大多数实例上的锁失败的情况下释放掉获取到的部分锁,这点是值得强调的,因为这样不需要等待lock的key过期使锁可以再次获取(如果一个网络分区发生同时客户端不再可能与Redis实例交互,这里有个有效的方案就是等待key过期)。
释放锁
释放锁涉及释放所有实例中的锁,不论客户端是否相信是否可能成功锁一个给定实例。
安全参数
是否参数安全?我们可以尝试理解不同的场景下会发生什么?
开始,假设一个客户端可以在大多数实例中获取锁。所有实例将包含一个存活相同时间的key。然后,key被设置在不同时间,因此key将会在不同时间过期。但是如果第一个key最坏被设置在T1时间(我们抽样在联系第一个服务器之前的时间)和最后一个key最后被设置在最坏T2时间(我们获取最后一个服务器回复的时间),我们可以确定第一个key过期时间存在于 MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT(TTL是key存活还剩余的时间
)。所有其他key将在这之后过期,因此我们确保key将被同步设置至少在这个时间点。
在大多数key被设置的时间,另一个客户端将不能获取锁,因为N/2+1的key已经存在,所以N/2+1 SET NX 操作不可能成功。因此如果一个锁被获取,它不可能在相同的时间被重复获取(违反互相排斥的属性)
然后我们想要同时确保多个尝试获取锁的客户端在相同时间不能同时成功。
如果一个客户端使用一个比最大有效时间相近or更大的时间,lock将被认为无效的同时将解锁所有实例,因此我们仅仅需要认识这种情况,客户端仅仅能锁定大多数实例在有效时间内。因此大多数客户端将可以锁N/2+1实例在同一时间,仅仅当这个锁大多数实例的世界大于TTL时间,这样可以使锁失效。
活跃度参数
系统活跃度基于三个主要的特征:
- 自动释放锁:基本上锁可以再次有效的被锁住
- 客户端将合作移除锁当锁没有被获取时,或者当锁被获取了同时任务终止了。我们可以不用等待key过期来重获取锁
- 当客户端试图重试获取锁时,它会等待比获取大多数实例的锁更大的时间,为的是避免脑裂的情况发生
然而,我们必须等待网络分区上的TTL结束,因此如果这里又持续的分区,我们需要一直等待。这个发生在每次一个客户端获取一把锁,同时获取分区结束在锁可能被移除前。
事实上,如果这有无限持续的网络分区,系统可能变的无效的在无限的时间内。
性能,灾难恢复和同步
许多用户使用Redis作为锁服务器需要高性能,包括低延迟的锁获取和释放,和大佬的获取释放操作执行在每一秒内。为了实现这个需求,与N个Redis 服务器交互的用来减少言辞的策略是完成多路复用的(or 简单的多路复用,将socket放到无阻塞模式中,发送很多命令,同时在之后读取很多命令,假设在客户端和每个实例间的RTT时延是类似的)
然后,这另一种情况需要认识的是持久化,如果我们想要在灾难恢复模式下处理。
假设我们配置Redis不适用持久化。一个客户端可以通过5个实例中的3个实例获取锁。其中的一个可以获取锁的实例被重启,此时这有三个实例可以锁定相同的资源,同时另一个客户端也可以锁它,这个违反锁排他性安全属性。
如果我们使用AOF持久性,事情将提升更多。例如我们可以更新一个服务器通过发送SHUTDOWN 同时重启它。因为Redis过期是语义上被实现因此当服务器下线时,时间仍然被消耗。我们的所有需求都满足。然后每一样都正常只有它能干净的关闭(However everything is fine as long as it is a clean shutdown. )。就如断电时的情况。如果Redis被配置在默认设置,每秒同步磁盘,可能重启后我们的key就消失了。在理论上,面对任何的实例重启时如果我们想要保证锁安全,我们需要确保fsync=always 的持久设置。这将完全破坏对应相同级别CP系统的性能,这种系统传统上用来在安全方式下实现分布式锁。
然而,事情比开始看起来更好了。基本上算法安全被保持只要一个实例在一次崩溃后重启,它不再参与任何当前活动的锁,因此当实例重启的时候当前活动锁的设置被往前保持通过锁的实例而不是重新加入系统的实例。
为了保证这些,我们需要使一个实例在崩溃后无效的时间超过我们使用的最大TTL,这个时间被所有的在实例崩溃时存在的相关的key需要,来使key无效同时自动释放。
使用延迟重启基本上可以保证达到安全性,即使是在没有任何Redis持久化有效的情况下,然而,记住这可能转化为一种处罚。例如如果大多数实例崩溃,系统将在TTL时间内变得全局无效(here globally means that no resource at all will be lockable during this time)。
使算法更可靠:延长锁的时间
如果通过客户端的工作执行被小的阶段组成,那么可以使用更小的锁有效时间,同时扩展算法实现一个锁扩展机制。基本上,如果当锁有效时间靠近低值时仍然在计算的过程中,客户端可以延长锁通过发送一个lua脚本到所有实例来扩展key的TTL,如果key存在并且值还是设置的随机值。
客户端应该仅仅认识到锁重获取如果它可以延长锁在大多数实例中,同时在有效时间内(basically the algorithm to use is very similar to the one used when acquiring the lock)。
然而,这并没有在技术上改变算法,因此最大数量的锁重获取尝试应该被限制的,如果相反存活性属性的一条将被违反。
引用:
https://redis.io/topics/distlock
https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html