Distributed locks with Redis

Posted by BY morningcat on April 9, 2020

在许多环境中,不同的进程必须以互斥的方式使用共享资源进行操作时,分布式锁是非常有用的解决方案。

有许多库和博客文章描述了如何使用 Redis 实现 DLM(Distributed Lock Manager),但是每个库都使用不同的方法,与使用稍微复杂一些的方法相比,许多库使用的方法具有较低的保证设计。

该页面试图提供一种更规范的算法来实现 Redis 的分布式锁。我们提出了一种称为 Redlock 的算法,该算法实现了 DLM,我们认为它比普通的单实例方法更安全。我们希望社区能够对其进行分析,提供反馈,并将其用作实现或更复杂或替代设计的起点。

一些实现

https://redis.io/topics/distlock#implementations

其中 Java 实现为:

安全与活跃度保障

我们将仅使用三个属性来对设计建模,从我们的角度来看,这三个属性是有效使用分布式锁所需的最低保证。

  • 安全属性

互斥。在任何给定时刻,只有一个客户端可以持有锁。

  • 活跃度属性 A

无死锁。最终,即使锁定资源的客户端崩溃或分区,也始终可以获得锁。

  • 活跃度属性 B

容错能力。只要大多数 Redis 节点都处于运行状态,客户端就可以获取和释放锁。

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

为了了解我们要改进的地方,让我们使用大多数基于 Redis 的分布式锁库来分析当前的事务状态。

使用 Redis 锁定资源的最简单方法是在实例中创建密钥。密钥通常使用 Redis 过期功能在有限的生存时间内创建,因此最终将被释放(列表中的属性2)。当客户端需要释放资源时,它将删除密钥。

从表面上看,这很好,但是存在一个问题:这是我们架构中的单点故障。如果Redis主节点宕机了怎么办?好吧,让我们添加一个从节点!如果主节点不可用,请使用它。不幸的是,这是不可行的。这样,我们就无法实现互斥的安全属性,因为 Redis 复制是异步的。

此模型存在明显的竞争条件:

  1. 客户端A获取主节点中的锁。
  2. 主节点在将密钥写入传输到从节点之前,主节点崩溃。
  3. 从节点选举成为主节点
  4. 客户端B获取对相同资源A的锁定,而该资源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个实例或有效时间为负)而未能获得该锁,则它将尝试解锁所有实例(即使它认为不是该实例)能够锁定)。

Redlock 算法是异步的吗

该算法基于这样的假设:尽管各进程之间没有同步时钟,但每个进程中的本地时间仍然以大约相同的速率流动,并且与锁的自动释放时间相比,误差很小。这个假设与现实世界的计算机非常相似:每台计算机都有一个本地时钟,我们通常可以依靠不同的计算机来产生很小的时钟漂移。

在这一点上,我们需要更好地指定我们的互斥规则:只有在持有锁的客户端将在锁有效时间内(如步骤3中获得的)减去一段时间(仅几毫秒)后,才能保证其终止工作。为了补偿进程之间的时钟漂移)。

有关需要限制时钟漂移的类似系统的更多信息,本文提供了有趣的参考:租约:一种有效的容错机制,可实现分布式文件缓存一致性。 http://dl.acm.org/citation.cfm?id=74870

失败重试

当客户端无法获取锁时,它应在随机延迟后重试,以尝试使试图同时获取同一资源的多个客户端不同步(这可能会导致大脑分裂的情况,其中没人胜)。同样,客户端在大多数Redis实例中尝试获取锁定的速度越快,出现裂脑情况(以及需要重试)的窗口就越小,因此理想情况下,客户端应尝试将SET命令发送到N个实例同时使用多路复用。

值得强调的是,对于未能获取大多数锁的客户端,尽快释放(部分)获取的锁有多么重要,这样就不必等待密钥期满才能再次获取锁(但是,如果发生网络分区,并且客户端不再能够与Redis实例进行通信,则在等待密钥到期时需要支付可用性损失)。

释放锁

安全论据

活跃度论据

性能,崩溃恢复和fsync

使算法更可靠:扩展锁