很多应用都利用Redis实现轻量级分布式锁,但大多数实现都存在一些问题。本文翻译Redis官网Distributed locks with Redis文档的核心内容,使大家快速了解Redis推荐的分布式锁实现方案。
分布式锁的基本特性
-
安全属性(Safety property): 独享(相互排斥)。在任意一个时刻,只有一个客户端持有锁。
-
活性A(Liveness property A): 无死锁。即便持有锁的客户端崩溃(crashed)或者网络被分裂(gets partitioned),锁仍然可以被获取。
-
活性B(Liveness property B): 容错。 只要大部分Redis节点都活着,客户端就可以获取和释放锁。
常见实现及问题
常见实现
最简单的方法就是在Redis中创建一个key,这个key有一个失效时间(TTL),以保证锁最终会被自动释放掉(这个对应特性2)。当客户端释放资源(解锁)的时候,会删除掉这个key。
问题
这个架构存在一个严重的单点失败问题。如果Redis挂了怎么办?你可能会说,可以通过增加一个slave节点解决这个问题。但这会导致上面特性1失效,因为Redis的主从同步通常是异步的,Redis是AP系统。
例如下面场景
- 客户端A从master获取到锁
- 在master将锁同步到slave之前,master宕掉了。
- slave节点被晋级为master节点
- 客户端B取得了同一个资源被客户端A已经获取到的另外一个锁。安全失效!
官网推荐实现
单Redis节点实现分布式锁
SET resource_name my_random_value NX PX 30000
resource_name
- 锁的名字
my_random_value
- 随机值,用来区分不同的客户端
NX
- 当key不存在时才生产
PX
- 自动失效时间
在释放锁的时候也需要提供my_random_value
,只有一致才会删除锁,通过下面Lua脚本实现
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这种方式可以避免删除别的客户端成功获取的锁
上面的实现在Redis单节点上能够很好地工作,但单节点不能提供高可用性。
Redis集群上实现分布式锁 - Redlock算法
针对N个相互独立的Master节点,采用上述的单节点方式获取或释放锁。
客户端操作步骤
-
获取当前系统时间,以毫秒为单位。
-
依次尝试从N个节点(例如5个),使用相同的key和随机值获取锁。此步骤中,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免在Redis节点已经挂掉的情况下,客户端一直等待响应结果。如果一个节点没有响应,客户端应该尽快尝试下一个Redis节点。
-
客户端使用当前时间减去开始获取锁时间(步骤1的时间)就得到获取锁的使用时间。当且仅当至少从N/2+1(例如3个)的节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
-
如果取到了锁,key的真正有效使用时间就是步骤3计算的时间。
-
如果获取锁失败(没有在至少N/2+1个节点上取到锁或者有效时间为负),客户端应该在所有的Redis节点上进行解锁(即便某些节点根本就没有加锁成功)。
算法要点
时钟同步
算法基于这样一个假设:虽然多个节点之间没有时钟同步,但每个节点都以相同的时钟频率前进,它们的时间差相对于失效时间来说几乎可以忽略不计。这和现实世界非常接近:每个计算机都有一个本地时钟,我们可以容忍多个计算机之间有较小的时钟漂移。
这里,要再次强调互斥规则:只有在锁的有效使用时间(步骤3计算的结果)范围内客户端能够做完它的工作,锁的安全性才能得到保证(锁的实际有效使用时间通常要短一些,因为计算机之间有时钟漂移的现象)。
失败重试
当客户端无法取到锁时,应该在一个随机延迟后重试, 以防止多个客户端在同时抢夺同一节点的锁(这样会导致脑裂,没有人会取到锁)。同样,客户端取得大部分节点的锁所花费的时间越短,脑裂出现的概率就会越低(必要的重试),所以,理想情况下,客户端应该同时(并发地)向所有节点发送SET命令。
需要强调,当客户端从大多数Redis节点获取锁失败时,应该尽快地释放(部分)已经成功取到的锁,这样其他的客户端就不必非得等到锁失效后才能取到(然而,如果发生脑裂,客户端无法和部分Redis节点通信,此时就只能等待锁自动释放,等于被惩罚了)。
释放锁
释放锁比较简单,向所有的Redis节点发送释放锁命令即可,不用考虑之前在哪些节点上获取过锁.
算法分析
Redlock算法可以满足前面提到的3个基本特性
1. 安全属性
任意时刻,只有一个客户端可以获得N/2+1个节点上的锁,假设有个客户端满足这个条件,其开始时间为T1向第一个节点发送SET命令前的时间,结束时间为T2得到第N/2+1个节点响应的时间,锁自动释放时间为TTL,节点间时钟漂移时间为CLOCK_DRIFT则其获取锁的有效使用时间至少为TTL-(T2-T1)-CLOCK_DRIFT,在此时间内,这N/2+1个节点上的锁都不会释放,也就不会有其它客户端能够获得N/2+1个节点的锁。同时如果其它客户端在超过此时间获取到了足够多的锁,但总时间势必超过TTL(因为需要至少一个节点上的锁被前一个客户端释放),根据算法认为锁失效,需要释放所有的锁。
2. 活性A无死锁
客户端如果获取到锁,使用完会主动释放,如果没有获取到锁,会立刻释放占有的锁。另外,锁上设有自动过期是时间TTL,即使客户端崩溃或者脑裂,TTL到期后也会释放。
3. 活性B容错
只要大多数节点工作,客户端就有可能获得锁,但会增大冲突的可能性。为此,要求客户端失败时,需要等待一个大约正常获取锁的随机时间,以最大限度的避免死锁冲突。
节点崩溃恢复
上面步骤存在一个问题,如果某个获得锁的节点突然崩溃,并且此时客户端获得的锁还没有同步到文件上,那么当节点恢复时,就会丢失锁数据。这就会导致其它客户端可能获取锁。
这个问题可以通过配置AOF为fsync=always解决,但这样会完全破坏Redis的性能。推荐通过延迟重启的方式解决,当一个Redis节点重启后,在一个TTL时间内,对客户端不可用,这样就可以保证满足安全属性。
锁扩展
建议将自动释放时间TTL设置短一些,根据上面的分析,这样可以在集群异常时,更快地释放锁,重新提供服务。对于运行时间比较长的客户端,可以采用锁扩展技术,即当剩余有效使用时间很少时,重新向所有节点发送一个Lua脚本,延长TTL,这个操作也需要在大多数节点(N/2+1)上成功,并且是在有效时间内再次获取到锁。由于此时,此客户端持有大多数节点的锁,不会发生锁竞争,应该很快成功。这个方法的一个弊端是,导致某个客户端长时间占有锁,导致其它客户端无法成功取得锁,需要根据实际情况判断使用。
网友评论