使用 Redis 实现分布式锁
提示
来自deepseek解释
原文链接:https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/
分布式锁在很多环境中是一种非常有用的原语,在这些环境中,不同进程必须以互斥的方式操作共享资源。
有许多库和博客文章描述了如何使用 Redis 实现 DLM(分布式锁管理器),但每个库使用的方法不同,许多库使用简单的方法,其保证性低于通过稍微复杂的设计所能达到的水平。
本页面描述了一种更规范的算法,用于使用 Redis 实现分布式锁。我们提出了一种称为 Redlock 的算法,它实现了一种 DLM,我们认为它比普通的单实例方法更安全。我们希望社区能够分析它、提供反馈,并将其作为实现更复杂或替代设计的起点。
实现方案
在描述算法之前,这里有一些已经可用的实现链接,可供参考。
- Redlock-rb(Ruby 实现)。还有一个 Redlock-rb 的分支,添加了 gem 以便于分发。
- RedisQueuedLocks(Ruby 实现)。
- redlock-ng(现代 Python 实现,同步和异步)。
- Pottery(Python 实现)。
- Aioredlock(Asyncio Python 实现)。
- RedisMutex(PHP 实现,同时支持 Redis 扩展 和 Predis 库 客户端)。
- Redlock-php(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 实现)。包括异步和锁扩展支持。
- Redlock4Net(C# .NET 实现)。
- node-redlock(NodeJS 实现)。包括锁扩展支持。
- redlock-universal(NodeJS 实现)。同时支持 node-redis 和 ioredis 客户端。
- 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:仅当键存在且存储的值恰好是我期望的值时才删除该键。 这通过以下命令完成:
DELEX key IFEQ my_random_value
DELEX 命令在 Redis 8.4 中引入。在早期的 Redis 版本中,这可以通过以下 Lua 脚本完成:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end这对于避免删除由另一个客户端创建的锁很重要。例如,一个客户端可能获取了锁,在执行某个操作时被阻塞的时间超过了锁的有效时间(键将过期的时间),然后删除了已被其他客户端获取的锁。 仅使用 DEL 是不安全的,因为客户端可能会删除另一个客户端的锁。而使用 DELEX 命令或上述脚本,每个锁都使用随机字符串“签名”,因此只有当锁仍然是尝试删除它的客户端所设置的锁时,才会被删除。
这个随机字符串应该是什么?我们假设它是来自 /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 中获得)完成其工作,再减去一些时间(仅几毫秒以补偿进程之间的时钟漂移)时,才保证互斥。
这篇论文包含了更多关于需要受约束时钟漂移的类似系统的信息: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 结束)。
活跃性论证
系统的活跃性基于三个主要特性:
- 锁的自动释放(因为键过期):最终键再次可用于锁定。
- 客户端通常会在未获取锁时或获取锁并完成工作后合作移除锁,这使得我们可能不必等待键过期即可重新获取锁。
- 当客户端需要重试锁时,它会等待比获取大多数锁所需时间更长的时间,以便在资源争用期间以概率方式降低脑裂情况的可能性。
然而,我们为网络分区支付了等于 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 对它的答复非常重要。 如果您关心一致性和正确性,您应该关注以下主题:
- 您应该实现 fencing token。 这对于可能需要较长时间运行的进程尤其重要,并且适用于任何分布式锁定系统。 延长锁的生命周期也是一种选择,但不要假设只要获取锁的进程处于活动状态,锁就会一直保留。
- Redis 不使用单调时钟作为 TTL 过期机制。 这意味着墙钟偏移可能导致多个进程获取同一个锁。 尽管可以通过阻止管理员手动设置服务器时间并正确设置 NTP 来缓解该问题,但在实际中仍可能出现此问题并损害一致性。
想提供帮助?
如果您对分布式系统有研究,非常希望得到您的意见/分析。此外,其他语言的参考实现也很棒。
提前感谢!