网上有关Redis分布式锁的文章可谓多如牛毛了,不信的话你能够拿关键词“Redis 分布式锁”随便到哪一个搜索引擎上去搜索一下就知道了。这些文章的思路大致相近,给出的实现算法也看似合乎逻辑,但当咱们着手去实现它们的时候,却发现若是你越是仔细推敲,疑虑也就愈来愈多。html
实际上,大概在一年之前,关于Redis分布式锁的安全性问题,在分布式系统专家Martin Kleppmann和Redis的做者antirez之间就发生过一场争论。因为对这个问题一直以来比较关注,因此我前些日子仔细阅读了与这场争论相关的资料。这场争论的大概过程是这样的:为了规范各家对基于Redis的分布式锁的实现,Redis的做者提出了一个更安全的实现,叫作Redlock。有一天,Martin Kleppmann写了一篇blog,分析了Redlock在安全性上存在的一些问题。而后Redis的做者当即写了一篇blog来反驳Martin的分析。但Martin表示仍然坚持原来的观点。随后,这个问题在Twitter和Hacker News上引起了激烈的讨论,不少分布式系统的专家都参与其中。redis
对于那些对分布式系统感兴趣的人来讲,这个事件很是值得关注。无论你是刚接触分布式系统的新手,仍是有着多年分布式开发经验的老手,读完这些分析和评论以后,大概都会有所收获。要知道,亲手实现过Redis Cluster这样一个复杂系统的antirez,足以算得上分布式领域的一名专家了。但对于由分布式锁引起的一系列问题的分析中,不一样的专家却能得出迥异的结论,从中咱们能够窥见分布式系统相关的问题具备何等的复杂性。实际上,在分布式系统的设计中常常发生的事情是:许多想法初看起来毫无破绽,而一旦详加考量,却发现不是那么完美无缺。算法
下面,咱们就从头到尾把这场争论过程当中各方的观点进行一下回顾和分析。在这个过程当中,咱们把影响分布式锁的安全性的那些技术细节展开进行讨论,这将是一件颇有意思的事情。这也是一个比较长的故事。固然,其中也免不了包含一些小“八卦”。数据库
就像本文开头所讲的,借助Redis来实现一个分布式锁(Distributed Lock)的作法,已经有不少人尝试过。人们构建这样的分布式锁的目的,是为了对一些共享资源进行互斥访问。安全
可是,这些实现虽然思路大致相近,但实现细节上各不相同,它们能提供的安全性和可用性也不尽相同。因此,Redis的做者antirez给出了一个更好的实现,称为Redlock,算是Redis官方对于实现分布式锁的指导规范。Redlock的算法描述就放在Redis的官网上:服务器
在Redlock以前,不少人对于分布式锁的实现都是基于单个Redis节点的。而Redlock是基于多个Redis节点(都是Master)的一种实现。为了能理解Redlock,咱们首先须要把简单的基于单Redis节点的算法描述清楚,由于它是Redlock的基础。网络
首先,Redis客户端为了获取锁,向Redis节点发送以下命令:dom
SET resource_name my_random_value NX PX 30000
上面的命令若是执行成功,则客户端成功获取到了锁,接下来就能够访问共享资源了;而若是上面的命令执行失败,则说明获取锁失败。异步
注意,在上面的SET
命令中:async
my_random_value
是由客户端生成的一个随机字符串,它要保证在足够长的一段时间内在全部客户端的全部获取锁的请求中都是惟一的。NX
表示只有当resource_name
对应的key值不存在的时候才能SET
成功。这保证了只有第一个请求的客户端才能得到锁,而其它客户端在锁被释放以前都没法得到锁。PX 30000
表示这个锁有一个30秒的自动过时时间。固然,这里30秒只是一个例子,客户端能够选择合适的过时时间。最后,当客户端完成了对共享资源的操做以后,执行下面的Redis Lua脚原本释放锁:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这段Lua脚本在执行的时候要把前面的my_random_value
做为ARGV[1]
的值传进去,把resource_name
做为KEYS[1]
的值传进去。
至此,基于单Redis节点的分布式锁的算法就描述完了。这里面有好几个问题须要重点分析一下。
首先第一个问题,这个锁必需要设置一个过时时间。不然的话,当一个客户端获取锁成功以后,假如它崩溃了,或者因为发生了网络分割(network partition)致使它再也没法和Redis节点通讯了,那么它就会一直持有这个锁,而其它客户端永远没法得到锁了。antirez在后面的分析中也特别强调了这一点,并且把这个过时时间称为锁的有效时间(lock validity time)。得到锁的客户端必须在这个时间以内完成对共享资源的访问。
第二个问题,第一步获取锁的操做,网上很多文章把它实现成了两个Redis命令:
SETNX resource_name my_random_value
EXPIRE resource_name 30
虽然这两个命令和前面算法描述中的一个SET
命令执行效果相同,但却不是原子的。若是客户端在执行完SETNX
后崩溃了,那么就没有机会执行EXPIRE
了,致使它一直持有这个锁。
第三个问题,也是antirez指出的,设置一个随机字符串my_random_value
是颇有必要的,它保证了一个客户端释放的锁必须是本身持有的那个锁。假如获取锁时SET
的不是一个随机字符串,而是一个固定值,那么可能会发生下面的执行序列:
以后,客户端2在访问共享资源的时候,就没有锁为它提供保护了。
第四个问题,释放锁的操做必须使用Lua脚原本实现。释放锁其实包含三步操做:’GET’、判断和’DEL’,用Lua脚原本实现能保证这三步的原子性。不然,若是把这三步操做放到客户端逻辑中去执行的话,就有可能发生与前面第三个问题相似的执行序列:
DEL
操纵,释放掉了客户端2持有的锁。实际上,在上述第三个问题和第四个问题的分析中,若是不是客户端阻塞住了,而是出现了大的网络延迟,也有可能致使相似的执行序列发生。
前面的四个问题,只要实现分布式锁的时候加以注意,就都可以被正确处理。但除此以外,antirez还指出了一个问题,是由failover引发的,倒是基于单Redis节点的分布式锁没法解决的。正是这个问题催生了Redlock的出现。
这个问题是这样的。假如Redis节点宕机了,那么全部客户端就都没法得到锁了,服务变得不可用。为了提升可用性,咱们能够给这个Redis节点挂一个Slave,当Master节点不可用的时候,系统自动切到Slave上(failover)。但因为Redis的主从复制(replication)是异步的,这可能致使在failover过程当中丧失锁的安全性。考虑下面的执行序列:
因而,客户端1和客户端2同时持有了同一个资源的锁。锁的安全性被打破。针对这个问题,antirez设计了Redlock算法,咱们接下来会讨论。
【其它疑问】
前面这个算法中出现的锁的有效时间(lock validity time),设置成多少合适呢?若是设置过短的话,锁就有可能在客户端完成对于共享资源的访问以前过时,从而失去保护;若是设置太长的话,一旦某个持有锁的客户端释放锁失败,那么就会致使全部其它客户端都没法获取锁,从而长时间内没法正常工做。看来真是个两难的问题。
并且,在前面对于随机字符串my_random_value
的分析中,antirez也在文章中认可的确应该考虑客户端长期阻塞致使锁过时的状况。若是真的发生了这种状况,那么共享资源是否是已经失去了保护呢?antirez从新设计的Redlock是否能解决这些问题呢?
因为前面介绍的基于单Redis节点的分布式锁在failover的时候会产生解决不了的安全性问题,所以antirez提出了新的分布式锁的算法Redlock,它基于N个彻底独立的Redis节点(一般状况下N能够设置成5)。
运行Redlock算法的客户端依次执行下面各个步骤,来完成获取锁的操做:
my_random_value
,也包含过时时间(好比PX 30000
,即锁的有效时间)。为了保证在某个Redis节点不可用的时候算法可以继续运行,这个获取锁的操做还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个Redis节点获取锁失败之后,应该当即尝试下一个Redis节点。这里的失败,应该包含任何类型的失败,好比该Redis节点不可用,或者该Redis节点上的锁已经被其它客户端持有(注:Redlock原文中这里只提到了Redis节点不可用的状况,但也应该包含其它的失败状况)。固然,上面描述的只是获取锁的过程,而释放锁的过程比较简单:客户端向全部Redis节点发起释放锁的操做,无论这些节点当时在获取锁的时候成功与否。
因为N个Redis节点中的大多数能正常工做就能保证Redlock正常工做,所以理论上它的可用性更高。咱们前面讨论的单Redis节点的分布式锁在failover的时候锁失效的问题,在Redlock中不存在了,但若是有节点发生崩溃重启,仍是会对锁的安全性有影响的。具体的影响程度跟Redis对数据的持久化程度有关。
假设一共有5个Redis节点:A, B, C, D, E。设想发生了以下的事件序列:
这样,客户端1和客户端2同时得到了锁(针对同一资源)。
在默认状况下,Redis的AOF持久化方式是每秒写一次磁盘(即执行fsync),所以最坏状况下可能丢失1秒的数据。为了尽量不丢数据,Redis容许设置成每次修改数据都进行fsync,但这会下降性能。固然,即便执行了fsync也仍然有可能丢失数据(这取决于系统而不是Redis的实现)。因此,上面分析的因为节点重启引起的锁失效问题,老是有可能出现的。为了应对这一问题,antirez又提出了延迟重启(delayed restarts)的概念。也就是说,一个节点崩溃后,先不当即重启它,而是等待一段时间再重启,这段时间应该大于锁的有效时间(lock validity time)。这样的话,这个节点在重启前所参与的锁都会过时,它在重启后就不会对现有的锁形成影响。
关于Redlock还有一点细节值得拿出来分析一下:在最后释放锁的时候,antirez在算法描述中特别强调,客户端应该向全部Redis节点发起释放锁的操做。也就是说,即便当时向某个节点获取锁没有成功,在释放锁的时候也不该该漏掉这个节点。这是为何呢?设想这样一种状况,客户端发给某个Redis节点的获取锁的请求成功到达了该Redis节点,这个节点也成功执行了SET
操做,可是它返回给客户端的响应包却丢失了。这在客户端看来,获取锁的请求因为超时而失败了,但在Redis这边看来,加锁已经成功了。所以,释放锁的时候,客户端也应该对当时获取锁失败的那些Redis节点一样发起请求。实际上,这种状况在异步通讯模型中是有可能发生的:客户端向服务器通讯是正常的,但反方向倒是有问题的。
【其它疑问】
前面在讨论单Redis节点的分布式锁的时候,最后咱们提出了一个疑问,若是客户端长期阻塞致使锁过时,那么它接下来访问共享资源就不安全了(没有了锁的保护)。这个问题在Redlock中是否有所改善呢?显然,这样的问题在Redlock中是依然存在的。
另外,在算法第4步成功获取了锁以后,若是因为获取锁的过程消耗了较长时间,从新计算出来的剩余的锁有效时间很短了,那么咱们还来得及去完成共享资源访问吗?若是咱们认为过短,是否是应该当即进行锁的释放操做?那到底多短才算呢?又是一个选择难题。
Martin Kleppmann在2016-02-08这一天发表了一篇blog,名字叫”How to do distributed locking “,地址以下:
Martin在这篇文章中谈及了分布式系统的不少基础性的问题(特别是分布式计算的异步模型),对分布式系统的从业者来讲很是值得一读。这篇文章大致能够分为两大部分:
首先咱们讨论一下前半部分的关键点。Martin给出了下面这样一份时序图:
在上面的时序图中,假设锁服务自己是没有问题的,它老是能保证任一时刻最多只有一个客户端得到锁。上图中出现的lease这个词能够暂且认为就等同于一个带有自动过时功能的锁。客户端1在得到锁以后发生了很长时间的GC pause,在此期间,它得到的锁过时了,而客户端2得到了锁。当客户端1从GC pause中恢复过来的时候,它不知道本身持有的锁已通过期了,它依然向共享资源(上图中是一个存储服务)发起了写数据请求,而这时锁实际上被客户端2持有,所以两个客户端的写请求就有可能冲突(锁的互斥做用失效了)。
初看上去,有人可能会说,既然客户端1从GC pause中恢复过来之后不知道本身持有的锁已通过期了,那么它能够在访问共享资源以前先判断一下锁是否过时。但仔细想一想,这丝毫也没有帮助。由于GC pause可能发生在任意时刻,也许刚好在判断完以后。
也有人会说,若是客户端使用没有GC的语言来实现,是否是就没有这个问题呢?Martin指出,系统环境太复杂,仍然有不少缘由致使进程的pause,好比虚存形成的缺页故障(page fault),再好比CPU资源的竞争。即便不考虑进程pause的状况,网络延迟也仍然会形成相似的结果。
总结起来就是说,即便锁服务自己是没有问题的,而仅仅是客户端有长时间的pause或网络延迟,仍然会形成两个客户端同时访问共享资源的冲突状况发生。而这种状况其实就是咱们在前面已经提出来的“客户端长期阻塞致使锁过时”的那个疑问。
那怎么解决这个问题呢?Martin给出了一种方法,称为fencing token。fencing token是一个单调递增的数字,当客户端成功获取锁的时候它随同锁一块儿返回给客户端。而客户端访问共享资源的时候带着这个fencing token,这样提供共享资源的服务就能根据它进行检查,拒绝掉延迟到来的访问请求(避免了冲突)。以下图:
在上图中,客户端1先获取到的锁,所以有一个较小的fencing token,等于33,而客户端2后获取到的锁,有一个较大的fencing token,等于34。客户端1从GC pause中恢复过来以后,依然是向存储服务发送访问请求,可是带了fencing token = 33。存储服务发现它以前已经处理过34的请求,因此会拒绝掉此次33的请求。这样就避免了冲突。
如今咱们再讨论一下Martin的文章的后半部分。
Martin在文中构造了一些事件序列,可以让Redlock失效(两个客户端同时持有锁)。为了说明Redlock对系统记时(timing)的过度依赖,他首先给出了下面的一个例子(仍是假设有5个Redis节点A, B, C, D, E):
上面这种状况之因此有可能发生,本质上是由于Redlock的安全性(safety property)对系统的时钟有比较强的依赖,一旦系统的时钟变得不许确,算法的安全性也就保证不了了。Martin在这里实际上是要指出分布式算法研究中的一些基础性问题,或者说一些常识问题,即好的分布式算法应该基于异步模型(asynchronous model),算法的安全性不该该依赖于任何记时假设(timing assumption)。在异步模型中:进程可能pause任意长的时间,消息可能在网络中延迟任意长的时间,甚至丢失,系统时钟也可能以任意方式出错。一个好的分布式算法,这些因素不该该影响它的安全性(safety property),只可能影响到它的活性(liveness property),也就是说,即便在很是极端的状况下(好比系统时钟严重错误),算法顶可能是不能在有限的时间内给出结果而已,而不该该给出错误的结果。这样的算法在现实中是存在的,像比较著名的Paxos,或Raft。但显然按这个标准的话,Redlock的安全性级别是达不到的。
随后,Martin以为前面这个时钟跳跃的例子还不够,又给出了一个由客户端GC pause引起Redlock失效的例子。以下:
Martin给出的这个例子其实有点小问题。在Redlock算法中,客户端在完成向各个Redis节点的获取锁的请求以后,会计算这个过程消耗的时间,而后检查是否是超过了锁的有效时间(lock validity time)。也就是上面的例子中第5步,客户端1从GC pause中恢复过来之后,它会经过这个检查发现锁已通过期了,不会再认为本身成功获取到锁了。随后antirez在他的反驳文章中就指出来了这个问题,但Martin认为这个细节对Redlock总体的安全性没有本质的影响。
抛开这个细节,咱们能够分析一下Martin举这个例子的意图在哪。初看起来,这个例子跟文章前半部分分析通用的分布式锁时给出的GC pause的时序图是基本同样的,只不过那里的GC pause发生在客户端1得到了锁以后,而这里的GC pause发生在客户端1得到锁以前。但两个例子的侧重点不太同样。Martin构造这里的这个例子,是为了强调在一个分布式的异步环境下,长时间的GC pause或消息延迟(上面这个例子中,把GC pause换成Redis节点和客户端1之间的消息延迟,逻辑不变),会让客户端得到一个已通过期的锁。从客户端1的角度看,Redlock的安全性被打破了,由于客户端1收到锁的时候,这个锁已经失效了,而Redlock同时还把这个锁分配给了客户端2。换句话说,Redis服务器在把锁分发给客户端的途中,锁就过时了,但又没有有效的机制让客户端明确知道这个问题。而在以前的那个例子中,客户端1收到锁的时候锁仍是有效的,锁服务自己的安全性能够认为没有被打破,后面虽然也出了问题,但问题是出在客户端1和共享资源服务器之间的交互上。
在Martin的这篇文章中,还有一个颇有见地的观点,就是对锁的用途的区分。他把锁的用途分为两种:
最后,Martin得出了以下的结论:
Martin对Redlock算法的形容是:
neither fish nor fowl (非驴非马)
【其它疑问】
(未完,故事太长,下半部待续)
原文出自:
http://zhangtielei.com/posts/blog-redlock-reasoning.html