redis分布式锁安全性的探讨

本文转载、整理自上篇:http://mp.weixin.qq.com/s/JTsJCDuasgIJ0j95K8Ay8w
与下篇:http://mp.weixin.qq.com/s/4CUe7OpM6y1kQRK8TOC_qQ两篇博文
其中省略了Martin提出的fencing token有关的讨论(上篇)、网友与Redis作者的讨论(下篇)以及Chubby的介绍(下篇)。
想详细看这三方面的朋友可以移至原文阅读。
最后引用一下Martin的话:Engineering discussions rarely have one right answer.

基于单Redis节点的分布式锁

首先,Redis客户端为了获取锁,向Redis节点发送如下命令:
SET resource_name my_random_value NX PX 30000
命令执行成功:成功获取了锁,可以访问共享资源了;
命令执行失败:获取锁失败。
注意,在上面的SET命令中:

  • my_random_value是由客户端生成的一个随机字符串,它要保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的。

  • NX(Not if Exist)表示只有当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]的值传进去。

SET resource_name my_random_value NX PX 30000
命令中容易被忽略的4个细节:

  1. 这个锁必须要设置一个有效时间(lock validity
    time)。否则的话,当一个客户端获取该锁成功之后,假如它崩溃了(单机存在的问题),或者由于发生了网络分割(network
    partition)导致它再也无法和Redis节点通信了,那么它就会一直持有这个锁,而其它客户端永远无法获得该锁了。(同时,有效时间设置也是一个两难的问题,如果设置太短的话,锁就有可能在客户端完成对于共享资源的访问之前过期,从而失去保护;如果设置太长的话,一旦某个持有锁的客户端释放锁失败,那么就会导致所有其它客户端都无法获取锁,从而长时间内无法正常工作。)
  2. 在获取锁的操作时,有人把它实现成了两个Redis命令: SETNX resource_name my_random_value
    EXPIRE resource_name 30 虽然这两个命令和前面算法描述中的一个SET命令执行效果相同,但却不是原子的
    如果客户端在执行完SETNX后崩溃了,那么就没有机会执行EXPIRE了,导致它一直持有这个锁。
  3. 设置一个随机字符串my_random_value是很有必要的它保证了客户端释放的锁必须是自己持有的锁。假如获取锁时SET的不是一个随机字符串,而是一个固定值,那么可能会发生下面的执行序列:
    1. 客户端1获取锁成功。
    2. 客户端1在某个操作上阻塞了很长时间。
    3. 过期时间到了,锁自动释放了。
    4. 客户端2获取到了对应同一个资源的锁。
    5. 客户端1从阻塞中恢复过来,释放掉了客户端2持有的锁。
  4. 释放锁的操作必须使用Lua脚本来实现。释放锁其实包含三步操作:’GET’、判断和’DEL’,用Lua实现能保证这三步的原子性。否则,如果把这三步操作放到客户端逻辑中去执行的话,就有可能发生与前面第三个问题类似的执行序列:
    1. 客户端1获取锁成功。
    2. 客户端1操作共享资源后进入判断要释放锁
    3. 客户端1由于某个原因阻塞住了很长时间。
    4. 过期时间到了,锁自动释放了。
    5. 客户端2获取到了对应同一个资源的锁。(要么实现随机字符串的唯一性,要么实现释放锁的原子性操作)
    6. 客户端1从阻塞中恢复过来,执行DEL操纵,释放掉了客户端2持有的锁。 实际上,在上述第三个问题和第四个问题的分析中,如果不是客户端阻塞住了,而是出现了大的网络延迟,也有可能导致类似的执行序列发生

基于Redis集群的分布式锁

前面的四个细节,只要实现的时候加以注意,就都能够被正确处理。但除此之外,由failover(失败切换重试)引起的问题,却是基于单Redis节点的分布式锁无法解决的。正是这个问题催生了Redlock的出现。
Redlock是为了规范各家对基于Redis的分布式锁的实现,而由Redis的作者提出的一个更安全的实现。
当单Redis节点宕机了,那么所有客户端就都无法获得锁了,服务变得不可用。为了提高可用性,我们可以给这个Redis节点挂一个Slave,当Master节点不可用的时候,系统自动切到Slave上(failover)。但由于Redis的主从复制(replication)是异步的,这可能导致在failover过程中丧失锁的安全性。考虑下面的执行序列:

  1. 客户端1从Master获取了锁。
  2. Master宕机了,存储锁的key还没有来得及同步到Slave上。
  3. Slave升级为Master。
  4. 客户端2从新的Master获取到了对应同一个资源的锁。

于是,客户端1和客户端2同时持有了同一个资源的锁。锁的安全性被打破。针对Redis主从复制过程中可能存在丧失锁的安全性问题,Redis作者设计了Redlock算法。

运行Redlock算法的客户端依次执行下面各个步骤,来完成获取锁的操作

  1. 获取当前时间(毫秒数)。
  2. 按顺序依次向N个Redis节点执行获取锁的操作。这个获取操作跟前面基于单Redis节点的获取锁的过程相同,包含随机字符串my_random_value,也包含过期时间(比如PX 30000,即锁的有效时间)。为了保证在某个Redis节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个Redis节点获取锁失败以后,应该立即尝试下一个Redis节点。这里的失败,应该包含任何类型的失败,比如该Redis节点不可用,或者该Redis节点上的锁已经被其它客户端持有(注:Redlock原文中这里只提到了Redis节点不可用的情况,但也应该包含其它的失败情况)。
  3. 计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数Redis节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。
  4. 如果最终获取锁成功了,那么这个锁的有效时间等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间
  5. 如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有Redis节点发起释放锁的操作(即前面介绍的Redis Lua脚本)。

当然,上面描述的只是获取锁的过程,而释放锁的过程比较简单:客户端向所有Redis节点发起释放锁的操作,不管这些节点当时在获取锁的时候成功与否。也就是说,即使当时向某个节点获取锁没有成功,在释放锁的时候也不应该漏掉这个节点。这是因为存在这样一种情况,客户端发给某个Redis节点的获取锁的请求成功到达了该Redis节点,这个节点也成功执行了SET操作,但是它返回给客户端的响应包却丢失了。这在客户端看来,获取锁的请求由于超时而失败了,但在Redis这边看来,加锁已经成功了。因此,释放锁的时候,客户端也应该对当时获取锁失败的那些Redis节点同样发起请求。实际上,这种情况在异步通信模型中是有可能发生的:客户端向服务器通信是正常的,但反方向却是有问题的。

由于N个Redis节点中的大多数能正常工作就能保证Redlock正常工作,因此理论上它的可用性更高。我们前面讨论的单Redis节点的分布式锁在failover的时候锁失效的问题,在Redlock中不存在了,但如果有节点发生崩溃重启,还是会对锁的安全性有影响的。具体的影响程度跟Redis对数据的持久化程度有关。
假设一共有5个Redis节点:A, B, C, D, E。设想发生了如下的事件序列:

  1. 客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住)。
  2. 节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了。
  3. 节点C重启后,客户端2锁住了C, D, E,获取锁成功。

这样,客户端1和客户端2同时获得了锁(针对同一资源)。

默认情况下,Redis的AOF持久化方式是每秒写一次磁盘(即执行fsync),因此最坏情况下可能丢失1秒的数据。为了尽可能不丢数据,Redis允许设置成每次修改数据都进行fsync,但这会降低性能。当然,即使执行了fsync也仍然有可能丢失数据(这取决于系统而不是Redis的实现)。所以,由于节点崩溃重启引发的锁失效问题,总是有可能出现的。为了应对这一问题,作者又提出了延迟重启(delayed restarts)的概念。也就是说,一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,这段时间应该大于锁的有效时间(lock validity time)。这样的话,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响。

Martin(一个分布式专家)认为Redlock对系统记时(timing)的过分依赖(Redis中EX或PX时间都依靠服务器时间,如果手动调一下服务器时间使其大于有效时间,那么这个键值对将立刻过期),他首先给出了下面的一个例子(还是假设有5个Redis节点A, B, C, D, E):

  1. 客户端1从Redis节点A, B, C成功获取了锁(多数节点)。由于网络问题,与D和E通信失败。
  2. 节点C上的时钟发生了向前跳跃,导致它上面维护的锁快速过期
  3. 客户端2从Redis节点C, D, E成功获取了同一个资源的锁(多数节点)。
  4. 客户端1和客户端2现在都认为自己持有了锁。

Redlock的安全性(safety property)对系统的时钟有比较强的依赖,一旦系统的时钟变得不准确,算法的安全性也就保证不了了。Martin在这里其实是要指出分布式算法研究中的一些常识问题,即好的分布式算法应该基于异步模型(asynchronous model),算法的安全性不应该依赖于任何记时假设(timing assumption)。在异步模型中:进程可能pause任意长的时间,消息可能在网络中延迟任意长的时间,甚至丢失,系统时钟也可能以任意方式出错。一个好的分布式算法,这些因素不应该影响它的安全性(safety property),只可能影响到它的活性(liveness property),也就是说,即使在非常极端的情况下(比如系统时钟严重错误),算法顶多是不能在有限的时间内给出结果而已,而不应该给出错误的结果。这样的算法在现实中是存在的,像比较著名的Paxos,或Raft。但显然按这个标准的话,Redlock的安全性级别是达不到的。

Martin还提出一个很有见地的观点,就是对锁的用途的区分。他把锁的用途分为两种:

  • 为了效率(efficiency),协调各个客户端避免做重复的工作。即使锁偶尔失效了,只是可能把某些操作多做一遍而已,不会产生其它的不良后果。比如重复发送了一封同样的email。

  • 为了正确性(correctness)。在任何情况下都不允许锁失效的情况发生,因为一旦发生,就可能意味着数据不一致(inconsistency),数据丢失,文件损坏,或者其它严重的问题。

最后,Martin得出了如下的结论:

  • 如果是为了效率(efficiency)而使用分布式锁,允许锁的偶尔失效,那么使用单Redis节点的锁方案就足够了,简单而且效率高。Redlock则是个过重的实现(heavyweight)

  • 如果是为了正确性(correctness)在很严肃的场合使用分布式锁,那么不要使用Redlock。它不是建立在异步模型上的一个足够强的算法,它对于系统模型的假设中包含很多危险的成分(对于timing)。应该考虑类似Zookeeper的分布式锁方案(这也是目前企业流行方案),或者支持事务的数据库。

到此,Martin认为Redlock会失效的情况主要有三种:

  • 时钟发生跳跃。
  • 长时间的GC pause。
  • 长时间的网络延迟。

对于后两种情况来说,Redlock在当初设计的时候已经考虑到了,对它们引起的后果有一定的免疫力。并且对于大延迟给Redlock带来的影响与所有的分布式锁是一致的,而这种影响不单单针对Redlock。Redlock的实现已经保证了它是和其它任何分布式锁的安全性是一样的
关键在于时钟跳跃,Redis作者认为通过恰当的运维,完全可以避免时钟发生大的跳动,而Redlock对于时钟的要求在现实系统中是完全可以满足的。(实际中:时钟偏移在现实中是存在的)
Martin在提到时钟跳跃的时候,举了两个可能造成时钟跳跃的具体例子:

  • 系统管理员手动修改了时钟。

  • 从NTP服务收到了一个大的时钟更新事件。

Redis作者反驳说:

  • 手动修改时钟这种人为原因,不要那么做就是了。否则的话,如果有人手动修改Raft协议的持久化日志,那么就算是Raft协议它也没法正常工作了。

  • 使用一个不会进行“跳跃”式调整系统时钟的ntpd程序(可能是通过恰当的配置),对于时钟的修改通过多次微小的调整来完成。

而Redlock对时钟的要求,并不需要完全精确,它只需要时钟差不多精确就可以了。比如,要记时5秒,但可能实际记了4.5秒,然后又记了5.5秒,有一定的误差。不过只要误差不超过一定范围,这对Redlock不会产生影响。antirez认为呢,像这样对时钟精度并不是很高的要求,在实际环境中是完全合理的。

基于ZK的分布式锁

Flavio Junqueira是ZooKeeper的作者之一。他给出了一个基于ZooKeeper构建分布式锁的描述(当然这不是唯一的方式):

  • 客户端尝试创建一个znode节点,比如/lock。那么第一个客户端就创建成功了,相当于拿到了锁;而其它的客户端会创建失败(znode已存在),获取锁失败。

  • 持有锁的客户端访问共享资源完成后,将znode删掉,这样其它客户端接下来就能来获取锁了。

  • znode应该被创建成ephemeral的。这是znode的一个特性,它保证如果创建znode的那个客户端崩溃了,那么相应的znode会被自动删除。这保证了锁一定会被释放。

看起来这个锁相当完美,没有Redlock过期时间的问题,而且能在需要的时候让锁自动释放。但仔细考察的话,并不尽然。
ZooKeeper是怎么检测出某个客户端已经崩溃了呢?实际上,每个客户端都与ZooKeeper的某台服务器维护着一个Session,这个Session依赖定期的心跳(heartbeat)来维持。如果ZooKeeper长时间收不到客户端的心跳(这个时间称为Sesion的过期时间),那么它就认为Session过期了,通过这个Session所创建的所有的ephemeral类型的znode节点都会被自动删除。
设想如下的执行序列:

  1. 客户端1创建了znode节点/lock,获得了锁。
  2. 客户端1进入了长时间的GC pause。
  3. 客户端1连接到ZooKeeper的Session过期了。znode节点/lock被自动删除。
  4. 客户端2创建了znode节点/lock,从而获得了锁。
  5. 客户端1从GC pause中恢复过来,它仍然认为自己持有锁。

最后,客户端1和客户端2都认为自己持有了锁,冲突了。这与之前Martin在文章中描述的由于GC pause导致的分布式锁失效的情况类似

看起来,用ZooKeeper实现的分布式锁也不一定就是安全的。该有的问题它还是有。但是,ZooKeeper作为一个专门为分布式应用提供方案的框架,它提供了一些非常好的特性,是Redis之类的方案所没有的。像前面提到的ephemeral类型的znode自动删除的功能就是一个例子。
还有一个很有用的特性是ZooKeeper的watch机制这个机制可以这样来使用,比如当客户端试图创建/lock的时候,发现它已经存在了,这时候创建失败,但客户端不一定就此对外宣告获取锁失败。客户端可以进入一种等待状态,等待当/lock节点被删除的时候,ZooKeeper通过watch机制通知它,这样它就可以继续完成创建操作(获取锁)。这可以让分布式锁在客户端用起来就像一个本地的锁一样:加锁失败就阻塞住,直到获取到锁为止。这样的特性Redlock就无法实现。

小结一下,基于ZooKeeper的锁和基于Redis的锁相比在实现特性上有两个不同:

  • 在正常情况下,客户端可以持有锁任意长的时间,这可以确保它做完所有需要的资源访问操作之后再释放锁。这避免了基于Redis的锁对于有效时间(lock validity time)到底设置多长的两难问题。实际上,基于ZooKeeper的锁是依靠Session(心跳)来维持锁的持有状态的,而Redis不支持Sesion。

  • 基于ZooKeeper的分布式锁支持在获取锁失败之后等待该锁重新释放后再获取。这让客户端对锁的使用更加灵活。

顺便提一下,如上所述的基于ZooKeeper的分布式锁的实现,并不是最优的。它会引发“herd effect”(羊群效应),降低获取锁的性能。一个更好的实现参见下面链接:
● http://zookeeper.apache.org/doc/r3.4.9/recipes.html#sc_recipes_Locks

你可能感兴趣的:(中间件)