Redlock 算法:Redis 实现分布式锁(译)

1,549 阅读11分钟

先介绍两个概念

Safety Properties, 在程序运行中不会进入非预期的状态(如非法调用参数, 数组下标越界等运行错误) Liveness Properties, 在程序运行中预期状态一定会到达(如停机, 获取资源请求一定有返回结果等等)

保证分布式锁有效的三个属性

  1. Safety Properties:安全性,此处也就是互斥性,任意时刻只能有一个客户端可以持有锁
  2. Liveness Property A:无死锁,即使持有锁的客户端崩溃或被分区,也可以获得锁
  3. Liveness Property B:容错性,只要大多数 Redis 节点正常,客户端就能获取和释放锁

为什么基于故障转移(failover-based)的实现还不够

我们先来看看现有大多数 Redis 分布式锁的实现
最简单的方案是在一个实例中创建一个 key,并给这个 key 设置过期时间,保证这个锁最终一定能够释放,当客户端释放锁的时候,删除这个 key
看上去可能不错,但是有个问题:当我们的 Redis 主节点挂掉时会发生什么?好,那我们增加一个从节点,当主节点不可用时自动切换到从节点。但不幸地是这不行,因为 Redis 复制是异步的,所以不能保证互斥性

在这个方案下有一个明显的竞态条件:

  1. 客户端 A 在 master 节点获取锁
  2. 在写 key 操作被传输到 slave 节点前 master 节点挂了
  3. slave 晋升为 master
  4. 客户端 B 获取 A 其实刚刚已经获取到的锁 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 又被其它客户端创建(也就是其它客户端获得锁)
如果前一个客户端在后一个客户端用完锁前进行了释放锁的操作,就导致了实际上现在属于后一个客户端的锁被删除
所以必须使用上面的脚本保证客户端释放的一定是自己持有的锁,而且随机值的生成很重要,必须是全局唯一

接下来我们把上述的算法扩展到分布式的情况

Redlock 算法

在算法的分布式版本中,假设有 N 个 Redis 节点。而且这些节点全都是相互独立的,都是 master 节点,且不使用分布式协调方案。假设 N=5 ,即部署 5 个 Redis master 节点在不同的机器(或虚拟机)上

客户端需要进行如下的操作来获取锁:

  1. 获取当前时间(毫秒)
  2. 尝试按顺序在 N 个节点获取锁(set 相同的 key value)。客户端在每个节点请求锁时,使用一个相对总的锁过期时间而言非常小的请求超时时间。例如锁过期时间为 10s,那么请求超时时间应该设置在大约 5~50ms 之间。这可以防止客户端在一个挂掉的节点上长时间阻塞:如果实例不可用,我们应该尝试尽快与下一个实例
  3. 客户端计算获取锁所花的时间(当前时间减去第一步中的时间)。当且仅当客户端在大多数节点上(至少三个)都成功获得了锁,而且总时间消耗小于锁有效时间,锁被认为获取成功
  4. 如果锁获取成功了,那么它的有效时间就是最初的锁有效时间减去之前获取锁所消耗的时间
  5. 如果因为某些原因,锁获取失败了(无论是不能在大部分节点成功获取锁,还是锁有效时间小于 0),将会尝试释放所有节点的锁(即使是那些没有获取成功的节点)

这个算法是异步的吗

这个算法依赖于一个假设:即使在没有同步时钟机制的两个进程中,每个进程的本地时间仍然以相同的速率前进,即使有误差,这个误差时间相对于锁自动释放时间也是极小到可以忽略的。这个假设非常像现实世界中的计算机:每台计算机都有一个本地时钟,我们经常相信不同的计算机的时间差是很小的 在这一点上需要再细化一下互斥锁的规则:必须确保客户端在 锁过期时间-跨进程的时间差(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 时才可以,这会导致锁失效

可用性(liveness)论证

系统可用性基于三个特性:

  1. 自动释放锁(基于 key 过期):最终锁一定能够再次被获取
  2. 现实情况下客户端一般都会主动释放锁,所以我们不需要等到 key 过期才能再去获取锁
  3. 当客户端发起重试获取锁的请求时,它会等待一段比去大多数节点获取锁的时间更长的时间,这会降低多个客户端同时请求锁而发生脑裂状态的概率

然而,我们在网络分区发生的时候会损失 TTL 时间的系统可用性,所以如果分区连续发生,不可用也会持续。这种情况在每次客户端获得锁并在释放锁前遇到了网络分区的情况时都会发生
基本上,如果持续的网络分区的话,系统也会持续不可用

性能、故障后恢复和 fsync

很多用户使用 Redis 做分布式锁服务时,不但要求加解锁要低延迟,还要求高吞吐量(每秒能够执行加/解锁操作的次数)。为了达到这个需求,可以通过多路复用并行和 N 个服务器通信,或者也可以将 socket 设置为非阻塞模式,一次性发送全部的命令,之后再一次性处理全部返回的命令,假设客户端和不同 Redis 服务节点的网络延迟不大的话

为了能够实现故障恢复,我们需要考虑关于持久化的问题 假设有一个客户端成功得获取了锁(至少 3/5 个节点成功),而已经成功获得锁的其中一个节点重启了,那么我们就又有了 3 个可以分配锁的节点,这样其它客户端就又可以成功获得锁了,违反了互斥锁的安全性原则

如果启用了 AOF 持久化,情况会好很多。例如我们可以发起 SHUTDOWN 请求并重启服务器,因为 Redis 超时时间是语义层面的,所以在服务器关掉期间超时还是存在的,所以过期策略仍然存在 但如果是意外停机呢?如果 Redis 被配置为每秒同步数据到磁盘一次(默认),可能在重启的时候丢失一些 key。理论上,如果我们要确保锁在任何重启的情况下都安全,就必须设置 fsync=always。但这样会完全牺牲性能,使其和传统的 CP 系统的分布式锁方案没有区别
但事情往往不像第一眼看上去这么糟糕,基本上,只要一个服务节点挂了重启后不去管系统中现有活跃的锁,这样当节点重启时,整个系统中活跃的锁必然是由正在已获得锁的客户端使用的,而不是新加入系统的
为了确保这一点,只需让崩溃重启的实例,在最大锁有效时间内不可用,令该节点的旧锁信息全部过期释放
使用延迟重启基本上可以解决安全性问题,但要注意,这可能会造成可用性的下降:当系统内的大多数节点都挂了,那么在 TTL 时间内整个系统都处于不可用状态(无法获得锁)

使算法更可靠:扩展锁

如果客户端执行的工作由小的步骤组成,那么可以使用比较小的 TTL 时间来设置锁,并在锁快过期时刷新锁有效时间(续约)。但在技术上不会改变算法本质,因此应该限制重新获取锁尝试的最大次数,不然会违反可用性