使用Redis实现微服务分布式锁


Redis 以其高性能和支持高读/写 QPS 的能力而闻名,这是作为分布式锁服务的后备存储非常理想的属性。此外,Redis 本身也支持 Lua 脚本。开源社区中有很多基于 Redis 的分布式锁的实现。总体而言,基于 Redis 的分布式锁比基于 MySQL 的分布式锁性能更高。下面我们来看几个使用 Redis 构建分布式锁的例子。

具有单个 Redis 实例的分布式锁
实现 1. 使用内置的 SETNX
SETNX 表示如果不存在则设置。如果键不存在,此命令设置键值对。否则,它是无操作的。Redis 键值对可以用来表示锁。如果存在密钥,则意味着客户端持有锁。任何key都可以用作共享资源的锁。假设我们定义了一个名为 的锁lock_name,当尝试获取锁时,我们可以使用以下命令:

SETNX lock_name true

这里我们尝试设置一个 kv 对,键是锁名lock_name,值是任意值true。通常,锁名可以是任何有效的 Redis 变量名,并且值是任意的,因为我们只对密钥是否存在感兴趣。如果此命令成功,则获取锁。否则意味着锁被其他客户端持有,当前客户端应该在一段时间后重试。

当我们用完共享资源想要释放锁时,我们可以简单地使用 DEL 命令删除 Redis 中的键。

DEL lock_name

SETNX 确保分布式锁的排他性lock_name——任何时候只有一个客户端可以持有锁。然而,这个简单的实现对于失败是不可靠的。例如,如果持有锁的客户端由于网络分区或进程退出而没有响应,则无法正确释放锁。死锁发生,因为其他客户端也无法获取此锁。这种故障模式在分布式系统中很常见,因此我们需要使用更健壮的实现。

实现 2. 使用内置 SET (NX EX)
避免上述死锁的常用方法是为锁设置一个 TTL。一旦 key 过期,Redis 会自动删除(即 TTL 后自动释放锁),即使持有锁的客户端无法释放锁。由于 SETNX 不支持直接设置 TTL,因此需要额外的 EXPIRE 命令。从概念上讲,获取锁的工作流程应该是这样的:

SETNX lock_name arbitrary_lock_value
EXPIRE lock_name 10

这种方法的问题在于 SETNX 和 EXPIRE 不是原子操作,而是两个独立的操作。SETNX 总是有可能成功而 EXPIRE 失败。
Redis 原生支持带有一组选项的 SET 命令,例如 SET (key, value, NX, EX, timeout),允许对 SETNX 和 EXPIRE 进行原子操作。获取/释放锁:

SET lock_name arbitrary_lock_value NX EX 10 # acquire the lock
# ... do something to the shared resource
DEL lock_name # release the lock

在上面的命令中,NX 与 SETNX 中的含义相同,而 EX 10 表示 TTL 为 10 秒。

我们有一个基于Redis的分布式锁服务,它提供了排他性,并且可以在客户端失败时自动释放锁。然而,对于一个最小可行的锁服务,我们应该考虑另一种失败模式:
假设客户端A持有锁,而A需要比平时更长的时间来完成任务,key已经TTL过期了,锁被自动释放了。这时,另一个客户端B有可能通过再次向Redis写入k-v对而成功获得相同的锁。
在这种情况下,客户端A仍然认为它持有该锁,所以在任务完成后,客户端A试图删除Redis中的k-v条目并成功了。本质上,客户端A删除了其他客户端持有的锁,这在实际生产环境中可能是灾难性的。

实施 3. SET (NX EX) + 在锁定释放前检查唯一的客户端 ID
设置key时,客户端应将唯一的客户端 ID 添加到 kv 对。在删除key之前,客户端应该检查这个 ID 以确定它是否仍然持有锁。如果 ID 不匹配,则表示该锁被其他客户端持有,当前客户端不应删除该key。从概念上讲,工作流程是:

SET lock_name client_id NX EX 10 # acquire the lock
# ... do something to the shared resource
# check if client_id matches stored value in k-v pair
IF client_id == GET lock_name
    DEL lock_name # release the lock

同样,IF 条件和 DEL 应该是原子的,但它们实际上是两个独立的操作。当客户端尝试释放锁时,需要这种获取-比较-删除操作。在这种情况下,我们可以使用 Lua 脚本将这些命令包装成一个原子操作。几乎所有 Redis 分布式锁的实现都包含类似于以下的 Lua 脚本片段:
// 如果来自 Redis GET 操作的值等于传入的值 // 从参数,则删除键if redis.call("get", "lock_name") == ARGV[1] 
// if the value from Redis GET operation equals the value passed in // from argument, then delete the key
if redis.call(
"get", "lock_name") == ARGV[1]
  then
    return redis.call(
"del", "lock_name")
  else
    return 0
end

这里 ARGV[1] 是一个输入参数。之前client_id获取锁的时候设置的,这里应该传入。client_id可以是客户端的有意义的标识符,或者只是一个 UUID 。


此实现满足以下 3 个功能要求。

  1. 由 SET 中的 NX 选项保证互斥
  2. TTL 机制在客户端失败时自动释放锁,还使用唯一的 client_id 来确保不允许客户端释放其他客户端持有的任意锁。
  3. 用于获取和释放锁的 API

实现4.开源解决方案Redisson
在上述解决方案中,我们实现了get-compare-set,以避免意外释放其他客户端持有的锁(即从Redis中删除相应的kv对)。我们还需要解决一个额外的问题:假设客户端 A 持有锁,并且需要比平时更长的时间来完成共享资源上的任务。但是,如果锁是由于 TTL 自动释放的,而客户端 A 实际上仍然需要锁怎么办?
一种简单的方法是将 TTL 设置得足够长。实际上,考虑到分布式锁服务通常服务的大量异构客户端,并且每个客户端在获取锁后都有其独特的业务逻辑要处理,因此很难将 TTL 设置为“正确”。
更通用的解决方案是,一旦客户端持有锁,它就会启动一个守护线程来定期检查锁是否存在。如果是这样,守护线程将重置 TTL 以防止锁自动释放。这种策略有时被称为租赁策略,意思是一个锁只租给一个具有固定租期长度的客户端,并且在租约到期之前,如果仍然需要锁,客户端应该更新租约。例如,开源解决方案Redisson 就采用了这种策略。这是 Redisson 中看门狗守护进程的高级示意图:


一旦获得锁,就会启动 WatchDog 守护线程。此后台线程定期检查客户端是否仍持有锁并相应地重置 TTL。这种策略有助于防止过早的锁释放。

带 Redis 集群的分布式锁
简单回顾一下,我们现在有一个基于单个 Redis 实例的分布式锁服务。Redis 内置命令 SET (NX EX) 用于原子获取锁并设置 TTL,而 Lua 脚本用于原子释放锁。使用唯一的客户端 ID 和 get-compare-set 逻辑,以便客户端无法释放其他客户端持有的任意锁。此外,守护线程用于在后台更新锁租约。
现在唯一的弱点是 Redis 实例本身,这是一个单点故障。系统可以处理的最大锁获取/释放 QPS 也受到单个 Redis 实例的 CPU/内存的限制。为了提高可用性和可扩展性,通常使用 Redis 集群。Redis集群的更多细节请参考之前的一篇文章Redis集群如何实现高可用和数据持久化。 然而,集群的使用会引入更多我们需要考虑的故障模式。让我们来看看这里的主要问题。请继续关注下一篇文章,我们将深入探讨解决方案。

复制滞后和领导者故障转移
我们来看看这个集群中leader failover导致的故障场景:

  • 客户端从 Redis 领导者实例获取了锁。
  • 由于复制滞后,代表领导者实例锁定的 kv 对尚未同步到跟随者实例。
  • 领导者失败,并触发故障转移过程,将追随者实例之一提升为新领导者。
  • 未同步的 kv 对将丢失。对于请求新领导者的其他客户端,就好像这些锁仍然可用。结果,多个客户端可以成功获取同一个锁,违反了排他性要求。

为了解决这个问题,Redis 的发明者提出了 RedLock 算法。

Redis 代理可能不支持 Lua
正如深入 Redis 集群:分片算法和架构中所讨论的,分片Redis 集群 + 代理通常用于实际生产环境。在这样的 Redis 架构中,客户端将直接与代理对话,而不是与底层的 Redis 集群对话。代理计算密钥的哈希值,并确定应由哪个 Redis 实例处理任务。不过,并非所有代理都支持 Lua 脚本。

在这种情况下,我们需要使用代理支持的语言来实现我们自己的原子 get-compare-set 操作。例如,redislock是 Redis 上分布式锁的开源 Golang 实现。