使用 Redis 进行分布式锁定

嘿哈布尔!

今天我们提请您注意一篇关于使用Redis实现分布式锁定的复杂文章的翻译,并邀请您谈论Redis作为主题的前景。 《Redlock 算法分析》一书的作者 Martin Kleppmann高负载应用”,给定 这里.

分布式锁定是一种非常有用的原语,在许多环境中使用,在这些环境中,不同的进程必须以互斥的方式处理共享资源。

有许多库和帖子描述了如何使用 Redis 实现 DLM(分布式锁管理器),但每个库都采用不同的方法,并且与稍微复杂的设计可实现的保证相比,它们提供的保证相当弱。

在本文中,我们将尝试描述一种条件规范算法,演示如何使用 Redis 实现分布式锁定。 我们将讨论一种称为 雷德洛克,它实现了分布式锁管理器,在我们看来,该算法比通常的单实例方法更安全。 我们希望社区能够对其进行分析、提供反馈,并将其用作更复杂或替代项目的起点。

执行

在继续描述算法之前,我们提供了几个现成实现的链接。 它们可以作为参考。

安全性和可用性保证

我们将仅使用三个属性来对我们的设计进行建模,我们认为这三个属性提供了有效使用分布式锁定所需的最低保证。

  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 选项)。 键设置为“myrandomvalue” 该值在所有客户端和所有锁定请求中必须是唯一的。
基本上,使用一个随机值来安全地释放锁,并通过一个脚本告诉 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中获得)退出,再减去一些时间(总共几个毫秒来补偿进程之间的时间差)。

以下有趣的文章详细介绍了此类需要协调时间间隔的系统: 租约:分布式文件缓存一致性的高效容错机制.

失败重试

当客户端获取锁失败时,必须在随机延迟后重试; 这样做是为了使多个客户端同时尝试获取同一资源上的锁去同步(这可能导致没有赢家的“裂脑”情况)。 此外,客户端尝试获取大多数 Redis 实例上的锁的速度越快,发生裂脑情况的窗口就越窄(并且重试的需要就越少)。 因此,理想情况下,客户端应尝试使用多路复用同时向 N 个实例发送 SET 命令。

这里值得强调的是,对于未能获取大部分锁的客户端来说,释放(部分)获取的锁是多么重要,这样他们就不必等待密钥过期才能再次获取资源上的锁(尽管如果发生网络碎片,并且客户端与 Redis 实例失去联系,那么您必须在等待密钥过期时付出可用性损失)。

释放锁

释放锁是一个简单的操作,只需要解锁所有实例,无论客户端是否已成功锁定特定实例。

安全考虑

算法安全吗? 让我们尝试想象一下不同场景下会发生什么。

首先,我们假设客户端能够获得大多数实例的锁定。 每个实例都将包含一个具有相同生命周期的密钥。 但是,每个密钥都是在不同的时间安装的,因此它们将在不同的时间过期。 但是,如果第一个密钥的安装时间不低于 T1(我们在联系第一台服务器之前选择的时间),并且最后一个密钥的安装时间不低于 T2(收到响应的时间)来自最后一个服务器),那么我们有信心集合中第一个过期的密钥至少会存活 MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT。 所有其他密钥将在稍后过期,因此我们可以确定所有密钥至少在这段时间同时有效。

在大多数密钥保持有效的时间内,另一个客户端将无法获取锁,因为如果 N/2+1 个密钥已存在,则 N/2+1 SET NX 操作无法成功。 因此,一旦获取了锁,就不可能在同一时刻再次获取它(这会违反互斥性质)。
但是,我们希望确保多个客户端尝试同时获取锁不能同时成功。

如果客户端锁定大多数实例的时间大约或超过最大锁定持续时间,它将认为锁定无效并解锁实例。 因此,我们只需要考虑客户端在小于到期日期的时间内阻止了大多数实例的情况。 在此情况下,针对上述论证,期间 MIN_VALIDITY 任何客户端都不能重新获取锁。 因此,只有当锁定多数实例的时间大于 TTL 时间时,多个客户端才能同时锁定 N/2+1 个实例(在第 2 阶段结束时结束),从而导致锁定无效。

您能否提供正式的安全证明,指出现有的类似算法,或者发现上述算法中的错误?

辅助功能注意事项

系统可用性取决于三个主要特征:

  1. 自动释放锁(当密钥过期时):密钥最终将再次可用于锁定。
  2. 事实上,当所需的锁尚未获取或已获取且作业已完成时,客户端通常会通过删除锁来互相帮助; 因此我们很可能不必等待密钥过期来重新获取锁。
  3. 事实上,当客户端需要重试获取锁时,它等待的时间比获取大多数锁所需的时间要长。 这降低了争夺资源时出现脑裂情况的可能性。

但是,可用性惩罚等于网段的 TTL,因此如果存在连续的网段,则惩罚可能是无限期的。 每当客户端获取锁,然后在释放锁之前窃取到另一个段时,就会发生这种情况。

原则上,给定无限连续的网络段,系统可以在无限长的时间内保持不可用状态。

性能、故障转移和 fsync

许多人使用Redis是因为他们需要较高的锁服务器性能,包括获取和释放锁所需的延迟以及每秒可以完成的获取/释放次数。 为了满足这个需求,有一个策略是与N个Redis服务器进行通信以减少延迟。 这是一种多路复用策略(或“穷人的多路复用”,其中套接字置于非阻塞模式,发送所有命令,并稍后读取命令,假设客户端和每个实例之间的往返时间相似) 。

然而,如果我们努力创建一个能够可靠地从故障中恢复的模型,我们还必须考虑与长期数据存储相关的考虑。

基本上,为了澄清这个问题,我们假设我们配置的 Redis 根本没有长期数据存储。 客户端成功阻止了 3 个实例中的 5 个。 客户端设法阻止的实例之一重新启动,此时同一资源又出现了 3 个实例,我们可以阻止这些实例,而另一个客户端又可以阻止重新启动的实例,这违反了安全属性假定锁的排他性。

如果启用数据提前(AOF),情况会略有改善。 例如,您可以通过发送 SHUTDOWN 命令并重新启动服务器来升级服务器。 由于 Redis 中的过期操作在语义上是通过这样的方式实现的:即使服务器关闭,时间也会继续流动,因此我们的所有要求都很好。 只要保证正常关机,这是正常的。 停电时该怎么办? 如果 Redis 默认配置为每秒在磁盘上进行 fsync 同步,那么重启后我们可能会找不到密钥。 理论上,如果我们想保证实例重启过程中的锁安全,我们应该启用 fsync=always 在长期数据存储的设置中。 这将完全降低性能,降低到传统上用于安全实现分布式锁的 CP 系统的水平。

但情况比乍一看要好。 原则上,算法的安全性得以保留,因为当实例在故障后重新启动时,它不再参与当前活动的任何锁定。

为了确保这一点,我们只需要确保在发生故障后实例在稍微超过我们使用的最大 TTL 的一段时间内保持不可用。 这样,我们将等到到期日期并自动释放故障时处于活动状态的所有密钥。

使用延迟重启,即使在 Redis 中没有任何长期持久性的情况下,原则上也可以实现安全性。 但请注意,这可能会因违反可访问性而被处以罚款。 例如,如果大多数实例发生故障,系统将在 TTL 内变得全局不可用(并且在此期间不能阻止任何资源)。

我们提高了算法的可用性:我们扩展了阻塞

如果客户端执行的工作由小步骤组成,则可以减少默认锁定持续时间并实现扩展锁定的机制。 原则上,如果客户端正忙于计算,并且锁过期值过低,您可以向所有实例发送一个 Lua 脚本,延长密钥的 TTL,前提是密钥仍然存在,并且其值仍然是锁定时获得的随机值。已获取锁。

仅当客户端在有效期内成功锁定了大多数实例时,才应考虑重新获取锁。

确实,从技术上讲,该算法不会改变,因此必须限制重复尝试获取锁的最大次数,否则将违反可访问性属性。

来源: habr.com

添加评论