您的位置:首页 > 数据库 > Redis

distributed locks with redis,分布式锁在redis中的实现

2015-07-09 17:17 405 查看

基于redis的分布式锁实现

在很多环境中不同的进程需要以排他性的方式占用共享资源,此时分布锁就成为一项很有用且必须的功能。

现在有很多的博客描述了 如何在redis的基础上实现分布式锁,并且进行了实现。但是每种实现都采用了不同的方式,有些实现比较简单,但是不能够保证功能完全实现并正确,有些实现稍微复杂些,但是在功能方面就比较完整。

这篇文章致力于提供一个在redis基础上实现分布式锁的比较权威的算法,叫做:Redlock,此算法实现的分布式锁要比单纯依赖单台redis实现的锁在正确性、可靠性方面高。

正确性、持续性原则

我们只在保证三个原则的基础上进行设计,我们觉得这三个原则就能保证算法的有效运行。
1.保证准确:在任何时候,有且只有一个进程能占有锁。

2.持续性保证A:能够释放死锁。当某个占有锁的客户单崩溃或者与服务器失去联系时,被占有的锁可以自动释放以便让其他客户端能够获取锁。

3.持续性保证B:要有很强的容错性能。只要多数redis节点还在正常运行(请注意此处的“多数”),客户端就可以获取、释放锁。

为什么采用灾备的方式不能满足需求

为了理解此算法的优秀之处,我们首先分析一下大部分依赖于redis实现的分布式锁存在的问题。

在redis的基础上实现分布式锁最简单的方法就是在单台redis上创建一个key。通常这个key会被设置一个过期时间(TTL),所以,这个key可以有两种方式进行释放:一是客户端主动释放此key,二是key的生命周期到期,redis服务器自动释放。

从表面上来看,这种实现方式会工作的很好,但是却存在一个问题:如果这台redis当机了怎么办?有的人可能会说,我们可以做一个主从备份,添加一台slave,当master当机的时候,直接切换到slave上。不幸的是,这种方法也是不可取的,因为这种方式不能满足我们的正确性要求,在于redis的备份形式是异步的。这种方式存在明显的资源竞争情况:

1.客户端A从master上获取了锁。

2.master在将锁所用的key同步到slave之前发生崩溃。(即redis以异步方式进行备份)

3.slave转变成为master。

4.此时客户端B发送请求获取锁,并且成功获取。但是此时客户单A同样拥有此锁,违背了排他性原则,也即不正确了。

或许在某些特定场景中,这种方式可能正是我们想要的方式,比如说:在发生崩溃时,我们就是允许多个客户端可以同时拥有锁。但是,如果没有这种需求,还是看看这篇文章提供的解决方案吧。

利用单台redis实现锁的正确方法

在讲解如何弥补由单台redis实现分布式锁所存在的问题之前,还是先让我们看一看利用单台redis是如何实现的吧,因为这种方式确实可以满足某些应用场景,只要这些应用允许资源竞争的问题存在。而且基于单台redis的实现也是我们将要介绍算法的基础。

我们可以采用以下的方式来获取锁:

SET resource_name my_random_value NX PX 30000

这个命令设置一个key,并且只有在这个key不存的情况下才能设置成功(由 NX 参数控制),并且对这个key设置了一个30000毫秒的过期时间(由 PX 参数控制)。这个key对应的值被设置为一个随机的值,但是必须保证这个随机的值在所有的客户端上和每个请求当中都是唯一的。(这个随机数是为是释放锁时所用,看下文)
随机数的唯一性很重要,它被用来在释放锁时进行比较、判断,用一段脚本执行以下语义:只有在key存在并且key对应的value值与随机数的值相等时才能被删除。用Lua实现的脚本入下:

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

避免释放一个由其他客户端创建的锁是非常重要的。例如:有个客户单获取了锁,但是执行了比较长时间的业务逻辑,以至于超过了锁的生命周期(TTL)而让锁自动释放掉了,当这个客户端执行完业务逻辑再去释放这个锁的时候,锁可能已经被其他客户端获取到了,会造成误操作。所以只使用单独的删除命令会误删除已被其他客户端获取的锁。而上面的脚本则解决了这个问题,释放前首先判断此时key所对应的value值是否为客户端设置key时赋予的随机数(前文已经要求了此随机数在所有客户端与所有请求的全局范围内都必须是唯一的)。如果是,则表明锁现在归当前客户端所占有,可以删除。就像利用这个随机数为这次操作做了签名一样。

那么这个随机数应该怎么取才能做到唯一呢?如果是我的话可能会从/dev/urandom这个文件中取20个字节,如果是各位看官的话,可能会选择一种低消耗但足够完成任务的方法吧。(经过Google,/dev/urandom是类unix下用于获取唯一随机数的文件)。例如:一个安全的方法就是用RC4对/dev/urandom加密以获取随机数。一个更简单的方法就是取unix的当前系统时间,转换成毫秒形式,然后粘连到客户端ID的后面作为唯一随机数,虽然这种方式不是很安全,但是能满足大部分需求了。

key被设置的生命周期时间在这里被称作“锁的有效时间”。在这个时间段里,客户端可以执行自己的业务逻辑而不会与其他客户端产生冲突,如果时间到期业务逻辑还未执行完毕,锁会自动释放,其他的客户端就可以请求获取锁了。从技术上来讲没有违背排他性的原则,而这种保障只是利用一个窗口期就实现了(同一个窗口期内只有一个客户端可以占有锁)。

经过以上论述,我们获得了一个实现分布式锁的小算法。虽然这个小算法是依赖于单台redis进行的实现,但是正确性也得到了保证,可以进行使用了。如果采用多台redis进行实现会有什么效果呢?就让我们把这个算法推广到多台redis上吧,看看会得到什么保障吧。

Redlock 算法

来看一看我们如何以分布式方式来实现这个算法吧,假设我们有N台redis服务,这些redis服务都是完全独立的,相互之间没有任何的备份关系或者隐士的合作关系。我们已经描述了利用单台redis如何实现此算法。在单台实现的方式中,我们获取锁、释放锁都得到了正确性的保障。在我们以下的例子中,假设我们有5台redis服务(N=5)。设置为5是有原因的,下文中我们将描述为何设置为5。设置为5就需要我们有5台单独的实体机或者虚拟机,以便在发生故障时,每台服务的崩溃对其他服务不会产生影响。

客户端将以以下步来获取锁。

1.以毫秒形式获取当前时间。

2.客户端按顺序从所有5台redis上获取锁,使用相同的key名称和随机数。在此步中,客户端要为获取锁设定一个超时时间(注意是获取锁所用的时间),获取每个redis的锁都要设定这个超时时间,这个时间要远小于锁的生命周期时间(TTL)。比如说,锁的生命周期为10秒钟,那么这个超时时间最好设定为5到50毫秒比较合理。这样做是为了防止客户端一直向某台已经当机的redis服务获取锁,假如某台redis服务不可用了,我们立即去链接下一台,越快越好。

3.用当前系统时间减去第一步获取的系统时间,客户端就能计算出在获取锁的这步骤上一共花费了多少时间。当且仅当客户端从大多数redis服务上获取了锁(我们有5台redis,所以此处的大多数应该为3),并且获取锁的时间没有超出锁的生命周期时间,我们才认为获取锁成功。

4.获取锁成功后,锁的有效时间被设定为初始的生命周期时间减去获取锁所用的时间,与第三步中计算的一样。

5.如果客户端因为某些原因未能成功获取锁(比如:未从多数redis服务上获取锁,N/2+1台,或者获取锁时间超过锁的生命周期时间),客户端需要释放所有的锁(即使未能获取某台redis服务上的锁,也要执行释放锁命令)。

算法是异步的吗?

这个算法并未要求所有redis服务器的时钟保持精确同步,只要所有服务器的时钟频率保持大体一致、各个服务器的之间的时间差远小于时钟的生命周期时间就可以。这正好符合了现实中的计算机环境:每台计算机都有自己的本地时钟,只要各个计算机之间时钟的波动足够小,就不会影响我们的服务。

在这一点上我们要强调一下我们的排他性原则:只要持有锁的客户端能在锁的有效时间内能够完成业务逻辑,就能保证业务逻辑的排他性,当然,这个有效时间要减去某些时间(只是几毫秒,用来弥补各个服务器间的时钟波动)。

失败时,重新获取锁

当一个客户端获取锁失败的时候,这个客户端应该在一个随机时延后再重新去获取锁,这是为了避免多个客户端同时去获取某个锁(这样的话肯能会引起脑裂问题,“split-brain”,感兴趣的话可以google一下什么是脑裂问题)。只要一个客户端能够很快速的获取到大多数redis服务上的锁,发生脑裂的窗口期就会很少,所以最理想的情况就是客户端在同一时间将所有的SET命令发送到所有的redis服务上。
这里要强调下,为什么客户端在获取锁失败以后,为什么要尽快将已申请成功的锁释放掉,因为这样的话再有客户端去申请锁就不必再等待锁过期了(但是如果发生网络异常,已经占有锁的客户单不能连接到redis服务而去释放锁,就只能等待锁过期自动释放了)。

释放锁

释放锁就比较简单了,客户端只需要发送释放命令到所有redis服务上就可以了,而不用考虑是否已经获取了某台redis服务上的锁。

安全性论证

这个算法是安全、正确的吗?我们可以通过几个不同的场景来进行论证。

假设某个客户端可以获取大部分redis服务上的锁。所有的这些redis服务上就会包含一个相同的key,并且过期时间一样。但是key的设置时间点是不同的,所以不同redis服务上的key的过期时间点是不一样的。如果第一个key被设置的时间点在最坏的情况下为T1(这个时间点为我们与第一台redis建立连接的时间点),最后一个key被设置的时间点最坏的情况为T2(这个时间点为我们获取最后一个key的返回结果的时间点)。我们可以确定第一个key的最快的过期时间为
MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT
。其余的key都会在这个时间点以后过期,所以我们可以确定所有key的有效期最少为
MIN_VALIDITY


当一个客户端在大部分redis服务上已经设置了key时,其他的客户端就不能再获取多数的key了,因为大于或等于N/2+1个的redis服务已经设置了key。所以当一个客户端已经申请锁成功了,其他的客户端就不可能在同时再获取锁了(如果能获取就违背了排他性原则)。

但是,我们要验证在同一时间多个客户端不能同时获取锁成功。

如果一个客户端获取大部分锁所消耗的时间接近或多于锁的生命周期时间,我们会认为这个锁无效并释放已经设置的key,所以我们只需要考虑客户端怎么才能够在很短的时间内成功设置key就可以了,这个时间要远远短于锁的生命周期时间。根据我们以上的论证,在
MIN_VALIDITY这么长的时间内没有哪个客户端能再次获取锁。所以只有在获取锁时间大于锁的生命周期的时候,多个客户端才能同时获取锁,但在这种情况下获取的锁已经是无效的了。


如果你能够找出论据表明这个算法是安全的或者有什么bug存在吗,如果能够的话,很值得夸奖嗷。




持续性论证

系统的可持续性通过以下三个功能来保证:

1.锁可以自动回收(基于redis的过期特性),所以锁可以保证最终都是可用的。

2.事实上,客户端在获取锁失败的时候会将已经设置的key释放掉,获取锁成功以后,客户端也会在业务逻辑执行完毕以后将锁释放掉。所以说我们在获取锁的时候并不需要等到key自动过期以后才能获取锁。

3.事实上,当一个客户端获取锁失败以后,需要等待一个随机的时间再去重新获取锁,这个时间要远大于获取锁需要的时间,这样是为了避免脑分裂情况的出现。(有关脑分裂的概述还请google)。

但是我们会对网络失联采取惩罚措施,类似于网络包的生命周期(TTL),所以如果有持续的网络失联,我们可能将进行无休止的惩罚(讲得应该就是redis的过期特性),这种情况发生在客户端已经取得了锁,但是由于网络失联,未能发出删除锁的指令。

如果客户端在每次链接时都会发生这种情况,那么我们的系统基本上就不用了。

性能、崩溃恢复、文件系统同步

很多用户在利用redis做分布式锁实现的时候,都要求实现有很高的性能,获取锁、释放锁的时延要尽量短,每秒钟的并发量要尽量的高。为了达到这个目的,可以利用多路并发的方式同时向N台redis服务发送请求以便减少时间(或者做一个弱化版的多路并发,将所有连接调整为非阻塞状态,同时将所有命令都发送出去,过一段时间后再去读取结果,当然,这种方式的实现必须在客户端与各个台redis服务器的RTT(round trip time)时间都差不多的情况下)。

但是如果我们想利用redis的持久化功能来实现“崩溃-恢复”的系统,还需考虑以下的情况。

让我们看看问题所在,如果所有的redis服务都没有配置持久化,一个客户端获取了5台redis中3台上的锁,3台中的一台因某些原因需要重启,重启以后,又达到了多数的要求(可以获取锁的redis服务变为了3台),此时其他的客户端也就可以重新获取锁了,这违背了锁的排他性原则。

如果我们设置为AOF的持久话模式,情况可能会好一些。比如说,我们需要对其中的一台redis服务进行升级,重新启动了这台机子,虽然重新启动了,但是由于持久化的原因,我们的锁环境还是可以保持正常运行的,因为对应key值的过期时间在重启过程中依然在慢慢消耗。虽然在正常的重启情况下可以保证锁环境的正常运行,但是如果是突然断电了呢,会有什么问题出现呢?假设我们将持久化的频率设置为每秒钟1次(配饰fsync参数),我们的key在重启以后就有可能已经丢失了。理论上,如果我们想保证,无论碰到何种情况的重启,锁环境都能正常运行,就需要把fsync设置为fsync=always,这样只要我们一设置key,就会立刻持久化。但是这种方式的实现与采用CP模式来实现分布式锁就没有任何区别了,虽然保证了正确性,但是性能就不敢恭维了。

另外一种方式看上去可能会好一点儿,我们可以让因崩溃而重启后的redis服务暂时不参与锁活动,这样就能保证正确性了。

那么,这个暂时的时间应该是多少呢?我们取所有key中过期时间最长的(key的生命周期),这个时间过期以后,所有的key都已经自动失效,相应的所有的锁也全部释放了。

虽然这种方式可以保证,无论redis服务发生何种形式的重启,锁环境都可以正常运行。但是却有造成服务不可用的危险,比如当大部分的redis发生了崩溃,整个环境就会有TTL长时间不能用(这里全部的意思是任何资源都不能被锁定了)。

让这个锁更具可靠性:扩展锁

如果客户端的业务逻辑执行时间很短,那么可以将锁的过期时间默认设置的短一点儿,需要的话再对锁的有效时间进行延长。如果客户端在执行的过程中,占有的锁即将过期,我们可以写一段命令脚本,发送到所有的redis服务器上来延长锁的生命周期,脚本逻辑类似于:key是否已经存在,存在的话,key对应的value值是否为获取锁时设定的随机数,如果相同,则重新设定key的过期时间,类似于重新获取了锁。

虽然上面的方法很有效,但是确没有对算法进行更改,所以重新发送获取锁的请求数要有限制,否则就违背了持续性原则。

翻译自:http://redis.io/topics/distlock


                                            
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: