基于本文回答

播面 播面

刷题像听歌,多听自然懂
0
评论

如何基于 Redis 或 ZooKeeper 实现一个高可用的分布式锁?

知识点图片

实现一个高可用的分布式锁,核心在于解决三个问题:互斥性(同一时间只有一个客户端持有锁)、容错性(持有锁的客户端挂掉后锁能自动释放)、高可用性(锁服务本身挂掉部分节点不影响服务)。

以下是基于 Redis 和 ZooKeeper 两种主流方案的详细实现原理及高可用策略。


方案一:基于 Redis 实现 (Redlock 算法)

普通的 Redis 分布式锁(单机 SET NX PX)存在单点故障风险。为了实现高可用,Redis 作者 Antirez 提出了 Redlock 算法。

1. 核心原理

Redlock 不依赖主从复制(因为主从复制是异步的,可能导致锁丢失),而是直接操作多个独立的 Redis Master 节点。

假设有 N 个(通常为 5 个)独立的 Redis 节点:

  1. 获取当前时间:记录开始尝试获取锁的时间戳 T1T_1
  2. 按顺序尝试在所有 N 个节点获取锁
    • 使用相同的 Key 和 Value(Value 必须是唯一随机值,如 UUID)。
    • 设置较短的连接超时时间(远小于锁的 TTL),防止在一个宕机的节点上卡住。
  3. 计算获取锁消耗的时间Telapsed=TnowT1T_{elapsed} = T_{now} - T_1
  4. 判断是否成功
    • 条件 1:在至少 N/2 + 1 个节点(过半数)上成功获取了锁。
    • 条件 2:Telapsed<TTLT_{elapsed} < TTL(锁的有效期)。
    • 只有同时满足这两个条件,才算获取锁成功。
  5. 锁的实际有效时间ValidityTime=TTLTelapsedClockDriftValidityTime = TTL - T_{elapsed} - ClockDrift(时钟漂移)。
  6. 失败处理:如果获取失败(没凑够半数或超时),必须在所有节点上执行解锁操作(防止某些节点虽然加锁成功但客户端以为失败了)。

2. 关键技术点

  • 原子加锁:使用 SET key value NX PX 30000
  • 原子解锁:使用 Lua 脚本,确保只有持有该 Value 的客户端才能删除 Key。
    plaintext
    if redis.call("get",KEYS[1]) == ARGV[1] then
        return redis.call("del",KEYS[1])
    else
        return 0
    end
  • 看门狗 (Watch Dog):业务逻辑执行时间超过 TTL 怎么办?需要一个后台线程(看门狗)在锁快过期时自动续期。

3. 推荐类库

Java 推荐使用 Redisson。它封装了 Redlock 算法和看门狗机制,开箱即用。


方案二:基于 ZooKeeper 实现

ZooKeeper (ZK) 基于 CP 模型(强一致性),天然适合做分布式协调。

1. 核心原理

利用 ZK 的 临时顺序节点 (Ephemeral Sequential Nodes)Watcher 机制

  1. 创建节点:客户端在 ZK 的 /locks 目录下创建一个临时顺序节点,例如 /locks/lock-0000001
  2. 判断序号:客户端获取 /locks 下的所有子节点,并按序号排序。
  3. 获取锁成功:如果自己创建的节点序号是最小的,则获得锁。
  4. 获取锁等待 (Watcher):如果自己不是最小的,找到比自己序号小 1 的那个节点,对它注册一个 删除事件监听器 (Watcher)
  5. 等待唤醒:当前客户端阻塞等待。当监视的前一个节点被删除(前一个客户端释放锁或断开连接)时,ZK 通知当前客户端。
  6. 重试:收到通知后,重复步骤 2。

2. 为什么是高可用的?

  • ZAB 协议:ZK 集群内部通过 ZAB 协议保证数据一致性。只要集群中半数以上节点存活,就能正常提供服务。
  • 自动释放:由于是临时节点,如果客户端宕机或网络分区,Session 断开,ZK 会自动删除该节点,从而触发 Watcher,下一个客户端自动获得锁,避免死锁。

3. 推荐类库

Java 推荐使用 Apache Curator。它封装了 InterProcessMutex,处理了复杂的 Watcher 注册、重试循环和连接异常。


方案对比与选型建议

特性 Redis (Redlock) ZooKeeper
一致性模型 AP 偏向 (可能存在极低概率的锁冲突) CP (强一致性)
性能 极高 (纯内存操作,无强一致性同步开销) 中等 (需要集群同步,创建删除节点开销大)
可靠性 依赖系统时钟,若发生大的时钟跳变可能导致锁失效 不依赖系统时钟,依赖 Session 心跳
死锁处理 依赖 TTL (过期时间) 依赖 Session (临时节点)
实现复杂度 复杂 (需要考虑时钟漂移、节点容错) 简单 (利用 ZK 原生机制)

选型建议:

  1. 追求极致性能,允许极低概率的并发错误:选 Redis

    • 场景:高并发抢单、秒杀、缓存击穿保护。
    • 注意:Redis 即使使用 Redlock,在极端网络延迟或 GC Pauses(垃圾回收停顿)下,理论上仍可能出现两个客户端同时持有锁的情况(Martin Kleppmann 对 Redlock 的质疑)。
  2. 追求强一致性,对性能要求不高:选 ZooKeeper

    • 场景:金融转账、订单状态变更、关键配置修改。
    • ZK 的可靠性更高,不会出现“锁丢失”的情况,且不需要预估业务执行时间(TTL)。

进阶:如何解决 "GC Pause" 问题?

无论用 Redis 还是 ZK,都面临一个极端问题:

  1. 客户端 A 拿到锁。
  2. 客户端 A 发生 Full GC,整个 JVM 停顿(STW)。
  3. 锁超时(Redis TTL 到期)或 Session 超时(ZK 心跳丢失)。
  4. 客户端 B 拿到锁。
  5. 客户端 A GC 结束,继续执行代码,此时 A 和 B 同时在操作共享资源。

解决方案:Fencing Token (栅栏令牌)
这需要存储层(如数据库)配合。

  1. 锁服务在颁发锁时,返回一个单调递增的 Token (比如 ZK 的 zxid)。
  2. 客户端操作数据库时,带上这个 Token。
  3. 数据库检查:UPDATE table SET val=x WHERE id=y AND current_token < new_token
  4. 如果 Token 小于数据库中记录的 Token,说明是过期的客户端,拒绝写入。
00:00
00:00