HOOOS

Redis 分布式锁设计:如何同时防死锁与“脑裂”

0 44 锁匠阿强 Redis分布式锁高并发
Apple

在分布式系统里,当多个服务实例需要访问同一个共享资源时,为了避免数据不一致或者操作冲突,我们通常需要一把“锁”来保证同一时间只有一个实例能操作。Redis 因为其高性能和原子操作特性,经常被用来实现分布式锁。但这事儿没那么简单,一不小心就会踩坑,最常见的两个大坑就是 死锁“脑裂”(我自己这么叫,指的是客户端以为自己还持有锁,但锁其实已经没了)。咱们今天就来深挖一下,怎么用 Redis 设计一个相对健壮的分布式锁,尤其是要搞定这两个问题,还得考虑网络不稳的情况。

一、最基础的尝试与缺陷

我们先从最简单的想法开始。

1. SETNX 的原始冲动

Redis 有个 SETNX 命令(SET if Not eXists),如果 key 不存在,就设置 key-value,返回 1;如果 key 已存在,啥也不做,返回 0。这看起来天然适合做锁:

# 尝试获取锁
SETNX lock_key true

如果返回 1,恭喜,拿到锁了!干活去吧。如果返回 0,说明锁被别人拿着呢,等等再试。

问题在哪? 如果拿到锁的客户端干活干到一半,自己崩了,没来得及释放锁(比如用 DEL lock_key),那这个 lock_key 就永远存在了,其他客户端再也拿不到锁,系统就卡死了——这就是 死锁

2. SETNX + EXPIRE 的天真组合

为了解决死锁,我们自然想到给锁加个“有效期”(TTL, Time To Live)。拿到锁之后,再用 EXPIRE 命令给它设个超时时间,比如 30 秒。就算客户端崩了,30 秒后 Redis 也会自动把这个 key 删掉,锁就自动释放了。

# 尝试获取锁
SETNX lock_key true
# 如果获取成功,设置超时
IF 上一步返回 1 THEN
  EXPIRE lock_key 30
ENDIF

问题又在哪? SETNXEXPIRE 是两条命令,它们不是原子操作!如果在 SETNX 成功之后,客户端还没来得及执行 EXPIRE 就崩了,那还是会发生死锁。虽然这个窗口期很小,但在高并发或不稳定的环境里,它就是定时炸弹。

二、原子获取与 TTL:解决死锁的关键一步

幸运的是,Redis 早就考虑到了这个问题。从 Redis 2.6.12 版本开始,SET 命令增加了牛逼的参数,可以原子性地完成“如果不存在则设置,并指定超时时间”的操作。

# 原子地尝试获取锁,设置值为一个唯一标识,并设置30秒超时
SET lock_key unique_value NX PX 30000

解释一下参数:

  • lock_key: 锁的名称。
  • unique_value: 这个后面会讲,现在先理解为锁的值。
  • NX: 等同于 SETNX,只在 key 不存在时才设置。
  • PX 30000: 设置 key 的过期时间为 30000 毫秒(30 秒)。注意这里用 PX 指定毫秒,比 EX 指定秒更精确。

这条命令是 原子 的。要么成功(key 不存在,设置了 value 和 TTL),要么失败(key 已存在)。这样就彻底解决了客户端在 SETNXEXPIRE 之间崩溃导致的死锁问题。只要设置了 TTL,锁最终一定会释放。

思考一下:TTL 设置多长合适? 这是个权衡。设太短,业务逻辑还没执行完,锁就过期了,可能导致多个客户端并发执行(后面会细说这个问题)。设太长,如果客户端真的崩了,这个资源就会被锁定更长时间,影响可用性。通常需要根据业务操作的预估最大耗时来设定,并留一些buffer。

三、 “脑裂”问题与防护令牌(Fencing Token)

解决了死锁,我们来看看另一个棘手的问题:“脑裂”。

场景描述:

  1. 客户端 A 成功获取了锁 lock_key,设置 TTL 为 30 秒。
  2. 客户端 A 开始执行业务逻辑,但因为某些原因(比如 Full GC、网络延迟、操作耗时过长),执行了超过 30 秒。
  3. 此时,lock_key 因为超时被 Redis 自动删除了。
  4. 客户端 B 尝试获取锁,SET lock_key B_unique_value NX PX 30000 成功!
  5. 客户端 B 开始执行业务逻辑。
  6. 客户端 A 从卡顿中恢复过来,它 并不知道 自己的锁已经过期了,它继续执行剩下的业务逻辑(比如写数据库)。

结果是什么?客户端 A 和 B 可能同时操作了共享资源,导致数据混乱或状态不一致。客户端 A 就像发生了“脑裂”,它还以为自己是“大脑”(持有锁),但实际上身体(共享资源)已经被新的“大脑”(客户端 B)控制了。

如何解决?引入防护令牌(Fencing Token)!

核心思想是:给每个锁实例分配一个 唯一且递增 的标识符(或者足够随机的唯一ID),作为锁的 value。客户端在持有锁期间对共享资源进行操作时,必须带着这个令牌去。资源的接收方(比如数据库、另一个服务)需要校验这个令牌。

等等,这里有点绕。我们先简化一下,不要求资源接收方校验。我们只要求 释放锁的时候必须检查锁的值 是否还是自己当初设置的那个。

回到刚才的 SET 命令:

# 生成一个唯一的随机字符串,比如UUID或者毫秒时间戳+随机数
my_random_value = generate_unique_id()
# 尝试获取锁
SET lock_key my_random_value NX PX 30000

如果设置成功,客户端 A 就记住了 my_random_value。当 A 完成业务逻辑,准备释放锁时,不能直接 DEL lock_key。为什么?因为可能此时锁已经被 B 获取了(值为 B_unique_value),你一 DEL 就把 B 的锁给释放了,天下大乱!

正确的释放姿势是:原子地 检查锁的值是否还是自己设置的那个 my_random_value,如果是,才删除。

四、原子释放:Lua 脚本显神威

Redis 不直接提供“检查值并删除”的原子命令。但是,它提供了 Lua 脚本 的能力!Lua 脚本在 Redis 服务端是原子执行的。我们可以写一个简单的 Lua 脚本来实现安全的锁释放:

-- Lua script for safe release
-- KEYS[1]: 锁的 key (e.g., "lock_key")
-- ARGV[1]: 客户端持有的唯一值 (e.g., "my_random_value")

if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end

这个脚本的意思是:

  1. 获取 KEYS[1](锁的 key)当前的值。
  2. 比较这个值和 ARGV[1](客户端当初设置的唯一值)是否相等。
  3. 如果相等,说明锁确实还是我的,执行 DEL KEYS[1] 删除锁,并返回 1 (删除成功)。
  4. 如果不相等,说明锁已经不是我的了(可能过期被别人抢了,或者是我误操作),啥也不干,返回 0。

客户端调用这个脚本(使用 EVALEVALSHA 命令),就能保证释放锁的操作是安全的,不会误删别人的锁。

至此,结合 SET key unique_value NX PX ttl 获取锁和 Lua 脚本释放锁,我们已经有了一个在单实例 Redis 上相对健壮的分布式锁实现。 它能防止死锁(靠 TTL),也能在一定程度上缓解“脑裂”(靠 unique_value 和原子释放,确保不会错误地释放别人的锁)。

但是! 这个“缓解”并不完美。如果在客户端 A 持有锁期间,锁过期了,B 获取了锁。然后 A 恢复了,虽然它释放锁时会失败(因为值对不上了),但它在释放锁 之前 执行的那些业务操作(比如写数据库)可能已经污染了数据。防护令牌真正的威力在于 每次操作共享资源时 都去校验令牌的有效性,但这通常需要在资源层面(数据库、服务接口)做配合,实现起来更复杂。

五、单实例 Redis 的局限性

我们上面的讨论都是基于单个 Redis 实例。这有什么问题?

  1. 单点故障 (SPOF):如果这个 Redis 实例挂了,整个分布式锁服务就瘫痪了。所有需要锁的服务都会阻塞或失败。
  2. 数据丢失风险:Redis 的持久化(RDB 和 AOF)通常是异步的。如果 Redis 刚把锁 lock_key 分配给客户端 A,还没来得及持久化就宕机了。重启后,lock_key 没了。这时客户端 B 来请求锁,就能成功获取。但客户端 A 可能还认为自己持有锁(因为它没收到 Redis 宕机的通知)。这就又回到了类似“脑裂”的状态。即使使用 AOF 的 fsync always 模式(每次写入都强制同步到磁盘),性能也会急剧下降,而且依然无法完全避免极端情况下的问题。

单实例 Redis 提供的锁,其可用性和一致性都受限于这个单点。对于要求不高的场景可能够用,但对于需要高可用、强一致的场景,就不太行了。

六、Redlock 算法:追求高可用的尝试

为了解决单点问题,Redis 的作者 Salvatore Sanfilippo (antirez) 提出了 Redlock (Redis Distributed Lock) 算法。

核心思想: 不再依赖单个 Redis 实例,而是向 多个独立的 Redis 实例(通常建议 5 个,部署在不同的机器或可用区)发起加锁请求。只有当客户端成功地在 大多数(N/2 + 1)实例上获取了锁,并且总耗时小于锁的有效时间,才认为加锁成功。

大致步骤(以 5 个实例为例):

  1. 客户端记录当前时间 T1。
  2. 客户端 依次(或理论上可以并发,但依次更简单)向 5 个 Redis 实例发起 SET lock_key unique_value NX PX ttl 请求。注意,这里的 ttl 应该设置得比较短,比如几秒或几十毫秒。并且,客户端需要为每个请求设置一个 远小于 ttl网络超时(比如几毫秒到几十毫秒),避免因为某个实例慢或挂掉而卡住太久。
  3. 客户端记录收到所有(或超时)响应后的时间 T2。
  4. 客户端检查:
    • 是否成功在 至少 3 个 (5/2 + 1 = 3) 实例上获取了锁?
    • 总耗时 T2 - T1 是否小于锁的有效时间 ttl?(需要留出一部分时间作为时钟漂移的容忍空间)
  5. 如果两个条件都满足,那么恭喜,锁获取成功!此时锁的 实际有效时间 应该重新计算为 ttl - (T2 - T1) - clock_drift_margin
  6. 如果条件不满足(获取的锁不够多数,或者耗时太长),那么认为获取锁失败。客户端 必须 立即向 所有 5 个 Redis 实例(无论之前是否成功获取)发起 释放锁 的请求(使用前面提到的 Lua 脚本,带上 unique_value)。这是为了清理掉可能部分成功的锁,避免影响其他人。

释放锁: 客户端向所有 N 个实例发送释放锁的 Lua 脚本即可。

优点:

  • 容错性:只要不超过 N/2 - 1 个 Redis 实例宕机,锁服务仍然可用。
  • 避免单点瓶颈:压力分散到多个实例。

七、Redlock 的争议与挑战

Redlock 听起来很美好,但自提出以来就伴随着巨大的争议,其中最著名的批评者是 Martin Kleppmann (《数据密集型应用系统设计》作者)。

核心争议点围绕着“时间”和“系统假设”:

  1. 时钟漂移 (Clock Drift):Redlock 的安全性严重依赖于多个节点和客户端之间的时钟大致同步。虽然它试图通过减去请求耗时来补偿,但无法处理时钟突然跳变(比如 NTP 调整)或显著的、持续的漂移。如果时钟差异过大,锁的实际有效时间可能比计算出来的要短,导致锁提前过期。

  2. 网络延迟 (Network Latency):客户端计算出的锁有效时间是基于 T1 和 T2 的。但网络延迟是变化的。可能客户端认为锁还有 10ms 才过期,但实际上由于网络延迟,它发送的操作指令到达共享资源时,锁已经过期了,并且可能被另一个客户端获取。

  3. 进程暂停 (Process Pauses):这是最致命的批评之一。假设客户端 A 获取了 Redlock,然后它的进程发生了长时间的暂停(比如因为 Full GC)。暂停期间,它在所有 Redis 实例上的锁都过期了。客户端 B 此时可以成功获取 Redlock。然后,客户端 A 从暂停中恢复,它 依然认为自己持有锁(因为它没有感知到暂停期间时间流逝了多久),继续执行危险操作。虽然我们之前提到的 防护令牌 (Fencing Token) 在这里 极其重要,可以帮助资源层面 检测 到 A 的操作是过期的(因为 B 已经用新的令牌操作过了),但这表明 Redlock 本身 并没有阻止 这种并发冲突的发生,它只是提供了一个概率上更可靠的锁获取机制。安全性的最后一道防线往往还是 Fencing Token。

  4. 异步模型的破坏:Kleppmann 认为,分布式系统中的安全属性(如互斥)通常应该依赖于异步模型(不依赖时间上限),比如通过 Raft/Paxos 这样的共识算法。Redlock 过度依赖时钟和 TTL,使得它的安全保证在现实世界的各种异常情况下(网络、GC、时钟问题)变得脆弱。

  5. 复杂性:实现和正确配置 Redlock 比单实例锁复杂得多,运维成本也更高。

antirez 的回应:antirez 认为 Kleppmann 的一些批评过于理论化,Redlock 在实践中对于大多数场景是足够安全的,并且性能远好于基于磁盘的共识系统。他强调 Redlock 的目标是在 Redis 的性能和可用性之间提供一个比单实例更好的锁方案,而不是要达到 Zookeeper 那样的强一致性保证。

八、实践中的考量与替代方案

那么,我们到底该用什么?

  1. 评估需求:首先问自己,你的业务场景对锁的 一致性要求有多高?是否能容忍 极小概率 的锁失效导致的数据不一致?如果不能容忍,那么基于 Redis 的锁(包括 Redlock)可能都不是最佳选择。

  2. 单实例 + Fencing Token + 持久化:对于很多场景,一个配置了 良好持久化(比如 AOF fsync everysec)的 单 Redis 实例(或主从架构,但锁操作只在主库),结合我们前面讨论的 SET key unique_value NX PX ttl 获取锁 和 Lua 脚本安全释放锁,再加上在 关键业务操作层面校验 Fencing Token,可能已经足够好。这种方案简单、高效,虽然理论上存在单点故障和极端情况下的数据丢失风险,但实践中往往能满足需求。

  3. Redlock 的适用场景:如果你确实需要比单实例更高的可用性,并且能接受 Redlock 的复杂性和其理论上的争议(或者你认为这些争议在你的环境中风险可控),那么可以考虑 Redlock。但请务必:

    • 使用奇数个独立的 Redis 实例 (>= 3, 推荐 5)。
    • 仔细调整 ttl 和网络超时。
    • 必须 配合 Fencing Token 在业务操作层面进行校验。
    • 充分测试其在异常情况下的表现。
  4. 寻求更强的保证:共识系统:如果你的业务对数据一致性要求极高,完全不能容忍锁失效带来的问题,那么应该考虑使用基于 共识算法 (如 Raft, Paxos) 的系统来实现分布式锁,例如:

    • Zookeeper:老牌、稳定,被广泛使用,提供强一致性保证,但性能相对 Redis 较低,运维也较复杂。
    • etcd:Kubernetes 使用的分布式键值存储,基于 Raft,同样提供强一致性保证。
    • Consul:也提供基于 Raft 的锁服务。
      这些系统通过共识协议保证了即使在部分节点故障、网络分区(有限情况下)时,锁的状态也是一致的,避免了 Redlock 中依赖时钟带来的问题。代价是通常性能较低,且部署和运维更复杂。
  5. 数据库锁:如果你的共享资源本身就在数据库里,有时利用数据库自身的锁机制(行锁、表锁,或者 SELECT ... FOR UPDATE)可能是最简单直接且安全的方式,因为它能保证操作和锁处于同一个事务内。

  6. 锁续期(看门狗 Watchdog):对于一些需要长时间持有锁的场景,可以在客户端实现一个“看门狗”机制。客户端获取锁后,启动一个后台线程/任务,在锁过期前的一小段时间内,自动去延长锁的 TTL(比如使用 Lua 脚本原子地检查锁值并 PEXPIRE)。这可以避免因为业务执行时间超过初始 TTL 而导致锁提前释放。但这增加了客户端的复杂性,并且如果客户端崩溃,“看门狗”也随之失效。

九、总结与建议

使用 Redis 实现分布式锁是一个充满权衡和陷阱的领域。

  • 基础方案SET key unique_value NX PX ttl + 唯一值 (Fencing Token) + Lua 原子释放 是单实例 Redis 锁的基石。它能防止死锁,并在一定程度上缓解“脑裂”,实现简单高效。
  • 关键防御Fencing Token 是防止因锁过期或失效导致并发操作破坏数据的 极其重要 的手段,无论你用哪种锁实现(单实例、Redlock 还是 ZK),都应该考虑在业务操作层面加入令牌校验。
  • Redlock:试图提供更高可用性,但其安全性依赖于对时间和系统行为的较强假设,存在争议。如果使用,务必配合 Fencing Token,并理解其局限性。
  • 强一致性需求:对于金融等要求极高一致性的场景,优先考虑 Zookeeper、etcd 等基于共识算法的系统。
  • 没有银弹:分布式锁没有完美的通用解决方案。选择哪种方案取决于你对 一致性、可用性、性能、复杂度 的具体要求和权衡。

最后请记住,分布式锁本身就是一种复杂的协调机制。设计时要尽可能简单,并深刻理解你所选择方案的 保证局限。很多时候,通过优化业务流程、使用无锁数据结构或队列等方式,避免 对分布式锁的强依赖,可能是更好的选择。

点评评价

captcha
健康