redis 与分布式锁

来源:互联网 发布:python magic number 编辑:程序博客网 时间:2024/06/06 12:21

setnx       SET if Not eXists

如果key不存在,设为指定的value,如果key存在,什么也不做。

返回值

   1 : key 被设置,说明key以前不存在

   0: key 未被设置,说明key以前存在

 

redis 2.6.12set命令可以代替setnx,setex,psetex

set 命令增加以下参数

EX  seconds, 设置指定的过期时间,以秒为单位

PX  milliseconds,设置指定的过期时间,以毫秒为单位

NX   仅当key不存在时才设置值

XX   仅当key存在时才设置值

 

simple string reply

     "ok" 代表set正确执行,null reply代表set操作因为NXXX参数而未成功

 

 

 

某个查询数据库的接口,因为调用量比较大,所以加了缓存,并设定缓存过期后刷新,问题是当并发量比较大的时候,如果没有锁机制,那么缓存过期的瞬间,大量并发请求会穿透缓存直接查询数据库,造成雪崩效应,如果有锁机制,那么就可以控制只有一个请求去更新缓存,其它的请求视情况要么等待,要么使用过期的缓存

 

$ok = $redis->setNX($key, $value);

 

if ($ok) {

    $cache->update();

    $redis->del($key);

}

 

 问题1:锁的有效时间

如果setnx成功了,但因为意外原因没执行到del程序就出问题了,或者由于发生了网络分割(network partition)导致它再也无法和Redis节点通信了,导致锁一直存在,以致于

以后缓存再也得不到更新了。于是我们需要在一个事务中执行,并且加一个过期时间

这个过期时间称为锁的有效时间(lock validity time)。获得锁的客户端必须在这个时间之内完成对共享资源的访问。

 

$redis->multi();

$redis->setNX($key, $value);

$redis->expire($key, $ttl);

$redis->exec();

 

因为setnx 不具备设置过期时间的功能,所以我们需要借助expire来设置。

但还有一个问题:当多个请求到达时,虽然只有一个请求的setnx成功了,但是任何一个

请求的expire都可以成功,如此就意味着即便获取不到锁,也可以刷新过期时间,如果请求比较

密集的话,那么过期时间会一直被刷新,导致锁一直有效。于是需要用lua脚本来实现

 

local key   = KEYS[1]

local value = KEYS[2]

local ttl   = KEYS[3]

 

local ok = redis.call('setnx', key, value)

 

if ok == 1 then

  redis.call('expire', key, ttl)

end

 

return ok

 

 

 

2.6.12 起,SET涵盖了SETEX的功能,并且SET本身已经包含了设置过期时间的功能,也就是说,我们前面需要的功能只用SET就可以实现。

 

SET resource-name anystring NX EX max-lock-time

 

$ok = $redis->set($key, $value, array('nx', 'ex' => $ttl));

 

if ($ok) {

    $cache->update();

    $redis->del($key);

}

 

 问题2:随机字符串

设置一个随机字符串my_random_value是很有必要的,它保证了一个客户端释放的锁必须是自己持有的那个锁

 

如上代码是完美的吗?答案是还差一点!设想一下,如果一个请求更新缓存的时间比较长(或者由于gc等原因),甚至比锁的有效期还要长,导致在缓存更新过程中,锁就失效了,此时另一个请求会获取锁,但前一个请求在缓存更新完毕的时候,如果不加以判断直接删除锁,就会出现误删除其它请求创建的锁的情况,所以我们在创建锁的时候需要引入一个随机值:

 

$ok = $redis->set($key, $random, array('nx', 'ex' => $ttl));

 

if ($ok) {

    $cache->update();

 

    if ($redis->get($key) == $random) {

        $redis->del($key);

    }

}

 

 

这段代码还有些问题,所以引出问题3

 问题3:释放锁的操作必须使用Lua脚本来实现。

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

客户端1获取锁成功。

客户端1访问共享资源。

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

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

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

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

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

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

 

如果不是客户端阻塞住了,而是出现了大的网络延迟,也有可能导致类似的执行序列发生。

 

如下lua脚本可以解决问题3

 

if redis.call("get",KEYS[1]) == ARGV[1]

then

    return redis.call("del",KEYS[1])

else

    return 0

end

 

 

 

假如Redis节点宕机了,那么所有客户端就都无法获得锁了,服务变得不可用。为了提高可用性,我们可以给这个Redis节点挂一个Slave,当Master节点不可用的时候,系统自动切到Slave上(failover)。但由于Redis的主从复制(replication)是异步的,这可能导致在failover过程中丧失锁的安全性。考虑下面的执行序列:

客户端1Master获取了锁。

Master宕机了,存储锁的key还没有来得及同步到Slave上。

Slave升级为Master

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

于是,客户端1和客户端2同时持有了同一个资源的锁。锁的安全性被打破。针对这个问题,antirez设计了Redlock算法

 

【其它疑问】

 

前面这个算法中出现的锁的有效时间(lock validity time),设置成多少合适呢?如果设置太短的话,锁就有可能在客户端完成对于共享资源的访问之前过期,从而失去保护;如果设置太长的话,一旦某个持有锁的客户端释放锁失败,那么就会导致所有其它客户端都无法获取锁,从而长时间内无法正常工作。看来真是个两难的问题。

 

而且,在前面对于随机字符串my_random_value的分析中,antirez也在文章中承认的确应该考虑客户端长期阻塞导致锁过期的情况。如果真的发生了这种情况,那么共享资源是不是已经失去了保护呢?antirez重新设计的Redlock是否能解决这些问题呢?

 

 

 

 分布式锁redlock

参考url

https://mp.weixin.qq.com/s/JTsJCDuasgIJ0j95K8Ay8w

 

https://mp.weixin.qq.com/s/4CUe7OpM6y1kQRK8TOC_qQ

 

https://redis.io/topics/distlock

 

由于前面介绍的基于单Redis节点的分布式锁在failover的时候会产生解决不了的安全性问题,因此antirez提出了新的分布式锁的算法Redlock,它基于N个完全独立的Redis节点(通常情况下N可以设置成5)。

 

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

1获取当前时间(毫秒数)。

2按顺序依次向NRedis节点执行获取锁的操作。这个获取操作跟前面基于单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节点发起释放锁的操作,不管这些节点当时在获取锁的时候成功与否。

 

 

 

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

 

假设一共有5Redis节点:A, B, C, D, E。设想发生了如下的事件序列:

客户端1成功锁住了A, B, C,获取锁成功(但DE没有锁住)。

节点C崩溃重启了,但客户端1C上加的锁没有持久化下来,丢失了。

节点C重启后,客户端2锁住了C, D, E,获取锁成功。

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

 

 

在默认情况下,RedisAOF持久化方式是每秒写一次磁盘(即执行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的分析

Martin Kleppmann2016-02-08这一天发表了一篇blog,名字叫"How to do distributed locking

",地址如下:

https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

Martin在这篇文章中谈及了分布式系统的很多基础性的问题(特别是分布式计算的异步模型),对分布式系统的从业者来说非常值得一读。这篇文章大体可以分为两大部分:

前半部分,与Redlock无关。Martin指出,即使我们拥有一个完美实现的分布式锁(带自动过期功能),在没有共享资源参与进来提供某种fencing机制的前提下,我们仍然不可能获得足够的安全性。

后半部分,是对Redlock本身的批评。Martin指出,由于Redlock本质上是建立在一个同步模型之上,对系统的记时假设(timing assumption)有很强的要求,因此本身的安全性是不够的。

首先我们讨论一下前半部分的关键点。Martin给出了下面这样一份时序图:



 

 

在上面的时序图中,假设锁服务本身是没有问题的,它总是能保证任一时刻最多只有一个客户端获得锁。上图中出现的lease这个词可以暂且认为就等同于一个带有自动过期功能的锁。客户端1在获得锁之后发生了很长时间的GC pause,在此期间,它获得的锁过期了,而客户端2获得了锁。当客户端1GC pause中恢复过来的时候,它不知道自己持有的锁已经过期了,它依然向共享资源(上图中是一个存储服务)发起了写数据请求,而这时锁实际上被客户端2持有,因此两个客户端的写请求就有可能冲突(锁的互斥作用失效了)。

 

初看上去,有人可能会说,既然客户端1GC pause中恢复过来以后不知道自己持有的锁已经过期了,那么它可以在访问共享资源之前先判断一下锁是否过期。但仔细想想,这丝毫也没有帮助。因为GC pause可能发生在任意时刻,也许恰好在判断完之后。

 

也有人会说,如果客户端使用没有GC的语言来实现,是不是就没有这个问题呢?Martin指出,系统环境太复杂,仍然有很多原因导致进程的pause,比如虚存造成的缺页故障(page fault),再比如CPU资源的竞争。即使不考虑进程pause的情况,网络延迟也仍然会造成类似的结果。

 

总结起来就是说,即使锁服务本身是没有问题的,而仅仅是客户端有长时间的pause或网络延迟,仍然会造成两个客户端同时访问共享资源的冲突情况发生。而这种情况其实就是我们在前面已经提出来的“客户端长期阻塞导致锁过期”的那个疑问。

 

那怎么解决这个问题呢?Martin给出了一种方法,称为fencing tokenfencing token是一个单调递增的数字,当客户端成功获取锁的时候它随同锁一起返回给客户端。而客户端访问共享资源的时候带着这个fencing token,这样提供共享资源的服务就能根据它进行检查,拒绝掉延迟到来的访问请求(避免了冲突)。如下图:

 




在上图中,客户端1先获取到的锁,因此有一个较小的fencing token,等于33,而客户端2后获取到的锁,有一个较大的fencing token,等于34。客户端1GC pause中恢复过来之后,依然是向存储服务发送访问请求,但是带了fencing token = 33。存储服务发现它之前已经处理过34的请求,所以会拒绝掉这次33的请求。这样就避免了冲突。

 

现在我们再讨论一下Martin的文章的后半部分。

 

Martin在文中构造了一些事件序列,能够让Redlock失效(两个客户端同时持有锁)。为了说明Redlock对系统记时(timing)的过分依赖,他首先给出了下面的一个例子(还是假设有5Redis节点A, B, C, D, E):

客户端1Redis节点A, B, C成功获取了锁(多数节点)。由于网络问题,与DE通信失败。

节点C上的时钟发生了向前跳跃,导致它上面维护的锁快速过期。

客户端2Redis节点C, D, E成功获取了同一个资源的锁(多数节点)。

客户端1和客户端2现在都认为自己持有了锁。

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

 

随后,Martin觉得前面这个时钟跳跃的例子还不够,又给出了一个由客户端GC pause引发Redlock失效的例子。如下:

客户端1Redis节点A, B, C, D, E发起锁请求。

各个Redis节点已经把请求结果返回给了客户端1,但客户端1在收到请求结果之前进入了长时间的GC pause

在所有的Redis节点上,锁过期了。

客户端2A, B, C, D, E上获取到了锁。

客户端1GC pause从恢复,收到了前面第2步来自各个Redis节点的请求结果。客户端1认为自己成功获取到了锁。

客户端1和客户端2现在都认为自己持有了锁。

Martin给出的这个例子其实有点小问题。在Redlock算法中,客户端在完成向各个Redis节点的获取锁的请求之后,会计算这个过程消耗的时间,然后检查是不是超过了锁的有效时间(lock validity time)。也就是上面的例子中第5步,客户端1GC 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的这篇文章中,还有一个很有见地的观点,就是对锁的用途的区分。他把锁的用途分为两种:

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

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

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

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

如果是为了正确性(correctness)在很严肃的场合使用分布式锁,那么不要使用Redlock。它不是建立在异步模型上的一个足够强的算法,它对于系统模型的假设中包含很多危险的成分(对于timing)。而且,它没有一个机制能够提供fencing token。那应该使用什么技术呢?Martin认为,应该考虑类似Zookeeper的方案,或者支持事务的数据库。

MartinRedlock算法的形容是:

neither fish nor fowl (非驴非马)

 

【其它疑问】

Martin提出的fencing token的方案,需要对提供共享资源的服务进行修改,这在现实中可行吗?

根据Martin的说法,看起来,如果资源服务器实现了fencing token,它在分布式锁失效的情况下也仍然能保持资源的互斥访问。这是不是意味着分布式锁根本没有存在的意义了?

资源服务器需要检查fencing token的大小,如果提供资源访问的服务也是包含多个节点的(分布式的),那么这里怎么检查才能保证fencing token在多个节点上是递增的呢?

Martin对于fencing token的举例中,两个fencing token到达资源服务器的顺序颠倒了(小的fencing token后到了),这时资源服务器检查出了这一问题。如果客户端1和客户端2都发生了GC pause,两个fencing token都延迟了,它们几乎同时达到了资源服务器,但保持了顺序,那么资源服务器是不是就检查不出问题了?这时对于资源的访问是不是就发生冲突了?

分布式锁+fencing的方案是绝对正确的吗?能证明吗?

 

 

 antirez的反驳

 

https://mp.weixin.qq.com/s/4CUe7OpM6y1kQRK8TOC_qQ

 

原创粉丝点击