使用Redis SETNX 命令实现分布式锁

基于setnx和getsetphp

 

 

http://blog.csdn.net/lihao21/article/details/49104695html

 

使用Redis的 SETNX 命令能够实现分布式锁,下文介绍其实现方法。node

SETNX命令简介

命令格式

SETNX key valuepython

将 key 的值设为 value,当且仅当 key 不存在。 
若给定的 key 已经存在,则 SETNX 不作任何动做。 
SETNX 是SET if Not eXists的简写。git

返回值

返回整数,具体为 
- 1,当 key 的值被设置 
- 0,当 key 的值没被设置github

例子

redis> SETNX mykey “hello” 
(integer) 1 
redis> SETNX mykey “hello” 
(integer) 0 
redis> GET mykey 
“hello” 
redis>redis

使用SETNX实现分布式锁

多个进程执行如下Redis命令:算法

SETNX lock.foo <current Unix time + lock timeout + 1>编程

若是 SETNX 返回1,说明该进程得到锁,SETNX将键 lock.foo 的值设置为锁的超时时间(当前时间 + 锁的有效时间)。 
若是 SETNX 返回0,说明其余进程已经得到了锁,进程不能进入临界区。进程能够在一个循环中不断地尝试 SETNX 操做,以得到锁。安全

解决死锁

考虑一种状况,若是进程得到锁后,断开了与 Redis 的链接(多是进程挂掉,或者网络中断),若是没有有效的释放锁的机制,那么其余进程都会处于一直等待的状态,即出现“死锁”。

上面在使用 SETNX 得到锁时,咱们将键 lock.foo 的值设置为锁的有效时间,进程得到锁后,其余进程还会不断的检测锁是否已超时,若是超时,那么等待的进程也将有机会得到锁。

然而,锁超时时,咱们不能简单地使用 DEL 命令删除键 lock.foo 以释放锁。考虑如下状况,进程P1已经首先得到了锁 lock.foo,而后进程P1挂掉了。进程P2,P3正在不断地检测锁是否已释放或者已超时,执行流程以下:

  • P2和P3进程读取键 lock.foo 的值,检测锁是否已超时(经过比较当前时间和键 lock.foo 的值来判断是否超时)
  • P2和P3进程发现锁 lock.foo 已超时
  • P2执行 DEL lock.foo命令
  • P2执行 SETNX lock.foo命令,并返回1,即P2得到锁
  • P3执行 DEL lock.foo命令将P2刚刚设置的键 lock.foo 删除(这步是因为P3刚才已检测到锁已超时)
  • P3执行 SETNX lock.foo命令,并返回1,即P3得到锁
  • P2和P3同时得到了锁

从上面的状况能够得知,在检测到锁超时后,进程不能直接简单地执行 DEL 删除键的操做以得到锁。

为了解决上述算法可能出现的多个进程同时得到锁的问题,咱们再来看如下的算法。 
咱们一样假设进程P1已经首先得到了锁 lock.foo,而后进程P1挂掉了。接下来的状况:

  • 进程P4执行 SETNX lock.foo 以尝试获取锁
  • 因为进程P1已得到了锁,因此P4执行 SETNX lock.foo 返回0,即获取锁失败
  • P4执行 GET lock.foo 来检测锁是否已超时,若是没超时,则等待一段时间,再次检测
  • 若是P4检测到锁已超时,即当前的时间大于键 lock.foo 的值,P4会执行如下操做 
    GETSET lock.foo <current Unix timestamp + lock timeout + 1>
  • 因为 GETSET 操做在设置键的值的同时,还会返回键的旧值,经过比较键 lock.foo 的旧值是否小于当前时间,能够判断进程是否已得到锁
  • 假如另外一个进程P5也检测到锁已超时,并在P4以前执行了 GETSET 操做,那么P4的 GETSET 操做返回的是一个大于当前时间的时间戳,这样P4就不会得到锁而继续等待。注意到,即便P4接下来将键 lock.foo 的值设置了比P5设置的更大的值也没影响。

另外,值得注意的是,在进程释放锁,即执行 DEL lock.foo 操做前,须要先判断锁是否已超时。若是锁已超时,那么锁可能已由其余进程得到,这时直接执行 DEL lock.foo 操做会致使把其余进程已得到的锁释放掉。

程序代码

用如下python代码来实现上述的使用 SETNX 命令做分布式锁的算法。

LOCK_TIMEOUT = 3
lock = 0
lock_timeout = 0
lock_key = 'lock.foo'

# 获取锁
while lock != 1:
    now = int(time.time())
    lock_timeout = now + LOCK_TIMEOUT + 1
    lock = redis_client.setnx(lock_key, lock_timeout)
    if lock == 1 or (now > int(redis_client.get(lock_key))) and now > int(redis_client.getset(lock_key, lock_timeout)):
        break
    else:
        time.sleep(0.001)

# 已得到锁
do_job()

# 释放锁
now = int(time.time())
if now < lock_timeout:
    redis_client.delete(lock_key)

 

参考资料

 

 

 

 

 

 

 

 

《Redis官方文档》用Redis构建分布式锁

原文连接  译者:yy-leo   校对:方腾飞(红体标记重点)

用Redis构建分布式锁

在不一样进程须要互斥地访问共享资源时,分布式锁是一种很是有用的技术手段。 有不少三方库和文章描述如何用Redis实现一个分布式锁管理器,可是这些库实现的方式差异很大,并且不少简单的实现其实只需采用稍微增长一点复杂的设计就能够得到更好的可靠性。 这篇文章的目的就是尝试提出一种官方权威的用Redis实现分布式锁管理器的算法,咱们把这个算法称为RedLock,咱们相信这个算法会比通常的普通方法更加安全可靠。咱们也但愿社区能一块儿分析这个算法,提供一些反馈,而后咱们以此为基础,来设计出更加复杂可靠的算法,或者 更好的新算法。

 

实现

 

在描述具体的算法以前,下面是已经实现了的项目能够做为参考: Redlock-rb (Ruby实现)。还有一个Redlock-rb的分支,添加了一些特性使得实现分布式锁更简单

安全和可靠性保证

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

  1. 安全属性:互斥,无论任什么时候候,只有一个客户端能持有同一个锁。
  2. 效率属性A:不会死锁,最终必定会获得锁,就算一个持有锁的客户端宕掉或者发生网络分区。
  3. 效率属性B:容错,只要大多数Redis节点正常工做,客户端应该都能获取和释放锁。

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

为了理解咱们想要提升的究竟是什么,咱们先看下当前大多数基于Redis的分布式锁三方库的现状。 用Redis来实现分布式锁最简单的方式就是在实例里建立一个键值,建立出来的键值通常都是有一个超时时间的(这个是Redis自带的超时特性),因此每一个锁最终都会释放(参见前文属性2)。而当一个客户端想要释放锁时,它只须要删除这个键值便可。 表面来看,这个方法彷佛很管用,可是这里存在一个问题:在咱们的系统架构里存在一个单点故障,若是Redis的master节点宕机了怎么办呢?有人可能会说:加一个slave节点!在master宕机时用slave就好了!可是其实这个方案明显是不可行的,由于这种方案没法保证第1个安全互斥属性,由于Redis的复制是异步的。 总的来讲,这个方案里有一个明显的竞争条件(race condition),举例来讲:

  1. 客户端A在master节点拿到了锁。
  2. master节点在把A建立的key写入slave以前宕机了。
  3. slave变成了master节点
  4. 4.B也获得了和A还持有的相同的锁(由于原来的slave里尚未A持有锁的信息)

固然,在某些特殊场景下,前面提到的这个方案则彻底没有问题,好比在宕机期间,多个客户端容许同时都持有锁,若是你能够容忍这个问题的话,那用这个基于复制的方案就彻底没有问题,不然的话咱们仍是建议你采用这篇文章里接下来要描述的方案。

采用单实例的正确实现

在讲述如何用其余方案突破单实例方案的限制以前,让咱们先看下是否有什么办法能够修复这个简单场景的问题,由于这个方案其实若是能够忍受竞争条件的话是有望可行的,并且单实例来实现分布式锁是咱们后面要讲的算法的基础。 要得到锁,要用下面这个命令: SET resource_name my_random_value NX PX 30000 这个命令的做用是在只有这个key不存在的时候才会设置这个key的值(NX选项的做用),超时时间设为30000毫秒(PX选项的做用) 这个key的值设为“my_random_value”。这个值必须在全部获取锁请求的客户端里保持惟一。 基本上这个随机值就是用来保证能安全地释放锁,咱们能够用下面这个Lua脚原本告诉Redis:删除这个key当且仅当这个key存在并且值是我指望的那个值。

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

这个很重要,由于这能够避免误删其余客户端获得的锁,举个例子,一个客户端拿到了锁,被某个操做阻塞了很长时间,过了超时时间后自动释放了这个锁,而后这个客户端以后又尝试删除这个其实已经被其余客户端拿到的锁。因此单纯的用DEL指令有可能形成一个客户端删除了其余客户端的锁,用上面这个脚本能够保证每一个客户单都用一个随机字符串’签名’了,这样每一个锁就只能被得到锁的客户端删除了。

这个随机字符串应该用什么生成呢?我假设这是从/dev/urandom生成的20字节大小的字符串,可是其实你能够有效率更高的方案来保证这个字符串足够惟一。好比你能够用RC4加密算法来从/dev/urandom生成一个伪随机流。还有更简单的方案,好比用毫秒的unix时间戳加上客户端id,这个也许不够安全,可是也许在大多数环境下已经够用了。

key值的超时时间,也叫作”锁有效时间”。这个是锁的自动释放时间,也是一个客户端在其余客户端能抢占锁以前能够执行任务的时间,这个时间从获取锁的时间点开始计算。 因此如今咱们有很好的获取和释放锁的方式,在一个非分布式的、单点的、保证永不宕机的环境下这个方式没有任何问题,接下来咱们看看没法保证这些条件的分布式环境下咱们该怎么作。

Redlock算法

在分布式版本的算法里咱们假设咱们有N个Redis master节点,这些节点都是彻底独立的,咱们不用任何复制或者其余隐含的分布式协调算法。咱们已经描述了如何在单节点环境下安全地获取和释放锁。所以咱们理所固然地应当用这个方法在每一个单节点里来获取和释放锁。在咱们的例子里面咱们把N设成5,这个数字是一个相对比较合理的数值,所以咱们须要在不一样的计算机或者虚拟机上运行5个master节点来保证他们大多数状况下都不会同时宕机。一个客户端须要作以下操做来获取锁:

1.获取当前时间(单位是毫秒)。

2.轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每一个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。好比若是锁自动释放时间是10秒钟,那每一个节点锁请求的超时时间多是5-50毫秒的范围,这个能够防止一个客户端在某个宕掉的master节点上阻塞过长时间,若是一个master节点不可用了,咱们应该尽快尝试下一个master节点。

3.客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(在这里是3个),并且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。

4.若是锁获取成功了,那如今锁自动释放时间就是最初的锁释放时间减去以前获取锁所消耗的时间。

5.若是锁获取失败了,无论是由于获取成功的锁不超过一半(N/2+1)仍是由于总消耗时间超过了锁释放时间,客户端都会到每一个master节点上释放锁,即使是那些他认为没有获取成功的锁。

这个算法是不是异步的?

这个算法是基于一个假设:虽然不存在能够跨进程的同步时钟,可是不一样进程时间都是以差很少相同的速度前进,这个假设不必定彻底准确,可是和自动释放锁的时间长度相比不一样进程时间前进速度差别基本是能够忽略不计的。这个假设就比如真实世界里的计算机:每一个计算机都有本地时钟,可是咱们能够说大部分状况下不一样计算机之间的时间差是很小的。 如今咱们须要更细化咱们的锁互斥规则,只有当客户端能在T时间内完成所作的工做才能保证锁是有效的(详见算法的第3步),T的计算规则是锁失效时间T1减去一个用来补偿不一样进程间时钟差别的delta值(通常只有几毫秒而已) 若是想了解更多基于有限时钟差别的相似系统,能够参考这篇有趣的文章:《Leases: an efficient fault-tolerant mechanism for distributed file cache consistency.

失败的重试

当一个客户端获取锁失败时,这个客户端应该在一个随机延时后进行重试,之因此采用随机延时是为了不不一样客户端同时重试致使谁都没法拿到锁的状况出现。一样的道理客户端越快尝试在大多数Redis节点获取锁,出现多个客户端同时竞争锁和重试的时间窗口越小,可能性就越低,因此最完美的状况下,客户端应该用多路传输的方式同时向全部Redis节点发送SET命令。 这里很是有必要强调一下客户端若是没有在多数节点获取到锁,必定要尽快在获取锁成功的节点上释放锁,这样就不必等到key超时后才能从新获取这个锁(可是若是网络分区的状况发生并且客户端没法链接到Redis节点时,会损失等待key超时这段时间的系统可用性)

释放锁

释放锁比较简单,由于只须要在全部节点都释放锁就行,无论以前有没有在该节点获取锁成功。

安全性的论证

这个算法究竟是不是安全的呢?咱们能够观察不一样场景下的状况来理解这个算法为何是安全的。 开始以前,让咱们假设客户端能够在大多数节点都获取到锁,这样全部的节点都会包含一个有相同存活时间的key。可是须要注意的是,这个key是在不一样时间点设置的,因此这些key也会在不一样的时间超时,可是咱们假设最坏状况下第一个key是在T1时间设置的(客户端链接到第一个服务器时的时间),最后一个key是在T2时间设置的(客户端收到最后一个服务器返回结果的时间),从T2时间开始,咱们能够确认最先超时的key至少也会存在的时间为MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT,TTL是锁超时时间、(T2-T1)是最晚获取到的锁的耗时,CLOCK_DRIFT是不一样进程间时钟差别,这个是用来补偿前面的(T2-T1)。其余的key都会在这个时间点以后才会超时,因此咱们能够肯定这些key在这个时间点以前至少都是同时存在的。

在大多数节点的key都set了的时间段内,其余客户端没法抢占这个锁,由于在N/2+1个客户端的key已经存在的状况下不可能再在N/2+1个客户端上获取锁成功,因此若是一个锁获取成功了,就不可能同时从新获取这个锁成功(否则就违反了分布式锁互斥原则),而后咱们也要确保多个客户端同时尝试获取锁时不会都同时成功。 若是一个客户端获取大多数节点锁的耗时接近甚至超过锁的最大有效时间时(就是咱们为SET操做设置的TTL值),那么系统会认为这个锁是无效的同时会释放这些节点上的锁,因此咱们仅仅须要考虑获取大多数节点锁的耗时小于有效时间的状况。在这种状况下,根据咱们前面的证实,在MIN_VALIDITY时间内,没有客户端能从新获取锁成功,因此多个客户端都能同时成功获取锁的结果,只会发生在多数节点获取锁的时间都大大超过TTL时间的状况下,实际上这种状况下这些锁都会失效 。 咱们很是期待和欢迎有人能提供这个算法安全性的公式化证实,或者发现任何bug。

性能论证

 

这个系统的性能主要基于如下三个主要特征:

1.锁自动释放的特征(超时后会自动释放),必定时间后某个锁都能被再次获取。

2.客户端一般会在再也不须要锁或者任务执行完成以后主动释放锁,这样咱们就不用等到超时时间会再去获取这个锁。

3.当一个客户端须要重试获取锁时,这个客户端会等待一段时间,等待的时间相对来讲会比咱们从新获取大多数锁的时间要长一些,这样能够下降不一样客户端竞争锁资源时发生死锁的几率。

然而,咱们在网络分区时要损失TTL的可用性时间,因此若是网络分区持续发生,这个不可用会一直持续。这种状况在每次一个客户端获取到了锁并在释放锁以前被网络分区了时都会出现。

基原本说,若是持续的网络分区发生的话,系统也会在持续不可用。

性能、故障恢复和fsync

不少使用Redis作锁服务器的用户在获取锁和释放锁时不止要求低延时,同时要求高吞吐量,也即单位时间内能够获取和释放的锁数量。为了达到这个要求,必定会使用多路传输来和N个服务器进行通讯以下降延时(或者也能够用假多路传输,也就是把socket设置成非阻塞模式,发送全部命令,而后再去读取返回的命令,假设说客户端和不一样Redis服务节点的网络往返延时相差不大的话)。

而后若是咱们想让系统能够自动故障恢复的话,咱们还须要考虑一下信息持久化的问题。

为了更好的描述问题,咱们先假设咱们Redis都是配置成非持久化的,某个客户端拿到了总共5个节点中的3个锁,这三个已经获取到锁的节点中随后重启了,这样一来咱们又有3个节点能够获取锁了(重启的那个加上另外两个),这样一来其余客户端又能够得到这个锁了,这样就违反了咱们以前说的锁互斥原则了。

若是咱们启用AOF持久化功能,状况会好不少。举例来讲,咱们能够发送SHUTDOWN命令来升级一个Redis服务器而后重启之,由于Redis超时时效是语义层面实现的,因此在服务器关掉期间时超时时间仍是算在内的,咱们全部要求仍是知足了的。而后这个是基于咱们作的是一次正常的shutdown,可是若是是断电这种意外停机呢?若是Redis是默认地配置成每秒在磁盘上执行一次fsync同步文件到磁盘操做,那就可能在一次重启后咱们锁的key就丢失了。理论上若是咱们想要在全部服务重启的状况下都确保锁的安全性,咱们须要在持久化设置里设置成永远执行fsync操做,可是这个反过来又会形成性能远不如其余同级别的传统用来实现分布式锁的系统。 而后问题其实并不像咱们第一眼看起来那么糟糕,基本上只要一个服务节点在宕机重启后不去参与如今全部仍在使用的锁,这样正在使用的锁集合在这个服务节点重启时,算法的安全性就能够维持,由于这样就能够保证正在使用的锁都被全部没重启的节点持有。 为了知足这个条件,咱们只要让一个宕机重启后的实例,至少在咱们使用的最大TTL时间内处于不可用状态,超过这个时间以后,全部在这期间活跃的锁都会自动释放掉。 使用延时重启的策略基本上能够在不适用任何Redis持久化特性状况下保证安全性,而后要注意这个也必然会影响到系统的可用性。举个例子,若是系统里大多数节点都宕机了,那在TTL时间内整个系统都处于全局不可用状态(全局不可用的意思就是在获取不到任何锁)。

扩展锁来使得算法更可靠

若是客户端作的工做都是由一些小的步骤组成,那么就有可能使用更小的默认锁有效时间,并且扩展这个算法来实现一个锁扩展机制。基本上,客户端若是在执行计算期间发现锁快要超时了,客户端能够给全部服务实例发送一个Lua脚本让服务端延长锁的时间,只要这个锁的key还存在并且值还等于客户端获取时的那个值。 客户端应当只有在失效时间内没法延长锁时再去从新获取锁(基本上这个和获取锁的算法是差很少的) 然而这个并不会对从本质上改变这个算法,因此最大的从新获取锁数量应该被设置成合理的大小,否则性能必然会受到影响。

想提供帮助?

若是你很了解分布式系统的话,咱们很是欢迎你提供一些意见和分析。固然若是能引用其余语言的实现话就更棒了。 谢谢!

原创文章,转载请注明: 转载自并发编程网 – ifeve.com

相关文章
相关标签/搜索