以下内容主要是从官方文档翻译过来,另外加了一些自己的理解。如果可以建议读官方文档的介绍。在实际开发中之所以使用分布式锁就是为了保证只有一个客户端可以对共享资源进行操作,目前分布式锁实现方式有多种,比如zookeeper,而且据说zookeeper可靠性要比Redis强很多,只是效率偏低,这里也无意去争论谁强谁弱,只是从纯技术的角度来看看如何使用Redis实现分布式锁。
目前已有许多库和博客文章描述了如何使用Redis实现分布式锁管理器,但是每个库都使用了不同的方法,并且许多库使用简单的方法与稍微复杂的方法相比其可靠性要低很多。
另外Java已经有相关的实现方式——Redisson。
一、安全性和活跃性保证
下面将仅使用三个属性对我们的设计进行建模,从我们的角度来看,这些属性是使用分布式锁所需的最低保证。
1、安全性:相互排斥。 在任意一个给定时刻,只有一个客户端可以持有锁。
2、活跃性A:无死锁。 即使锁定共享资源的客户端崩溃或被分区,客户端最终也可以获到锁。
3、活跃性B:容错。 只要大多数Redis节点处于运行状态,客户端就能够获取和释放锁。
为什么基于故障转移的实现还不够
为了理解RedLock算法改进的内容,我们先了解一下目前一些基于Redis实现分布式锁的状态。
使用Redis锁定资源最简单的方式就是创建一个key,并且这个key通常会设置一个自动过期时间,这样就能够保证这个锁最终会被释放掉(不会死锁)。当客户端需要释放资源的时候,删除这个key就可以了。表面上看这样可行,但是有一个问题:就是架构中的单点故障,如果Redis挂掉了怎么办?大家肯定想给这个Redis添加一个从节点啊,主节点挂掉就用从节点。但是这样做是无法实现互斥安全性的,因为Redis的复制是异步的。
很明显这种情况下就会出现竞争条件:
客户端A在主节点获取到了锁
在主节点向从节点同步数据时主节点挂掉了
从节点被提升为主节点
客户端B也获取了此时客户端A持有的锁,这时候就出现了冲突。
在一些特殊情况下,例如在故障期间,多个客户端同时持有锁是完全正常的。 如果是这种情况,可以使用基于复制的解决方案。 否则,我们建议使用本文档中描述的解决方案来实现。
使用单实例实现的正确方式
在尝试克服上问所述单实例设置的限制之前,让我们检查如何在简单的情况下正确地执行它,因为这在不时可以接受竞争条件的应用中,这实际上是一个可行的解决方案。并且因为锁定到单个实例是实现本文实现分布式算法的基础。
可以通过下面的命令来获取一个锁:
SET key_name random_value NX PX 30000
上面的命令如果key_name
不存在,则添加一个key (NX
选项,即if not exist),这个key的值为random_value
,设置的过期时间是30000毫秒(PX
选项,EX
选项单位则是秒)。这样有个好处就是保证key最终会释放,即上面这步操作其实是一个原子操作。如果分步操作,比如先设置key和value,然后再设置其超时时间并不能保证key会被释放掉。
上面命令中之所以使用随机值也是为了以安全的方式释放锁,使用一个脚本告诉Redis:只有当key存在且key存储的value正是我期望的那个值时才删除key。 下面是通过一个Lua脚本完成的:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
之前公司内部技术分享的时候有提到过使用Lua脚本操作Redis,但是自己对这方面好像没什么兴趣,所以也就没有去专门的研究过。感兴趣的话可以去专门的研究一下。
为了避免删除由其他客户端创建的锁是非常重要的。举个简单例子:客户端A获取到了一个锁,但是其在某些操作过程中被阻塞的时间长于锁定有效时间(key的失效时间),然后客户端A删除了已经由另一个客户端B获取的锁。也就是说客户端A在获取到其创建的锁A之后持有时间过长,这时锁A已经超时(失效),而客户端A这时去删除锁A(锁A已经不在了),所以删除的却是客户端B持有的锁B.....这里客户端B创建的锁B是在锁A超时之后,客户端A删除之前完成的。
因此仅使用DEL是不安全的,因为客户端可能会删除另一个客户端持有的锁。使用上面的脚本而不是每个锁都使用一个随机字符串“签名”,这样只有在客户端去尝试删除的锁依然是它设置的锁时,这个锁才会被删除。这一点道理我是可以理解,但是如何判断客户端获取的锁就是它自身创建的锁呢??上面的Lua脚本里面具体的ARVG[1]
的意思我太明白。
这个随机字符串应该是什么?假设它是来自/ dev / urandom的20个字节,但是我们可以找到更简单的方法来保证它在你的任务中是唯一的。比如,一个安全的方案是使用/dev/urandom对RC4进行种子处理,并从中生成伪随机流。一个更简单的解决方案是使用unix时间和微秒分辨率的组合,将其与客户端的ID连接起来,这个方案不是那么的安全,但是在大多数环境中都应该是可以完成任务的。
我们设置key的生存时间被称为“锁定有效时间”。它既是锁自动释放时间,也是客户端在不违背确保互斥的前提下,在其他客户端可能会再次获取到这个锁之前执行相关操作所需的时间(应该说是客户端操作时间的上限,即不能超过锁的有效期),这仅限于在给定的窗口期,即从获得锁定的那一刻起的时间范围内。
所以现在我们有了获得和释放锁的好方法。根据上文可见由单个、始终可用的Redis实例组成的非分布式系统是安全的。下面让我们将概念扩展到没有这种保证的分布式系统中。
二、Redlock算法
在算法的分布式版本中,我们假设有N个Redis主机。 这些节点完全独立,并且我们不使用复制或任何其他隐式协调系统。 在上面的内容中我们已经描述了如何在单个实例中安全地获取和释放锁。我们理所当然地认为这种算法将会使用这种方法在单个实例中获取和释放锁。在下面的示例中,我们设置N = 5,这是一个合理的值,因此我们需要在不同的计算机或虚拟机上运行5个Redis主服务器,以确保它们以大多数独立的方式失败(这里我不是很理解,意思是说当大多数实例失败的时候,整个过程就算失败了吗?)。
为了获取分布式锁,客户端需要执行以下操作:
1、获取当前时间的毫秒值。
2、尝试按顺序获取所有N个实例中的锁,在所有实例中使用相同的key名称和随机值。在这个过程中,当在每个Redis实例中设置锁时,客户端使用一个与总的自动释放时间相比较小的超时时间值来获取它。比如,设置的总的自动释放时间是10秒,那么超时可以设置在5~50毫秒范围内。这个超时时间是针对每一个实例的,主要为了防止客户端长时间阻塞,即试图与一个不可用的Redis节点进行通信。这种情况下应该及时停止,并尝试尽快与下一个实例进行通信。其实这个还是很好理解的,就是为了及时止损,因为总的超时时间是固定的,如果某没能从某节点获取到对应的锁,应该及时放弃,赶快获取下个实例的锁,以避免整个过程阻塞,其实只要保证能获取到一半以上实例的锁就当这个锁定成功了。
3、客户端通过从当前时间中减去在步骤1中获得的时间戳来计算获取锁所需总的时间。当且仅当客户端能够在大多数实例中获取到锁时(假设共5个实例,那么至少要获取3个)并且获取这些锁所经过的总时间小于锁的有效时间,那么认为锁定被获取。也就是说判断获取分布式锁有没有成功只需要有两个保证,一、保证获取到一半以上Redis实例中的锁;二、保证整个过程(获取分布式锁)的时间要小于其初始的有效时间。
4、如果获得了锁,那么这个锁实际的其有效时间是初始有效时间减去在每个Redis实例上花费的时间,如步骤3中计算的那样。
5、如果客户端由于某种原因无法获取到锁定N / 2 + 1个实例的锁或分布式锁的实际有效时间为负,那么则表明没有获取到分布式锁,这时它将尝试释放所有的Redis实例(即使是它没有获取到锁的实例)。
通过上面这些步骤我觉得应该基本上能够理解Redis实现分布式锁的方法了,当然严谨一点的话还应该减去时钟漂移。
算法是否异步?
该算法依赖于以下前提条件:尽管跨进程没有同步时钟,但每个进程中的本地时间仍然大致以相同的速率流动,其中错误与锁的自动释放时间相比较小。 这个假设与现实世界的计算机非常相似:每台计算机都有一个本地时钟,我们通常可以依靠不同的计算机来获得较小的时钟漂移。
此时我们需要更好地指定我们的互斥规则:只要持有锁的客户端将在锁定有效时间内(上面步骤3中获得)终止其工作,减去一些时间(仅几毫秒,它就得到保证 为了补偿进程之间的时钟漂移)。
一般来讲可以忽略时钟漂移,因为其相对锁的超时时间很短,索引我们可以近似看作这个算法是同步的。但是从一个更严谨的角度考量,时钟漂移不能忽略,尤其是服务器距离很远的情况下。
有关需要绑定时钟漂移的类似系统的更多信息,本文是一个有趣的参考:
Leases: an efficient fault-tolerant mechanism for distributed file cache consistency.
失败重试机制
当客户端无法获取锁时,它应该在一个随机延迟后再次尝试,以避免多个客户端尝试在同一时间获取同一资源的锁情况(这可能会导致脑裂情况)。此外,一个客户端尝试在大多数Redis实例中获取锁的速度越快,发生脑裂情况的可能性就越小(并且需要重试),所以理想情况下客户端应尝试使用多路复用的方式将SET命令同时发送到N个Redis实例 。
需要强调的是,对于没能获得大多数锁的客户端来说,及时释放(部分)获取的锁是非常非常重要的,这样就没必要再等待key到期,然后再去获取锁(但是如果发生网络分区且客户端无法再与Redis实例通信,则在等待key到期时需要支付可用性惩罚),说白了,及时释放锁能更加节省时间。
释放锁
释放锁是很简单的,只需在所有的实例中释放锁即可,无论客户端是否获取到该实例的锁。
安全性讨论
这个算法安全吗?我们可以尝试了解一下不同场景中会发生什么。
首先假设客户端能够在大多数情况下获取锁。所有Redis实例都将包含一个存活时间相同的key。但是这个key在不同实例上设置的时间点并不相同,因此key也将在不同的时间点过期。但是如果第一个key在时间T1设置为最差(在与第一个服务器通讯之前采样的时间),并且最后一个key在时间T2设置为最差(从最后一个服务器获得回复的时间),我们可以确定在第一个key的存活时间最小为MIN_VALIDITY = TTL-(T2-T1)-CLOCK_DRIFT
,即分布式锁的过期时间 - 获取锁过程的总时间 - 时钟漂移。所有其他key都将在之后的时间到期,因此我们确信这些key将至少同时设置为这个时间。
在设置大多数key期间,另一个客户端将无法获取锁到,因为如果它已经获取到N / 2 + 1个key,那么N / 2 + 1 个SET NX操作就不能成功。因此,如果已经获得锁,那么它是无法再次被重新获取的(违反互斥属性)。
然而我们还希望确保同时尝试获取锁的多个客户端不能同时成功。
如果客户端使用的时间接近或大于锁的最大有效时间(SET的TTL
)来锁定大多数Redis实例,那么可以认为锁定无效并将所有实例释放,因此我们只需要考虑客户端能够在小于有效时间的时间内锁定大多数实例的情况。在这种情况下,对于上面已经表达的参数,对于MIN_VALIDITY
没有客户端能够重新获取锁。因此只有当锁定多数实例的时间大于TTL
时,多个客户端才能同时锁定N / 2 + 1实例(时间上面是步骤2的结束时间),这时后这个锁是无效的。
活跃性讨论
系统的活跃性主要基于三个主要特征:
1、锁的自动释放(因为key到期):最终可以再次锁定key。
2、通常情况下,当没有获得锁时或者获取了锁并且整个流程终止时,客户端通常会移除锁,这样就能够使得我们可能不用等到key到期就能重新获取锁。
3、事实上当客户端需要重新尝试获取锁时,它等待的时间比获取大多数锁所需的时间要大得多,这样就可以避免在资源争用期间出现脑裂的情况。
但是,我们在网络分区上付出的代价等于TTL
时间,因此如果有连续分区,我们可以无限期地支付此代价。这种情况在每次客户端获取锁并在能够删除锁之前进行分区时都会发生。
基本上,如果存在无限的连续网络分区,那么系统可能在无限长的时间内都不可用。
性能、故障恢复和fsync
使用Redis作为锁服务器需要很高的性能要求,以满足获取和释放锁的低延迟以及每秒可执行的获取/释放操作数量方面的要求。为了满足这个要求,需要减少客户端与N个Redis服务器通信延迟,因此选择多路复用(或者是简单版的多路复用,即将Socket
置于非阻塞模式,发送所有命令,之后读取所有命令,假设客户端和每个实例之间的RTT
,即往返时延是相似的)。
但是如果我们想要定位故障恢复系统模型,还有另一个关于持久化的因素需要考虑。
假设我们根本没有配置Redis的持久化。客户端从5个实例中的3个都获取到了锁。而客户端能够获取锁的1个Redis实例重启了,这时候就又出现了3个实例可以锁定同一资源的情况,这样另一个客户端可以再次锁定它,这样就违反了锁独占的安全属性。举个例子,客户端A获取了3个实例的锁,这时可以认为客户端拿到了分布式锁,但这时候这个3个实例中的1个重启了,但是客户端A已经被判断为拿到分布式锁了。而客户端B这时也获取了3个Redis实例的锁(客户端A没有获取到的2个加重启的1个),也拿到了分布式的锁,这样就违反了锁的独占性。
如果我们开启AOF持久化策略,情况会有所改善。例如,我们可以通过发送SHUTDOWN
并重新启动它来升级服务器。因为Redis过期是在语义上实现的,所以当服务器关闭时,实际上服务器仍然在计时,即unix时间,这样我们所有的要要求都没有问题。只要它是一个纯粹的关机操作那么所有都没有问题。如果是停电呢?如果默认情况下将Redis设置为每秒在磁盘上进行fsync,那么重新启动后可能会丢失key。理论上如果我们想要在任何类型的实例重启时都保证锁的安全性,我们需要在Redis的持久化设置中始终启用fsync=always
,这时候Redis的性能先对要低写。反过来这将完全毁掉传统上用于以安全方式实现分布式锁的CP系统的性能。
然而事情比第一眼看起来要好一些。基本上只要实例在崩溃后重新启动时,它就会保留算法安全性。重启后的实例将不再参与任何当前活动的锁,因此当实例重新启动时,当前活动锁的集合都是通过锁定的那些实例而不是重新加入系统的实例获得的。也就是说重启之后的实例不影响之前获取到的锁,且也不会参与到当前活动的锁中。
为了保证这一点,我们只需要在Redis崩溃后创建一个实例,且在一个至少比最大TTL
多一点的时间范围内都不可用。这样实例崩溃时存在的锁的所有key就会变为无效并最终自动释放。也就是说在崩溃时存在的锁的有效期内,重新启动的Redis实例都不可用,即不参与当前锁的计算。
使用延迟重启基本上可以实现安全性,即使没有开启的Redis持久化,但请需要注意的是这可能转化为可用性惩罚。例如,如果大多数实例崩溃,对TTL
而言系统将全局不可用(意味着在此期间不可锁定任何资源)。大多数实例崩溃也就意味着整个算法是不可用的。
三、总结
上文的RedLock算法是从实现上比较容易理解,关键点就是两个:
一、获取到的所有Redis的实例数必须大于N/2,即至少有N/2 + 1个实例
二、获取所有Redis实例的时间 + 时钟漂移 + 业务执行时间应该小于TTL
。
另外需要注意的是:
1、一定要为每个Redis实例设置超时时间,且这个时间要远小于TTL
,以避免获取某个Redis的锁时长时间阻塞,而影响整个流程。
2、在释放Redis的分布式锁时要释放所有实例,即使没有获取到该实例。
3、重试机制应该设置一定的次数限制
这里的RedLock算法是官方文档提供的,实际应用中可以使用相关语言的具体实现,比如Java实现的Redisson。以上内容基本上是从官方文档翻译过来的,但是因为自己能力有限,所以可能会有一定的偏差,还请谅解,如果上面内容中有什么不当之处也请指正。