保证分布式锁有效的三个属性
我们先来看看现有大多数 Redis 分布式锁的实现。
最简单的方案是在一个实例中创建一个 key,并给这个 key 设置过期时间,保证这个锁最终一定能够释放,当客户端释放锁的时候,删除这个 key
看上去可能不错,但是有个问题:当我们的 Redis 主节点挂掉时会发生什么?好,那我们增加一个从节点,当主节点不可用时自动切换到从节点。但不幸地是这不行,因为 Redis 复制是异步的,所以不能保证互斥性。
在这个方案下有一个明显的竞态条件:
SAFETY VIOLATION 违反了文章开头提到的安全属性
虽然有上述缺陷,但在一些特殊场景下,这种方案还是可以使用的。比如故障发生的时候,多个客户端同时持有锁对于系统运行或者业务逻辑没有太大影响,那么就可以使用这种基于复制的解决方案。否则最好还是使用本文后续将会提到的 Redlock 算法。
在解决单实例单点故障的限制前,我们先来看看如何正确地执行它
获取一个锁的方式:
set resource_name my_random_value NX PX 30000
解释:
在 resource_name 不存在(NX 选项)的时候创建它,并设置过期时间为 30000 毫秒。
值是一个随机值,而且这个值在每个客户端和每个锁请求中都是唯一的,这样做的目的是为了能够安全地释放锁,不会出现 A 客户端获取的锁被 B 客户端删除的情况。使用一段简单的 lua 代码告诉 Redis,只有 key 存在而且值与当前客户端持有的值相等时才删除这个 key。
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
防止由其他客户端创建的锁被错误删除非常重要。
举个例子,当一个客户端获取了锁,并因一些长时间的操作,阻塞时间超过了锁的可用时间(key 过期时间)导致 key 被删除,然后该 key 又被其它客户端创建(也就是其它客户端获得锁)。
如果前一个客户端在后一个客户端用完锁前进行了释放锁的操作,就导致了实际上现在属于后一个客户端的锁被删除。
所以必须使用上面的脚本保证客户端释放的一定是自己持有的锁,而且随机值的生成很重要,必须是全局唯一。
接下来我们把上述的算法扩展到分布式的情况。
在算法的分布式版本中,假设有 N 个 Redis 节点。而且这些节点全都是相互独立的,都是 master 节点,且不使用分布式协调方案。假设 N=5 ,即部署 5 个 Redis master 节点在不同的机器(或虚拟机)上。
客户端需要进行如下的操作来获取锁:
这个算法依赖于一个假设:即使在没有同步时钟机制的两个进程中,每个进程的本地时间仍然以相同的速率前进,即使有误差,这个误差时间相对于锁自动释放时间也是极小到可以忽略的。这个假设非常像现实世界中的计算机:每台计算机都有一个本地时钟,我们经常相信不同的计算机的时间差是很小的。
在这一点上需要再细化一下互斥锁的规则:必须确保客户端在 锁过期时间-跨进程的时间差(clock drift) 时间内做完自己所有的工作
更多相关信息可以阅读这篇有趣的文章:Leases: an efficient fault-tolerant mechanism for distributed file cache consistency
当一个客户端不能获得锁时,它应该在随机延迟后再次尝试,避免大量客户端同时获取锁的情况出现,这种情况下可能发生脑裂(split brain condition),导致大家都获取不了锁。另外,客户端越快尝试在大多数节点中获取锁,出现脑裂情况的时间窗口就越小。所以理想的情况下,客户端应该并行同时向全部节点发起获取锁请求。
这里有必要强调一下,客户端在没有成功获取锁时,一定要尽快并行在全部节点上释放锁,这样就没有必要等到 key 超时后才能重新获取这个锁(但是如果网络分区的情况发生,客户端无法连接到 Redis 节点时,会损失锁自动过期释放这段时间内的系统可用性)。
释放锁比较简单,因为只需要所有节点都释放锁都行,不管之前有没有在该节点获取成功锁。(译者注:为什么要对所有节点操作?因为分布式场景下从一个节点获取锁失败不代表在那个节点上加锁失败,可能实际上加锁已经成功了,但是返回时因为网络抖动超时了。)
这个算法到底是不是安全的,我们可以观察一些不同情况下的表现。
我们假设客户端可以在全部节点上获取成功锁,所有的节点将会有一个相同存活时间的 key。但要注意,这个 key 是在不同时间设置的,所以 key 也会在不同时间超时。如果在最坏情况下,第一个 key 在 T1 时间设置(在发起请求前采样),最后一个 key 在 T2 时间设置(在服务器响应后采样),我们可以确认最早超时的 key 至少也会存在 MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT 时间。所有其他的 key 超时都会大于这个时间,所以我们可以确定至少在这个时间点前这些 key 都是同时存在的。
在大部分节点都设置了 key 的时候,其他客户端无法抢占这个锁,因为 N/2+1 SET NX 操作在 N/2+1 个 key 存在的情况下无法成功。所以如果一个锁被获取成功了,就不可能重新在同一时间获取它(违反了安全属性)。
此外我们还需要确保多个客户端同时获取锁时不会同时成功。
如果一个客户端获取大多数节点的锁的耗时接近甚至超过锁的最大有效时间,那么系统就会认为这个锁是无效的,并全部解锁。所以我们只需要考虑在大多数节点获取锁的耗时小于锁有效时间的情况。在前面讨论的案例中可知,在 MIN_VALIDITY 时间内,没有客户端能成功重新获取锁。所以多个客户端只可能在大多数节点上获取锁的时间大于 TTL 时才可以,这会导致锁失效。
系统可用性基于三个特性:
然而,我们在网络分区发生的时候会损失 TTL 时间的系统可用性,所以如果分区连续发生,不可用也会持续。这种情况在每次客户端获得锁并在释放锁前遇到了网络分区的情况时都会发生。
基本上,如果持续的网络分区的话,系统也会持续不可用。
很多用户使用 Redis 做分布式锁服务时,不但要求加解锁要低延迟,还要求高吞吐量(每秒能够执行加/解锁操作的次数)。为了达到这个需求,可以通过多路复用并行和 N 个服务器通信,或者也可以将 socket 设置为非阻塞模式,一次性发送全部的命令,之后再一次性处理全部返回的命令,假设客户端和不同 Redis 服务节点的网络延迟不大的话,
为了能够实现故障恢复,我们需要考虑关于持久化的问题。
假设有一个客户端成功得获取了锁(至少 3/5 个节点成功),而已经成功获得锁的其中一个节点重启了,那么我们就又有了 3 个可以分配锁的节点,这样其它客户端就又可以成功获得锁了,违反了互斥锁的安全性原则。
如果启用了 AOF 持久化,情况会好很多。例如我们可以发起 SHUTDOWN 请求并重启服务器,因为 Redis 超时时间是语义层面的,所以在服务器关掉期间超时还是存在的,所以过期策略仍然存在。
但如果是意外停机呢?如果 Redis 被配置为每秒同步数据到磁盘一次(默认),可能在重启的时候丢失一些 key。理论上,如果我们要确保锁在任何重启的情况下都安全,就必须设置 fsync=always。但这样会完全牺牲性能,使其和传统的 CP 系统的分布式锁方案没有区别。
但事情往往不像第一眼看上去这么糟糕,基本上,只要一个服务节点挂了重启后不去管系统中现有活跃的锁,这样当节点重启时,整个系统中活跃的锁必然是由正在已获得锁的客户端使用的,而不是新加入系统的。
为了确保这一点,只需让崩溃重启的实例,在最大锁有效时间内不可用,令该节点的旧锁信息全部过期释放。
使用延迟重启基本上可以解决安全性问题,但要注意,这可能会造成可用性的下降:当系统内的大多数节点都挂了,那么在 TTL 时间内整个系统都处于不可用状态(无法获得锁)。
如果客户端执行的工作由小的步骤组成,那么可以使用比较小的 TTL 时间来设置锁,并在锁快过期时刷新锁有效时间(续约)。但在技术上不会改变算法本质,因此应该限制重新获取锁尝试的最大次数,不然会违反可用性。
redis的过期时间是依赖系统时钟的,如果时钟漂移过大时会影响到过期时间的计算。
为什么系统时钟会存在漂移呢?先简单说下系统时间,linux提供了两个系统时间:clock realtime和clock monotonic。
最理想的情况时,所有系统的时钟都时时刻刻和NTP服务器保持同步,但这显然是不可能的。导致系统时钟漂移的原因有两个: