Redis 分布式锁

一种基于 Redis 的分布式锁模式

在许多环境中,分布式锁是一种非常有用的基本工具,它可以确保不同进程以互斥方式操作共享资源。

有许多库和博客文章描述了如何使用 Redis 实现 DLM(分布式锁管理器),但每个库都使用不同的方法,并且与稍微复杂的设计相比,许多方法提供较低的保证。

本页面介绍了一种更规范的、使用 Redis 实现分布式锁的算法。我们提出了一种名为 Redlock 的算法,它实现了一个 DLM,我们认为它比香草单实例方法更安全。我们希望社区能够分析它、提供反馈,并将其用作实现或更复杂、替代设计的起点。

实现

在描述算法之前,这里有一些现有实现的链接可供参考。

安全性和活性保证

我们将基于以下三个属性来建模我们的设计,从我们的角度来看,这些属性是有效使用分布式锁所需的最基本保证。

  1. 安全属性:互斥。在任何给定时刻,只有一个客户端可以持有锁。
  2. 活性属性 A:无死锁。即使锁定资源的客户端崩溃或发生分区,最终也始终可以获取锁。
  3. 活性属性 B:容错性。只要大多数 Redis 节点处于运行状态,客户端就能够获取和释放锁。

为什么基于故障转移的实现不足够

为了理解我们想要改进的地方,让我们分析大多数基于 Redis 的分布式锁库的现状。

使用 Redis 锁定资源的最简单方法是在实例中创建一个键。这个键通常会使用 Redis 的过期功能设置一个有限的生存时间,这样最终它会被释放(我们列表中的属性 2)。当客户端需要释放资源时,它删除该键。

从表面上看,这工作得很好,但存在一个问题:这是我们架构中的单点故障。如果 Redis 主节点宕机怎么办?好的,我们添加一个副本!并在主节点不可用时使用它。遗憾的是,这不可行。这样做我们将无法实现互斥的安全属性,因为 Redis 复制是异步的。

这种模式存在竞态条件

  1. 客户端 A 在主节点获取锁。
  2. 在键的写入操作传输到副本之前,主节点崩溃了。
  3. 副本被提升为新的主节点。
  4. 客户端 B 获取了 A 已经持有锁的同一资源的锁。违反了安全性!

有时,在特殊情况下,例如发生故障期间,多个客户端同时持有锁是完全可以接受的。如果是这种情况,您可以使用基于复制的解决方案。否则,我们建议您实现本文档中描述的解决方案。

单实例的正确实现

在尝试克服上述单实例设置的限制之前,让我们检查一下在这种简单情况下如何正确实现,因为在偶尔出现竞态条件可以接受的应用中,这实际上是一种可行的解决方案,而且单实例锁定是我们将用于此处描述的分布式算法的基础。

获取锁的方法如下:

    SET resource_name my_random_value NX PX 30000

该命令仅在键不存在时设置键(NX 选项),过期时间为 30000 毫秒(PX 选项)。键的值被设置为“my_random_value”。这个值必须在所有客户端和所有锁请求中都是唯一的。

基本上,随机值用于安全地释放锁,通过一个脚本告诉 Redis:仅当键存在并且存储在该键上的值与我期望的值完全相同时才删除该键。这可以通过以下 Lua 脚本实现

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

这很重要,可以避免删除由其他客户端创建的锁。例如,一个客户端可能获取了锁,执行某些操作时阻塞的时间超过了锁的有效期(键的过期时间),然后才删除锁,而此时锁可能已经被其他客户端获取了。仅仅使用 DEL 是不安全的,因为一个客户端可能会删除另一个客户端的锁。而使用上述脚本,每个锁都用一个随机字符串“签名”,因此只有在尝试删除锁的客户端设置的锁仍然存在时,才会删除该锁。

这个随机字符串应该是什么?我们假设它是来自 /dev/urandom 的 20 字节,但您可以找到更便宜的方式使其对于您的任务足够唯一。例如,一个安全的选择是用 /dev/urandom 初始化 RC4,并从中生成一个伪随机流。一个更简单的解决方案是使用具有微秒精度的 UNIX 时间戳,并将时间戳与客户端 ID 拼接。它不如前者安全,但对于大多数环境可能足够了。

“锁有效期”是我们用作键的生存时间。它既是自动释放时间,也是客户端在另一个客户端可能再次获取锁之前执行所需操作的时间窗口,这在技术上不会违反互斥保证,因为互斥仅限于从获取锁的那一刻起的一个给定时间窗口内。

至此,我们有了获取和释放锁的良好方法。使用这个系统,对于由一个始终可用的单实例组成的非分布式系统而言,推理是安全的。让我们将这个概念扩展到没有此类保证的分布式系统。

Redlock 算法

在算法的分布式版本中,我们假设有 N 个 Redis 主节点。这些节点是完全独立的,因此我们不使用复制或任何其他隐式协调系统。我们已经描述了如何在单个实例中安全地获取和释放锁。我们假定该算法将使用此方法在单个实例中获取和释放锁。在我们的示例中,我们设置 N=5,这是一个合理的值,因此我们需要在不同的计算机或虚拟机上运行 5 个 Redis 主节点,以确保它们以大部分独立的方式发生故障。

为了获取锁,客户端执行以下操作:

  1. 获取当前时间的毫秒值。
  2. 尝试在所有 N 个实例中按顺序获取锁,在所有实例中使用相同的键名和随机值。在步骤 2 中,在每个实例中设置锁时,客户端使用一个超时时间,这个超时时间相对于总锁自动释放时间来说很小。例如,如果自动释放时间是 10 秒,超时时间可以在 5-50 毫秒范围内。这可以防止客户端长时间阻塞试图与宕机的 Redis 节点通信:如果某个实例不可用,我们应该尽快尝试与下一个实例通信。
  3. 客户端计算获取锁花费的时间,用当前时间减去步骤 1 中获得的时间戳。当且仅当客户端能够在大多数实例(至少 3 个)中获取锁,并且获取锁的总耗时少于锁有效期时,才认为锁已成功获取。
  4. 如果锁已获取,其有效期被认为是初始有效期减去步骤 3 中计算的已耗用时间。
  5. 如果客户端由于某种原因未能获取锁(未能锁定 N/2+1 个实例或有效期为负数),它将尝试解锁所有实例(即使是它认为未能锁定的实例)。

算法是异步的吗?

该算法依赖于这样一个假设:尽管进程之间没有同步时钟,但每个进程的本地时间以大致相同的速率更新,与锁的自动释放时间相比,误差很小。这个假设非常类似于现实世界的计算机:每台计算机都有一个本地时钟,我们通常可以相信不同计算机之间的时钟漂移很小。

在这一点上,我们需要更好地明确我们的互斥规则:只有当持有锁的客户端在锁有效期(如步骤 3 中获得)内完成其工作,并减去一些时间(仅需几毫秒以补偿进程之间的时钟漂移)时,互斥才能得到保证。

本文包含更多关于需要有界时钟漂移的类似系统的信息:Leases: an efficient fault-tolerant mechanism for distributed file cache consistency

失败后重试

当客户端无法获取锁时,它应该在随机延迟后重试,以尝试使多个试图同时获取同一资源锁的客户端去同步化(这可能导致谁也无法获取锁的脑裂情况)。此外,客户端尝试在大多数 Redis 实例中获取锁的速度越快,出现脑裂情况的窗口就越小(并且越不需要重试),因此理想情况下,客户端应该尝试使用多路复用同时向 N 个实例发送 SET 命令。

值得强调的是,对于未能获取大多数锁的客户端来说,尽快释放(部分)获取的锁是多么重要,这样就无需等待键过期后才能再次获取锁(但是,如果发生网络分区且客户端无法再与 Redis 实例通信,则需要等待键过期,这将导致可用性惩罚)。

释放锁

释放锁很简单,无论客户端是否认为自己成功锁定了给定的实例,都可以执行释放操作。

安全性论证

算法安全吗?让我们检查不同场景下会发生什么。

首先,我们假设客户端能够在大多数实例中获取锁。所有实例将包含一个具有相同生存时间的键。然而,键是在不同时间设置的,所以键也会在不同时间过期。但是,如果第一个键在最坏情况下是在时间 T1(我们联系第一个服务器之前采样的时间)设置的,最后一个键在最坏情况下是在时间 T2(我们从最后一个服务器获取回复的时间)设置的,那么我们确定集合中第一个过期的键将至少存在 MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT 的时间。所有其他键都会在之后过期,因此我们确定这些键将同时存在至少这个时间。

在大多数键都已设置的时间段内,另一个客户端将无法获取锁,因为如果 N/2+1 个键已经存在,则 N/2+1 个 SET NX 操作无法成功。因此,如果已经获取了锁,就不可能同时重新获取它(违反互斥属性)。

然而,我们还要确保多个客户端尝试同时获取锁时不能同时成功。

如果客户端使用接近或大于锁最大有效期(基本上是我们用于 SET 的 TTL)的时间锁定了大多数实例,它将认为锁无效并解锁实例,因此我们只需考虑客户端能够在小于有效期的时间内锁定大多数实例的情况。在这种情况下,根据上述已经表达的论点,对于 MIN_VALIDITY 时间段,任何客户端都无法重新获取锁。因此,只有当锁定大多数实例的时间大于 TTL 时间(从而使锁无效)时,多个客户端才有可能同时锁定 N/2+1 个实例(这里的“时间”指步骤 2 的结束时)。

活性论证

系统的活性基于三个主要特性:

  1. 锁的自动释放(因为键会过期):最终键会再次变得可锁定。
  2. 客户端通常会配合在未获取锁时移除锁,或在获取锁且工作完成后移除锁,这使得我们很可能无需等待键过期即可重新获取锁。
  3. 当客户端需要重试锁定时,会等待一段相对长于获取大多数锁所需的时间,以概率性地降低资源竞争时出现脑裂的可能性。

然而,在网络分区时,我们会付出等于 TTL 时间的可用性代价,因此如果持续发生分区,我们可能会无限期地付出这种代价。这发生在每次客户端获取锁并在能够移除锁之前就被分区隔离时。

基本上,如果存在无限连续的网络分区,系统可能会无限期地不可用。

性能、崩溃恢复和 fsync

许多使用 Redis 作为锁服务器的用户需要高性能,包括获取和释放锁的延迟以及每秒可执行的获取/释放操作次数。为了满足这一要求,与 N 个 Redis 服务器通信以降低延迟的策略无疑是多路复用(将 socket 置于非阻塞模式,发送所有命令,然后稍后读取所有命令,前提是客户端与每个实例之间的 RTT 类似)。

然而,如果我们想构建一个崩溃恢复系统模型,还需要考虑持久化。

基本上要看到这里的问题,假设我们完全没有配置 Redis 的持久化。一个客户端在 5 个实例中的 3 个中获取了锁。其中一个客户端获取了锁的实例重启了,此时我们又可以在另外 3 个实例中锁定相同的资源,另一个客户端可以再次锁定它,从而违反了锁排他性的安全属性。

如果我们启用 AOF 持久化,情况会改善不少。例如,我们可以通过向服务器发送 SHUTDOWN 命令并重启来升级服务器。因为 Redis 的过期语义实现使得服务器关闭时时间仍在流逝,所以我们的所有要求都可以满足。然而,只要是正常关机,一切都没问题。如果是断电呢?如果 Redis 配置为(默认情况下)每秒执行一次 fsync 到磁盘,那么重启后我们的键可能会丢失。理论上,如果我们想在任何类型的实例重启面前保证锁的安全性,我们需要在持久化设置中启用 fsync=always。这将由于额外的同步开销而影响性能。

然而,事情并非初看起来那么糟糕。基本上,只要一个实例在崩溃后重启时不再参与任何**当前活动的**锁,算法的安全性就能得到保持。这意味着当实例重启时,所有当前活动的锁都是通过锁定除正在重新加入系统的那个实例之外的其他实例获得的。

为了保证这一点,我们只需要在实例崩溃后使其不可用至少比我们使用的最大 TTL 长一点的时间。这是实例崩溃时存在的关于锁的所有键失效并自动释放所需的时间。

使用延迟重启,即使没有任何 Redis 持久化可用,基本上也可以实现安全性,但请注意,这可能会转化为可用性惩罚。例如,如果大多数实例崩溃,系统将全局不可用 TTL 时间(这里的全局意味着在此期间没有任何资源可以被锁定)。

使算法更可靠:延长锁的有效期

如果客户端执行的工作由小步骤组成,则可以默认使用较小的锁有效期,并通过实现锁扩展机制来扩展算法。基本上,如果客户端在计算过程中,当锁的有效期即将达到较低值时,可以通过向所有实例发送一个 Lua 脚本来延长锁的有效期,前提是键存在且其值仍是客户端获取锁时分配的随机值。

客户端仅当能够在大多数实例中且在有效期内成功延长锁时,才应认为锁已重新获取(基本上使用的算法与获取锁时的算法非常相似)。

然而,这在技术上并未改变算法,因此应该限制锁重新获取尝试的最大次数,否则会违反其中一个活性属性。

关于一致性的免责声明

请仔细查阅本页末尾的Redlock 分析部分。Martin Kleppman 的文章和 antirez 对其的回应非常相关。如果您关心一致性和正确性,则应注意以下主题:

  1. 您应该实现隔离令牌(fencing tokens)。这对于可能花费大量时间的进程尤为重要,并且适用于任何分布式锁定系统。延长锁的生命周期也是一种选择,但不要假设只要获取了锁的进程处于活动状态,锁就会一直保留。
  2. Redis 的 TTL 过期机制不使用单调时钟。这意味着墙壁时钟的偏移可能导致多个进程获取同一锁。尽管通过阻止管理员手动设置服务器时间并正确设置 NTP 可以缓解此问题,但在实际情况中仍有可能发生此问题并损害一致性。

想帮忙吗?

如果您对分布式系统感兴趣,非常欢迎您的意见/分析。其他语言的参考实现也会非常棒。

提前感谢!

Redlock 分析


  1. Martin Kleppmann 在此处分析了 Redlock。对该分析的反驳可以在此处找到
为本页评分
返回顶部 ↑