在很多环境下,多个不同的进程需要以排他的形式使用共享资源,这是使用分布式锁机制是一种传统但有效的方案。
有很多的库和博客都介绍了如何用Redis去实现DLM(Distributed Lock Manger,分布式锁管理器),但是每个库彼此之间的实现方式都不相同。而且很多的实现方式都比较简单,保险级别较低。
此篇文章试图介绍一种更为标准的用redis来实现分布式锁的算法,我们将这种算法叫做Redlock,我们相信用此算法实现的DLM比普通的单redis实例实现方法更加安全。我们希望社区可以对其进行评估,给予反馈,或者把它作为其他复杂算法实现的切入点或替代方案。
Implementations | 实现
在介绍算法之前,如下是基于此算法的一些现成的实现,可以用来作为参考:
- Redlock-rb (Ruby implementation). There is also a fork of Redlock-rb that adds a gem for easy distribution and perhaps more.
- Redlock-py (Python implementation).
- Aioredlock (Asyncio Python implementation).
- Redlock-php (PHP implementation).
- PHPRedisMutex (further PHP implementation)
- cheprasov/php-redis-lock (PHP library for locks)
- Redsync.go (Go implementation).
- Redisson (Java implementation).
- Redis::DistLock (Perl implementation).
- Redlock-cpp (C++ implementation).
- Redlock-cs (C#/.NET implementation).
- RedLock.net (C#/.NET implementation). Includes async and lock extension support.
- ScarletLock (C# .NET implementation with configurable datastore)
- node-redlock (NodeJS implementation). Includes support for lock extension.
Safety and Liveness guarantees | 安全和存活性保证
我们的算法设计是基于如下三个特性建模的,在我们看来,这也是为了高效的使用分布式锁所需要的最低的保证:
- 安全特性:排它性。在任何时间点,只能有一个客户端可以持有锁。
- 存活特性A:无死锁。总是可以获取到锁,即使在一个被客户端锁住的资源损坏了或分区了的情况下。
- 存活特性B:可容错。只要大多数的redis节点是可用的,那么客户端就可以获取和释放锁。
Why failover-based implementations are not enough | 为什么基于故障转移的实现不能够完全满足要求
为了理解我们希望提高的地方,让我们先来分析一下当前大多数基于redis的分布式锁的库的实现状况。
通过redis来锁定一个资源的最简单的实现就是在redis实例中创建一个key,创建的同时给key设置一个有效期。利用redis的过期机制,这个key最终总是会被释放(上述的第二条特性)。当客户端需要释放资源的时候,它只需要删除这个key即可。
表面上来看这种方式没有什么问题,但是这种实现存在一个单点故障的问题。如果redis节点宕机了怎么办?好吧,那让我们加上一个slave节点,当redis的master节点宕机了之后,slave节点可以接管服务。但是,很遗憾此种方法是不可行的,因此这种master-slave的实现无法完全保证互斥性,因为redis主从节点之间的复制是异步的。
在这个模型中显然存在一个资源竞争因素:
- 客户端A在master节点获取到锁。
- master节点在还没有将key复制给slave节点之前宕机了。
- slave节点被提升为master。
- 客户端B获取到锁,这样子客户端A和B就针对同一资源都拿到了锁。违背了互斥性。
有时在特殊的情况下,这种实现方式是完全ok的。比如在发生故障的时候,多个客户端允许同时持有锁。除此之外,我们建议用此文章描述的算法来实现DLM较为妥当。
Correct implementation with a single instance | 单redis实例的正确实现方式
在尝试用Redlock解决上述案例的缺点之前,让我们先看看如何正确使用redis单实例来实现分布式锁。如果应用程序的非频繁的竞态条件是可接受的,那么单redis实例是一个可行的方案,而且这种方案的实现也是接下来要介绍的分布式算法的基础。
要获取一个锁,可以使用如下的命令:
SET resource_name my_random_value NX PX 30000
这条命令只有当key不存在时才会生成它(NX选项),并且超时时间为30000毫秒(PX选项)。key的值被设置为my_random_value
。这个值必须要在所有的客户端和所有的锁请求中唯一。这个随机的值用来以一种安全的方式释放锁,通过一个脚本来通知redis:当这个key存在,并且key的值也完全与期望的一致,才移除这个key。这样一个逻辑通过Lua脚本的实现如下:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这样子做可以避免删除其他客户端创建的锁。比如说,一个客户端可能请求一个锁,这个客户端接下来进行其他任务,并且其他任务执行了较长的时间,超过了key过期的时间,然后此客户端再回来删除锁,如果这个时候它直接使用DEL
命令来删除的话,有可能删除的是其他客户端的锁(因为key已近超时被删除,其他客户端获取到了新的锁)。使用上面的逻辑,相当于给每一个客户端创建的锁都做了一次“签名”(通过给key赋予随机且唯一的值),客户端删除锁的时候只能删除之前“签名”过的那个锁。
那么这个随机的值应该是什么形式的呢?最好是从/dev/urandom
中生成的20字节的随机值,但是你也可以使用其他更廉价的方式生成唯一值,只要它足够的“唯一”。比如说通过使用/dev/urandom
选取RC4种子,然后从中生成伪随机数据流。一个更简单的方案就是使用带有微秒的unix时间戳和其他数据的组合,比如客户端id,这样子不是绝对安全,但是对于大多数任务来说都已经足够了。
这里的key的生存时间我们叫做“锁的有效时间”。它既是锁的自动释放时间,也是当前客户端在其他客户端获取锁之前执行必要操作所花的时间。这样子在技术上没有违反互斥性原则,只是从获取锁的那一刻开始,限制一个指定的时间窗口。
那么到目前为止,我们已经有了一个很好的请求和释放锁的方案。这是一个非分布式系统,构建于一个单一的redis实例之上,只要理论上此redis实例永远在线,那么这个方案就是安全的。 接下来让我们将这些概念扩展到一个分布式系统之上,在这个分布式系统上,我们不需要保证每个redis实例的永久可用性。
The Redlock algorithm | Redlock算法
在这个算法的分布式版本中,我们假设拥有N个redis主节点。这些节点是完全彼此独立的,没有使用replication或其他协调系统。我们已经介绍了如何在一个单redis实例中获取和释放锁,这个Redlock算法在单redis实例上对锁的操作机制也是一样的。在接下来的举例中,我们设置节点数为5,因此我们需要在5台不同的机器上分别运行redis实例,确保它们在宕机时彼此之间不会有影响。
为了获取锁,客户端需要执行如下操作步骤:
- 获取当前系统的以毫秒为单位的系统时间。
- 尝试按照顺序在N个redis实例中获取锁,并且保持在所有的实例中都使用相同的key和value。在此步骤中,当客户端在每一个实例中获取锁时,它同时会设置一个超时时间,这个超时时间应该远小于锁的自动释放时间。比如说如果锁的自动释放时间为10秒,那么这个超时时间就可以设置在5-50毫秒之间。这样子就避免客户端在与一个已经宕机的redis实例的通信中浪费太多时间,如果一个节点不可用,客户端应该马上与下一个节点进行通信。
- 客户端在每一个redis节点上获取锁时都会计算从开始到现在总共过去了多少时间,此计算只需要用当前的时间减去第一步中保存下来的时间即可得到。当且仅当客户端在多数节点中获取到了锁(至少3个),并且总共消耗的时间小于锁的初始有效时间,这个锁才被认为是获取成功了。
- 如果获取锁成功,那么锁的有效时间应该设置为初始有效时间减去时间流逝的时长,即步骤3的计算结果。
- 如果客户端获取锁失败(没有在超过N/2+1个节点上获取到锁或计算出有效时间是负数都认为获取锁失败),此时客户端会尝试对所有的节点进行解锁操作(即使对于那些获取锁失败的节点)
译者注:之所以在每个redis节点上面都计算时间的流逝时长,并将lock的有效时间设置为:初始有效时间 - 流逝时长。是因为这样子可以保证,所有的redis节点的lock会在同一个时间点过期。如果在每一个redis节点上面设置相同的lock有效时间,由于在每个结点上获取锁操作是串行的,那么此时必然会造成后面加锁的节点的锁过期会晚于前面的节点,这样子就造成了不同步。
Is the algorithm asynchronous? | 这个算法是异步的吗?
这个算法依赖于所有运行redis实例的主机相互之间都没有做时钟同步的情形,它们彼此都使用自己的本地时间,时钟周期应该是极度近似的(理论上来说每一台机器的时钟周期都不可能是绝对相同的,但是彼此之间的这种极微小差异,是可以容忍和忽略的)。这种类比就好像是真实世界中的计算机一样:每一台计算机都有一个本地的时钟,他们彼此之间的时钟的偏移是极小的,通常是可承受的。
在这一点上,我们需要更加详细的说明我们算法的互斥性规则: 它保证了,只要客户端持有锁,客户端就会在锁的有效期之内(这里的有效期指的是上面的步骤3获取到的时间,而不是锁的初始有效期时间)完成它对资源的操作工作,当然还得减去一些时间(这些时间通常已毫秒为单位,是针对不同进程间的时钟偏移的补偿)。
关于需要约束时钟偏移的相似系统的更多信息,这边文章是比较受人关注的:Leases: an efficient fault-tolerant mechanism for distributed file cache consistency。
Retry on failure | 失败重试
当一个客户端无法获取到锁时,它应该在一个随机的延时时间之后重新尝试,以避免多个客户端延时相同的时间,造成它们在同一时间对同一资源进行访问的问题(此时容易造成脑裂现象)。同样的,客户端发送消息的延时越小,出现脑裂条件(和所需要的重试)的时间窗口就越小,所以,理想的情况是,客户端应该尝试通过多播的方式,在同一时间向所有redis实例发送SET指令。
客户端如果没有在多数redis实例上获取到锁,它应该立刻在已经获取到锁的节点上释放锁,对于这一点是值的重点强调的。这样子的话,对于这个资源的锁就不用等到key自动过期之后才能够被再次获取(然而,如果出现网络分区这种情况,客户端将无法与redis实例进行通信,此资源就必须等到key自动过期之后才能被使用了)。
Releasing the lock | 释放锁
对于锁的释放就比较简单了,客户端只需要在所有的redis实例上执行锁的释放命令,而不用管客户端是否之前在此节点上成功的获取了锁。
Safety arguments | 安全性论证
这个算法真的安全吗?我们接下来模拟一下在不同的场景下都会发生些什么。
在开始之前,我们假设客户端可以在多数的redis实例上获取到锁。所有的实例都存在一个相同的key,并且此key有相同的生存时间,然而,在不同的实例上,针对于此key设置的实际生存时间是不同的,所以key会在不同的时候过期。但是如果第一个key在最坏的情况下设置的时间为T1(这个时间是与第一个redis实例通信之前的时间),最后一个key在最坏的情况下设置的时间为T2(从最后一个服务器的响应中获取的),我们确信在整个集合中的第一个key的存活时间至少为MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT
。其他的key都会在其之后失效,所以我们确信所有的key会至少在这个时间内被同时设置完成。
在这个时间期间,多数的key会被设置,其他客户端将不可能得到锁,因为已经有超过半数的key已经被设定,所以N/2+1 SET NX
操作会失败。所以一旦lock被获取,它就不可能在同一时间被其他客户端获取(不然就违背了互斥性)。
然而,我们也想要确保多个客户端在同一时间不可能同时成功的获取到锁。
如果一个客户端锁住了多数redis实例,使用的时间接近于或超过了锁的最大存活时间(TTL的最大初始值),它会认为获取的锁无效,并且会解锁所有的redis实例,所以我们只需要考虑客户端可以获取到大多数redis节点的锁,并且时间小于TTL有效时长的这种情况。在这种情况下,这个争论就如上面锁描述的那样,对于MIN_VALIDITY
,没有客户端可以重复获取到锁。所以,多个客户端同时锁定n/2 +1个redis实例的情况只会发生于锁定时长超过TTL时间的这种情况,而这种情况发生后,客户端会标识此锁为无效的,并解除锁定。
如果你可以提供一个对此安全性的有效的证据,指出现存的相似的算法,或发现了bug,我们将会非常感激你的提醒。
Liveness arguments | 存活性论证
系统的存活性基于三个主要的特性:
- 锁的自动释放(由于key过期),过期之后key可以被其他客户端获取。
- 实际上客户端也会有移除锁的机制,当客户端获取锁失败时,或者当
锁获取到了,但是需要加锁处理的工作被终结了的时候。这种情况下,我们就不需要等到key自动失效了。 - 当客户端需要重新尝试获取一个锁时,它等待的时长会超过需要获取锁的总时长。从而在概率上就使得在资源竞争中的脑裂现象变得不太可能。
然而这一机制的实现是以可用性为代价的。当发生网络分区时,整个系统的可用性代价就是TTL时长。如果发生连续性的网络分区,那么可用性就无法确定了。这种情况发生于客户端获取到了锁,还没来得及释放锁时被网络分区隔离了。
Performance, crash-recovery and fsync | 性能,故障修复和fsync
很多用户在需要高性能的场景中使用redis作为锁服务器。为了满足这一需求,使用多播技术(或poor man's multiplexing,将socket放在非阻塞同步模型中,发送所有的命令,然后之后读取这些命令,假设客户端和每个redis实例中的RTT都类似)与N个redis实例通信可以用来降低延时。
然而如果我们需要实现一个故障恢复的模型的话那就需要考虑数据的持久化了。
我们先假设所有的redis实例没有配置持久化。一个客户端在总共5个实例中的3个中获取到了锁。之后这3个实例中的其中一个重启了,这时对于同一个资源,又有个3个redis实例可以获取锁,其他客户端就会对此资源进行锁定,这样子就违背了锁的排它性。
如果我们启用AOF持久化,事情会变得好一点。例如说,我们可以对其中一个redis服务器发起SHUTDOWN指令来重启它。在重启完成之后,此redis实例从AOF文件中将数据恢复出来,给锁设置的生存时间也会同步减少重启所花费的这些时间,一切都没有问题。然后,也只有当是正常关机的时候才不会引发问题,如果是一个电源中断的宕机呢?如果redis配置的是每秒fsync数据到硬盘,那么重启之后有可能我们的key会丢失,理论上,如果我们要确保在任意形式的服务器重启的情况下的锁的安全性,我们需要配置fsync=always
。而这种配置会极大的降低性能,性能上甚至都赶不上传统的以一种安全的方法实施分布式锁的相同等级的CP系统。
然而,事情比看起来要好很多。只要实例在宕机之后能够重新启动,算法的安全性就没有影响。当它重启后,他不会影响到当前已激活的锁的计算,所以,当前已激活的锁会从此机器之外的实例中获取。
为了保证这一点,在redis服务器重启完成之后,我们需要让此redis实例在启动之后的开始的一段时间不可用,此时长要大于最大TTL时长一点点。这么设计是为了保证在此服务器宕机时的那些key能够自动过期。
使用delayed restarts
就可以达到上述目标,甚至不需要配置任何的redis持久化。然而,这一点会转化为对系统可用性的惩罚。例如,如果大多数的redis实例都宕机了,这个系统在TTL时长内就变为全局不可用状态了(这里的全局不可用的意思是客户端没法针对资源加锁,导致接下来的业务无法进行下去)
Making the algorithm more reliable: Extending the lock | 将这个算法变得更可靠:锁的延期
如果业务的执行由客户端的一系列步骤所组成,默认可以将锁的有效时间设置的更小,并实现一个锁的ttl延长机制。如果客户端的计算工作只处理了一半,同时锁的有效时间已经不多了,它可以通过一个Lua脚本的来给所有的redis实例延长key的ttl,并且此key的值保持不变。
客户端应该只有当它可以在锁的有效期之内在多数redis实例上延长锁时才会考虑锁的重新获取。基本上,锁的延长算法非常类似于获取锁的算法。
这一机制对整体的算法没有影响,所以锁的重新获取的最大尝试数量也应该被限制,否则,就会违背存活性特性。
Want to help? | 寻求帮助?
如果你在开发分布式系统,对此有自己的观点和分析,请告诉我们。或者帮助我们将此算法使用其他开发语言来实现它。
非常感谢!
Analysis of Redlock | Redlock的外部分析
- Martin Kleppmann的分析在这里。我不同意他的分析,相关的回复发表在这里
完!
感觉中间关于Safety arguments这一段理解的不是很透彻,基本是按照字面意思翻译的,如果有错误的地方麻烦提出指正。多谢!
参考资料:
Distributed locks with Redis