如何基于 Redis 或 ZooKeeper 实现一个高可用的分布式锁?
实现一个高可用的分布式锁,核心在于解决三个问题:互斥性(同一时间只有一个客户端持有锁)、容错性(持有锁的客户端挂掉后锁能自动释放)、高可用性(锁服务本身挂掉部分节点不影响服务)。
以下是基于 Redis 和 ZooKeeper 两种主流方案的详细实现原理及高可用策略。
方案一:基于 Redis 实现 (Redlock 算法)
普通的 Redis 分布式锁(单机 SET NX PX)存在单点故障风险。为了实现高可用,Redis 作者 Antirez 提出了 Redlock 算法。
1. 核心原理
Redlock 不依赖主从复制(因为主从复制是异步的,可能导致锁丢失),而是直接操作多个独立的 Redis Master 节点。
假设有 N 个(通常为 5 个)独立的 Redis 节点:
- 获取当前时间:记录开始尝试获取锁的时间戳 。
- 按顺序尝试在所有 N 个节点获取锁:
- 使用相同的 Key 和 Value(Value 必须是唯一随机值,如 UUID)。
- 设置较短的连接超时时间(远小于锁的 TTL),防止在一个宕机的节点上卡住。
- 计算获取锁消耗的时间:。
- 判断是否成功:
- 条件 1:在至少 N/2 + 1 个节点(过半数)上成功获取了锁。
- 条件 2:(锁的有效期)。
- 只有同时满足这两个条件,才算获取锁成功。
- 锁的实际有效时间:(时钟漂移)。
- 失败处理:如果获取失败(没凑够半数或超时),必须在所有节点上执行解锁操作(防止某些节点虽然加锁成功但客户端以为失败了)。
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 机制。
- 创建节点:客户端在 ZK 的
/locks目录下创建一个临时顺序节点,例如/locks/lock-0000001。 - 判断序号:客户端获取
/locks下的所有子节点,并按序号排序。 - 获取锁成功:如果自己创建的节点序号是最小的,则获得锁。
- 获取锁等待 (Watcher):如果自己不是最小的,找到比自己序号小 1 的那个节点,对它注册一个 删除事件监听器 (Watcher)。
- 等待唤醒:当前客户端阻塞等待。当监视的前一个节点被删除(前一个客户端释放锁或断开连接)时,ZK 通知当前客户端。
- 重试:收到通知后,重复步骤 2。
2. 为什么是高可用的?
- ZAB 协议:ZK 集群内部通过 ZAB 协议保证数据一致性。只要集群中半数以上节点存活,就能正常提供服务。
- 自动释放:由于是临时节点,如果客户端宕机或网络分区,Session 断开,ZK 会自动删除该节点,从而触发 Watcher,下一个客户端自动获得锁,避免死锁。
3. 推荐类库
Java 推荐使用 Apache Curator。它封装了 InterProcessMutex,处理了复杂的 Watcher 注册、重试循环和连接异常。
方案对比与选型建议
| 特性 | Redis (Redlock) | ZooKeeper |
|---|---|---|
| 一致性模型 | AP 偏向 (可能存在极低概率的锁冲突) | CP (强一致性) |
| 性能 | 极高 (纯内存操作,无强一致性同步开销) | 中等 (需要集群同步,创建删除节点开销大) |
| 可靠性 | 依赖系统时钟,若发生大的时钟跳变可能导致锁失效 | 不依赖系统时钟,依赖 Session 心跳 |
| 死锁处理 | 依赖 TTL (过期时间) | 依赖 Session (临时节点) |
| 实现复杂度 | 复杂 (需要考虑时钟漂移、节点容错) | 简单 (利用 ZK 原生机制) |
选型建议:
追求极致性能,允许极低概率的并发错误:选 Redis。
- 场景:高并发抢单、秒杀、缓存击穿保护。
- 注意:Redis 即使使用 Redlock,在极端网络延迟或 GC Pauses(垃圾回收停顿)下,理论上仍可能出现两个客户端同时持有锁的情况(Martin Kleppmann 对 Redlock 的质疑)。
追求强一致性,对性能要求不高:选 ZooKeeper。
- 场景:金融转账、订单状态变更、关键配置修改。
- ZK 的可靠性更高,不会出现“锁丢失”的情况,且不需要预估业务执行时间(TTL)。
进阶:如何解决 "GC Pause" 问题?
无论用 Redis 还是 ZK,都面临一个极端问题:
- 客户端 A 拿到锁。
- 客户端 A 发生 Full GC,整个 JVM 停顿(STW)。
- 锁超时(Redis TTL 到期)或 Session 超时(ZK 心跳丢失)。
- 客户端 B 拿到锁。
- 客户端 A GC 结束,继续执行代码,此时 A 和 B 同时在操作共享资源。
解决方案:Fencing Token (栅栏令牌)
这需要存储层(如数据库)配合。
- 锁服务在颁发锁时,返回一个单调递增的 Token (比如 ZK 的 zxid)。
- 客户端操作数据库时,带上这个 Token。
- 数据库检查:
UPDATE table SET val=x WHERE id=y AND current_token < new_token。 - 如果 Token 小于数据库中记录的 Token,说明是过期的客户端,拒绝写入。