Redis 分布式锁
Redis 的分布式锁模式
分布式锁在许多环境中是一个非常有用的基本机制,在这些环境中,不同的进程必须以互斥的方式操作共享资源。
有很多库和博文描述了如何使用 Redis 实现 DLM(分布式锁管理器),但每个库都使用不同的方法,而且许多库都使用简单的方法,其保证低于使用稍微复杂的设计可以实现的保证。
本页介绍了一种更规范的算法,用于使用 Redis 实现分布式锁。我们提出了一个名为 **Redlock** 的算法,它实现了 DLM,我们认为它比传统的单实例方法更安全。我们希望社区能够对其进行分析、提供反馈,并将其用作实现或更复杂或替代设计的起点。
实现
在描述算法之前,以下是一些可用于参考的已可用实现的链接。
- Redlock-rb(Ruby 实现)。还有一个 Redlock-rb 的分支,它添加了一个宝石以方便分发。
- RedisQueuedLocks(Ruby 实现)。
- Redlock-py(Python 实现)。
- Pottery(Python 实现)。
- Aioredlock(Asyncio Python 实现)。
- Redlock-php(PHP 实现)。
- PHPRedisMutex(另一个 PHP 实现)。
- cheprasov/php-redis-lock(PHP 锁库)。
- rtckit/react-redlock(异步 PHP 实现)。
- Redsync(Go 实现)。
- Redisson(Java 实现)。
- Redis::DistLock(Perl 实现)。
- Redlock-cpp(C++ 实现)。
- Redis-plus-plus(C++ 实现)。
- Redlock-cs(C#/.NET 实现)。
- RedLock.net(C#/.NET 实现)。包括异步和锁扩展支持。
- ScarletLock(具有可配置数据存储的 C# .NET 实现)。
- Redlock4Net(C# .NET 实现)。
- node-redlock(NodeJS 实现)。包括锁扩展支持。
- Deno DLM(Deno 实现)
- Rslock(Rust 实现)。包括异步和锁扩展支持。
安全性和活性保证
我们将使用三个属性来模拟我们的设计,从我们的角度来看,这些属性是有效使用分布式锁的最低保证。
- 安全属性:互斥。在任何给定时刻,只有一个客户端可以持有锁。
- 活性属性 A:无死锁。即使锁定资源的客户端崩溃或被分区,最终总是可以获取锁。
- 活性属性 B:容错。只要大多数 Redis 节点都处于运行状态,客户端就能获取和释放锁。
为什么基于故障转移的实现还不够
为了理解我们想要改进什么,让我们分析一下大多数基于 Redis 的分布式锁库的现状。
使用 Redis 锁定资源最简单的方法是在一个实例中创建一个键。该键通常使用 Redis 过期功能创建,并设置有限的生存时间,以便最终会被释放(我们列表中的属性 2)。当客户端需要释放资源时,它会删除该键。
表面上这工作得很好,但有一个问题:这是我们架构中的单点故障。如果 Redis 主节点宕机怎么办?好吧,让我们添加一个副本!并在主节点不可用时使用它。不幸的是,这样做是不可行的。这样做我们就无法实现互斥的安全属性,因为 Redis 复制是异步的。
这种模型存在竞争条件
- 客户端 A 在主节点获取锁。
- 主节点在将写入操作传输到副本之前崩溃了。
- 副本被提升为主节点。
- 客户端 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 主节点,以确保它们能够以大部分独立的方式失败。
为了获取锁,客户端执行以下操作
- 它以毫秒为单位获取当前时间。
- 它尝试以相同的方式依次在所有 N 个实例中获取锁,在所有实例中使用相同的键名和随机值。在步骤 2 中,在每个实例中设置锁时,客户端使用一个超时时间,该超时时间比总锁自动释放时间小,以便获取锁。例如,如果自动释放时间是 10 秒,则超时时间可能在 ~ 5-50 毫秒范围内。这可以防止客户端在尝试与宕机的 Redis 节点通信时被长时间阻塞:如果一个实例不可用,我们应该尽快尝试与下一个实例通信。
- 客户端计算获取锁所花费的时间,方法是从步骤 1 中获得的时间戳中减去当前时间。当且仅当客户端能够在大多数实例(至少 3 个)中获取锁,并且获取锁的总时间小于锁有效期时,才认为锁被获取。
- 如果锁被获取,则其有效期被视为初始有效期减去在步骤 3 中计算的时间。
- 如果客户端由于某种原因未能获取锁(要么无法锁定 N/2+1 个实例,要么有效期为负),它将尝试解锁所有实例(即使它认为自己无法锁定的实例)。
算法是异步的吗?
该算法依赖于以下假设:虽然在进程之间没有同步时钟,但每个进程的本地时间以大致相同的速率更新,与锁的自动释放时间相比,误差率很小。这种假设与现实世界中的计算机非常相似:每台计算机都有一个本地时钟,通常我们可以依赖不同的计算机具有很小的时钟漂移。
在这一点上,我们需要更好地说明我们的互斥规则:它只在持有锁的客户端在锁有效期(如步骤 3 中获得的)内完成其工作,减去一些时间(为了补偿进程之间的时间漂移,只有几毫秒)的情况下保证。
这篇论文包含有关类似系统的信息,这些系统需要有界 *时钟漂移*: 租赁:一种有效的容错机制,用于分布式文件缓存一致性.
失败重试
当客户端无法获取锁时,它应该在随机延迟后再次尝试,以便尝试使多个尝试在同一时间为同一资源获取锁的客户端不同步(这可能导致分裂脑状态,没有人获胜)。此外,客户端尝试在大多数 Redis 实例中获取锁的速度越快,分裂脑状态窗口(以及重试的需要)就越小,因此理想情况下,客户端应该尝试使用多路复用同时将 SET
命令发送到 N 个实例。
值得强调的是,对于未能获取大多数锁的客户端,尽快释放(部分)获取的锁非常重要,这样就不必等待键过期才能再次获取锁(但是,如果发生网络分区,客户端无法再与 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 的结束时间)。
生存性论据
系统的生存性基于三个主要特征:
- 锁的自动释放(因为键过期):最终键再次可用以锁定。
- 通常,客户端会协作删除锁,无论锁是未获取还是获取后工作已终止,这使得我们不必等待键过期来重新获取锁。
- 当客户端需要重试锁时,它会等待一个时间,该时间比获取大多数锁所需的时间长得多,以便在资源争用期间概率性地降低发生脑裂情况的可能性。
但是,我们在网络分区上会支付相当于 TTL
时间的可用性损失,因此,如果存在连续分区,我们可能会无限期地支付此损失。每次客户端获取锁并在能够删除锁之前被分区隔离时都会发生这种情况。
基本上,如果存在无限的连续网络分区,系统可能会无限期地变得不可用。
性能、崩溃恢复和 fsync
许多使用 Redis 作为锁服务器的用户需要高性能,既包括获取和释放锁的延迟,也包括每秒可以执行的获取/释放操作数量。为了满足此要求,与 N 个 Redis 服务器通信以降低延迟的策略肯定是对多个服务器进行多路复用(将套接字置于非阻塞模式,发送所有命令,并在稍后读取所有命令,假设客户端和每个实例之间的 RTT 类似)。
但是,如果我们要针对崩溃恢复系统模型,还有另一个关于持久性的考虑因素。
基本上,要了解这里的问题,假设我们将 Redis 配置为完全不进行持久性。客户端在 5 个实例中的 3 个实例中获取锁。客户端能够获取锁的实例之一重新启动,此时,我们可以为同一资源锁定的实例再次有 3 个,另一个客户端可以再次锁定它,这违反了锁排他性的安全性属性。
如果我们启用 AOF 持久性,情况会好很多。例如,我们可以通过向服务器发送 SHUTDOWN
命令并重新启动它来升级服务器。由于 Redis 过期是语义上实现的,因此服务器关闭时时间仍然会流逝,因此我们所有的要求都很好。但是,只要是干净关闭,一切就都很好。如果发生断电怎么办?如果 Redis 配置为(默认情况下)每秒在磁盘上进行 fsync,则重新启动后我们的键可能丢失。理论上,如果我们想在任何类型的实例重新启动的情况下保证锁的安全性,我们需要在持久性设置中启用 fsync=always
。这将由于额外的同步开销而影响性能。
但是,情况比乍看起来好。基本上,只要实例在崩溃后重新启动时不再参与任何 **当前活动** 的锁,算法安全性就会得到保留。这意味着实例重新启动时的一组当前活动锁都是通过锁定除重新加入系统的实例之外的其他实例获得的。
为了保证这一点,我们只需要让实例在崩溃后重新启动时,至少在比我们使用的最大 TTL
时间长一点的时间内不可用。这是所有关于实例崩溃时存在的锁的键失效并自动释放所需的时间。
使用 *延迟重启*,基本上可以实现安全性,即使没有任何 Redis 持久性可用,但请注意,这可能会导致可用性损失。例如,如果大多数实例崩溃,系统将全局不可用 TTL
时间(这里全局意味着在此期间任何资源都无法锁定)。
使算法更可靠:扩展锁
如果客户端执行的工作由多个小步骤组成,则默认情况下可以使用较小的锁有效时间,并扩展算法以实现锁扩展机制。基本上,如果客户端在锁有效期接近较低值时处于计算中间,则可以通过向所有实例发送 Lua 脚本,如果键存在且其值仍是客户端获取锁时分配的随机值,则扩展键的 TTL 来扩展锁。
只有当客户端能够将锁扩展到大多数实例中并在有效时间内完成时,客户端才应认为锁重新获取(基本上,要使用的算法与获取锁时使用的算法非常相似)。
但是,这在技术上不会改变算法,因此应限制锁重新获取尝试的最大次数,否则会违反生存性属性之一。
关于一致性的免责声明
请仔细阅读本页末尾的 Redlock 分析 部分。Martin Kleppmann 的文章以及 antirez 对它的答复非常重要。如果您担心一致性和正确性,您应该注意以下主题:
- 您应该实现隔离令牌。这对于可能需要大量时间的进程尤其重要,并且适用于任何分布式锁定系统。扩展锁的生存期也是一种选择,但不要假设只要获取锁的进程处于活动状态,锁就会保留。
- Redis 没有使用单调时钟来进行 TTL 过期机制。这意味着墙上时钟偏移会导致一个以上的进程获取锁。即使可以通过阻止管理员手动设置服务器时间并正确设置 NTP 来缓解此问题,但在现实生活中仍然有可能发生此问题并危及一致性。
想帮忙吗?
如果您精通分布式系统,我们很乐意收到您的意见/分析。此外,其他语言的参考实现将非常棒。
提前感谢!
Redlock 分析
- Martin Kleppmann 在这里分析了 Redlock。对这种分析的论点可以 在这里找到。