在许多场景下,分布式锁是非常有用的原语,在这些场景下,不同的进程必须以互斥的方式使用共享的资源执行逻辑。
有许多库和博客文章描述了如何使用 Redis 实现 DLM(Distributed Lock Manager),但每个库都使用不同的方法,许多库使用简单的实现,和稍微复杂一点的设计相比,简答的实现的稳定性会更低点。
本问提供了一种更加规范算法,用 Redis 实现分布式锁。本文提出了一种名为 Redlock 的算法,它实现了一种我们认为比普通的单实例方法更安全的 DLM。
我们将用三个属性来建模我们的设计,这是有效使用分布式锁所需的最低保证:
-
安全属性:互斥。在任何时刻,只有一个客户端可以持有锁。
-
可靠保证 1:无死锁。始终可以获取锁,即使锁定资源的客户端崩溃或被分离。
-
可靠保证 2:容错。只要大部分 Redis节点处于运行状态,客户端就能够获取和释放锁。
使用 Redis 锁定资源的最简单方法是在实例中创建 key。key 通常是使用 Redis expires 功能在有限的生存时间内创建的,因此最终锁将被释放(满足列表中的可靠保证 2)。当客户端需要释放资源时,会删除 key。
该方案存在一个问题:如果架构中的一个单点故障了,便会存在问题。如果 Redis master 节点宕机怎么办?可以添加一个 replica 副本。但是这个方案是不可行的,这样我们就无法实现互斥的安全属性,因为 Redis 复制是异步的。
该模型存在明显的竞争条件:
-
客户端 A 获取 master 节点的锁
-
在将 key 的内容传输到复制副本之前,master 节点崩溃
-
replica 副本升级为 master 节点
-
客户端 B 获得了 A 已经拥有的同一资源的锁,违反安全规定
在特殊情况下,例如在故障期间,多个客户端可以同时持有锁,对系统来说没有问题的。如果是这种情况,你可以使用基于复制的解决方案。否则,我们建议采用本文中描述的解决方案。
在尝试解决上述单实例的问题之前,我们先检查一下在简单的情况下如何正确的设置 key,因为这实际上是一种可行的解决方案,适用于可随时接受竞争条件的应用程序,并且给单个实例加锁是我们在这里描述分布式算法的基础。
要获得锁,方式如下:
SET resource_name my_random_value NX PX 30000
该命令仅在 key 不存在时设置 key,过期时间为 30000 毫秒。该 key 设置为一个值"my_random_value",该值在所有客户端和所有锁请求中必须是唯一的。
基本上,使用随即值是为了以安全的方式释放锁,脚本告诉 Redis:仅当 key 存在且存储在 key 中的值正是我期望的值时才移除 key,这是通过以下 lua 脚本实现的:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这是为了避免删除由另一个客户端创建的锁,这点很重要。例如,某个客户端可能会获取锁,在某些操作中被阻塞的时间超过锁的有效期(key 的过期时间),然后移除了其他客户端已经获取的锁。仅使用 DEL 命令是不安全的,因为一个客户端可能会删除另一个客户端的锁。
在上面的脚本中,每个锁都是用随机字符串"签名"的,因此只有当是正确的客户端时,才能够去删除锁。
这个随机字符串应该是什么?假设它是来自 /dev/urandom 的 20 个字节字符串,或许你可以找到更简单的方法使它对于这个加锁任务来说足够独特。例如,一个安全的选择是使用 /dev/urandom 为 RC4 种子,并从中生成一个伪随机流。
一个更简单的解决方案是将用微秒级别 unix time 和客户端 ID 连接在一起,但是这样就不那么安全了,但在大多数环境中,随机字符串的选择取决于具体的任务。
key 的生存时间被称为"锁定有效时间",它既是自动释放时间,也是另一个客户端能够再次获取锁之前所需等待的时间,这在技术上没有违反互斥保证,互斥保证仅限于从获取锁的那一刻起给定的时间窗口(单个锁不是永久的)。
在该算法的分布式版本中,我们假设有 N 个 Redis 主机。这些节点是完全独立的,所以我们不使用复制或其他的隐式协调系统。我们之前已经描述了如何在单个实例中安全地获取和释放锁。我们也可以继续认为,该算法将使用此方法在单个实例中获取和释放锁。
在我们的示例中,我们设置了 N=5,这是一个合理值,因此我们需要在不同的计算机或虚拟机上运行 5 台 redis 主节点。
为了获取锁,客户端执行以下操作:
-
它以毫秒为单位获取当前时间
-
它尝试在 N 个实例中依次获取锁,在所有实例中使用相同的 key 和随机值。在步骤 2 中,当在每个实例中设置锁时,客户端使用了一个 timeout,该 timeout 与锁自动释放的总时间相比很少,以便能够获取锁。例如,如果自动释放时间为 10 秒,则 timeout 可能在 5-50 毫秒内。这可以防止客户端在尝试与已关闭的 Redis 节点连接时长时间处于阻塞状态:如果某个实例不可用,我们应该尽快尝试和下一个实例连接。
-
客户端通过从当前时间减去步骤 1 中获得的时间戳来计算获取锁所用的时间。如果有且仅当客户端能够在大多数实例(至少 3 个)中获取锁,并且获取锁所用的时间总时间小于锁有效时间,则认为已获取到锁。
-
如果获取到了锁,则其有效时间被视为初始有效时间减去已经经过的时间,如步骤 3 中计算的。
-
如果客户端由于某种原因(无法锁定 N/2+1 个实例或有效期为负数)无法获取锁,它将尝试解锁所有实例(即使是它认为无法锁定的实例)。
当客户端无法获取锁时,它应在一个随机的(自定义)延迟后重试,以便避免多个试图同时获取同一资源锁的客户端进入无人获胜的脑裂状态。此外,客户端在大多数 Redis 实例中尝试获取锁的速度越快,脑裂的可能性就越少(同时需要重试),因此理想情况下,客户端应该尝试使用多路复用同时向 N 个 Redis 实例发送 SET 命令。
值得强调的是,对于无法获得大部分锁的客户端来说,尽快释放(部分)已经获得的锁是非常的重要,这样就不需要等待 key 到期才能再次获得锁(但是如果发生网络分区,并且客户端不再能够与 Redis 实例通信,则在等待 key 过期时需要付出可用性的代价)
释放锁很简单,只需要在所有实例中释放锁,不管客户端是否信任它能够成功锁定给定实例。