回炉重造之Redis篇:Redlock

安全和可靠性保证

在描述我们的设计之前,我们想先提出三个属性,这三个属性在我们看来,是实现高效分布式锁的基础。

  • 一致性:互斥,不管任何时候,只有一个客户端能持有同一个锁。
  • 分区可容忍性:不会死锁,最终一定会得到锁,就算一个持有锁的客户端宕掉或者发生网络分区。
  • 可用性:只要大多数Redis节点正常工作,客户端应该都能获取和释放锁。

为什么基于故障切换的方案不够好

为了理解我们想要提高的到底是什么,我们先看下当前大多数基于Redis的分布式锁三方库的现状。 用Redis来实现分布式锁最简单的方式就是在实例里创建一个键值,创建出来的键值一般都是有一个超时时间的(这个是Redis自带的超时特性),所以每个锁最终都会释放(参见前文属性2)。而当一个客户端想要释放锁时,它只需要删除这个键值即可。

表面来看,这个方法似乎很管用,但是这里存在一个问题:在我们的系统架构里存在一个单点故障,如果Redis的master节点宕机了怎么办呢?有人可能会说:加一个slave节点!在master宕机时用slave就行了!但是其实这个方案明显是不可行的,因为这种方案无法保证第1个安全互斥属性,因为Redis的复制是异步的。 总的来说,这个方案里有一个明显的竞争条件(race condition),举例来说:

客户端A在master节点拿到了锁。
master节点在把A创建的key写入slave之前宕机了。
slave变成了master节点
B也得到了和A还持有的相同的锁(因为原来的slave里还没有A持有锁的信息)

当然,在某些特殊场景下,前面提到的这个方案则完全没有问题,比如在宕机期间,多个客户端允许同时都持有锁,如果你可以容忍这个问题的话,那用这个基于复制的方案就完全没有问题,否则的话我们还是建议你采用这篇文章里接下来要描述的方案。

采用单实例的正确实现

SET resource_name my_random_value NX PX 30000

上面的命令如果执行成功,则客户端成功获取到了锁,接下来就可以访问共享资源了;而如果上面的命令执行失败,则说明获取锁失败。

这个key的值设为“my_random_value”,这个值必须在所有获取锁请求的客户端里保持唯一,基本上这个随机值就是用来保证能安全地释放锁;
NX: 这个命令的作用是在只有这个key不存在的时候才会设置这个key的值;
PX: 超时时间设为30000毫秒。

最后,当客户端完成了对共享资源的操作之后,执行下面的Redis Lua脚本来释放锁:(ps:删除这个key当且仅当这个key存在而且值是我期望的那个值)

1
2
3
4
5
6

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

释放锁的操作必须使用Lua脚本来实现。释放锁其实包含三步操作:’GET’、判断和’DEL’,用Lua脚本来实现能保证这三步的原子性。
否则,如果把这三步操作放到客户端逻辑中去执行的话,就有可能发生与前面第三个问题类似的执行序列:

客户端1获取锁成功。

客户端1访问共享资源。

客户端1为了释放锁,先执行’GET’操作获取随机字符串的值。

客户端1判断随机字符串的值,与预期的值相等。

客户端1由于某个原因阻塞住了很长时间。

过期时间到了,锁自动释放了。

客户端2获取到了对应同一个资源的锁。

客户端1从阻塞中恢复过来,执行DEL操纵,释放掉了客户端2持有的锁。

在一个非分布式的、单点的、保证永不宕机的环境下这个方式没有任何问题,接下来我们看看无法保证这些条件的分布式环境下我们该怎么做

Redlock算法

redis官网上介绍了一种redlock算法,该算法弃用了单redis节点,采用N个(官网推荐5个)独立的redis节点作为锁服务,客户端要获取锁,必须向N/2+1(绝大部分)节点成功申请锁后,才能访问临界资源。

但是该算法中获取锁的过程变的复杂了,时间也就越不可控,假设从redis1节点获取锁成功开始到从redis(N/2+1)获取锁成功结束到时间为SPACETIME,锁到有效时间不再是key到TTL,而是:TTL-SPACETIME

当SPACETIME比较大时,客户端非常有可能获取到一个已经失效到锁,所以在获取锁之后red lock算法需要再次验证锁是否失效

获取锁

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

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

释放锁

而释放锁的过程比较简单:客户端向所有Redis节点发起释放锁的操作,不管这些节点当时在获取锁的时候成功与否。

基于redis的分布式锁到底安全吗

http://zhangtielei.com/posts/blog-redlock-reasoning.html
http://zhangtielei.com/posts/blog-redlock-reasoning-part2.html

分享到: