分布式锁深度剖析:ZooKeeper(CP)与 Redis(AP)的实现原理与对比
在分布式系统中,锁是协调多个进程对共享资源互斥访问的基础工具。本文将深入分析 ZooKeeper 和 Redis 两种主流分布式锁的实现方案,结合 CAP 理论对比它们的特性,并探讨极端情况下的问题与对策。
一、CAP 理论:分布式锁的设计基石
在开始之前,我们先回顾 CAP 理论,它解释了分布式系统在面临网络分区时的取舍:
| 特性 | 含义 | 说明 |
|---|---|---|
| C(一致性) | 所有节点在同一时刻看到相同的数据 | 写操作完成后,读操作必须返回最新值 |
| A(可用性) | 服务始终可用,每个请求都能收到非错误响应 | 不保证数据最新,但保证不超时 |
| P(分区容错性) | 系统在网络分区(节点间通信中断)时仍能继续运行 | 分布式系统必须选 P,然后在 C 和 A 之间权衡 |
- CP 系统:放弃可用性,保证强一致性和分区容错性(如 ZooKeeper、etcd)
- AP 系统:放弃强一致性,保证高可用和分区容错性(如 Redis 主从架构)
💡 分布式锁需要强一致性吗?通常需要 ------ 如果锁数据不一致,可能导致多个客户端同时进入临界区。但 Redis 为了性能选择了 AP 风格,所以会有丢锁风险。
二、ZooKeeper 实现分布式锁(CP 型)
2.1 基础版本:临时节点 + Watch(存在羊群效应)
ZooKeeper 的数据模型是树形目录(ZNode),支持临时节点 (会话结束时自动删除)和 Watch 机制(监听节点变化)。
加锁思路:
- 在锁目录下创建一个临时节点,比如
/locks/mutex。 - 创建成功的客户端获得锁。
- 其他客户端 watch 该节点,当节点被删除时(锁释放),所有监听客户端同时收到通知,然后抢占创建节点。
问题:羊群效应(Herd Effect)
当锁释放时,大量客户端被唤醒并尝试创建节点,但只有一个能成功,其余失败后会再次 watch,导致 ZooKeeper 瞬时压力巨大,网络开销急剧增加。
2.2 改进方案:临时顺序节点(避免羊群效应)
核心思想: 让所有客户端创建临时顺序节点 (如 /locks/lock_0000000001),节点序号全局递增。谁创建的节点序号最小,谁持有锁。
工作流程:
是
否
客户端请求加锁
在锁目录下创建临时顺序节点
/locks/lock_xxx
获取锁目录下所有子节点并排序
自己是否序号最小?
获得锁,执行业务
watch 序号比自己小的前一个节点
等待前一个节点删除事件
收到删除通知,重新判断
业务完成,删除自己节点
释放锁
为什么避免了羊群效应?
每个客户端只 watch 前一个节点,锁释放时只通知下一个节点(或少数节点),而不是所有客户端。
ZooKeeper 锁的优缺点:
| 优点 | 缺点 |
|---|---|
| 强一致性,锁安全可靠 | 性能相对较低(每次操作需要 ZooKeeper 集群多数确认) |
| 客户端宕机自动释放(临时节点) | 需要维护长连接(会话) |
| 无羊群效应,公平锁 | 实现比 Redis 复杂 |
三、Redis 实现分布式锁(AP 型)
3.1 早期方案:SETNX + EXPIRE(非原子,有风险)
bash
SETNX lock_key 1
EXPIRE lock_key 30
问题:如果 SETNX 后客户端崩溃,EXPIRE 未执行,锁永远不释放。
改进:使用 SET key value NX EX seconds 原子命令(Redis 2.6.12+)。
3.2 Redisson 实现:Lua + WatchDog
Redisson 是 Redis 官方推荐的 Java 分布式锁实现,内部通过 Lua 脚本 保证原子性,并提供 WatchDog 自动续期。
加锁流程(简化版)
WatchDog Redis单机/主节点 Redisson客户端 WatchDog Redis单机/主节点 Redisson客户端 loop [锁未释放且客户端存活] alt [加锁成功] EVAL Lua脚本 (判断锁是否存在,不存在则设置并返回1) 成功(返回1) / 失败(返回0) 启动 WatchDog 线程(每10秒执行) EVAL 续期Lua(将锁过期时间重置为30秒) OK
Lua 脚本核心逻辑(伪代码):
lua
if redis.call('exists', KEYS[1]) == 0 then
redis.call('hset', KEYS[1], ARGV[2], 1)
redis.call('pexpire', KEYS[1], ARGV[1])
return 1
else
if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then
redis.call('hincrby', KEYS[1], ARGV[2], 1)
redis.call('pexpire', KEYS[1], ARGV[1])
return 1
else
return 0
end
end
WatchDog 续期机制:
- 默认锁超时时间 30 秒。
- 每 10 秒检查一次(锁超时时间的三分之一),如果客户端仍持有锁,则重置过期时间为 30 秒。
- 客户端宕机 → WatchDog 线程消失 → 锁到期自动释放(不会永久死锁)。
3.3 Redis 主从架构下的丢锁问题(致命缺陷)
场景: Redis 采用主从 + 哨兵模式(AP 架构)
1. 客户端向 Master 写入锁 key(成功)
2. Master 宕机,数据尚未同步到 Slave
3. 哨兵选举新 Master(原来的 Slave 升为主)
4. 新 Master 中没有锁 key
5. 另一个客户端可以成功加锁 → 两个客户端同时持有锁 ❌
这就是 AP 系统放弃一致性带来的严重后果。
3.4 RedLock 方案及其争议
为了修复主从丢锁问题,Redis 作者提出 RedLock:
- 部署至少 3 个独立的 Redis 主节点(非主从,无复制)。
- 客户端依次向所有节点请求加锁(使用相同的 key 和随机值)。
- 当超过半数节点(N/2+1)加锁成功,且总耗时小于锁有效时间,才认为加锁成功。
- 释放锁时向所有节点发送删除命令。
RedLock 的问题(Martin Kleppmann 等专家指出):
- 依赖系统时钟:锁的有效性依赖各节点时钟一致,时钟跳跃可能导致锁失效。
- 垃圾回收(GC)停顿:客户端 GC 期间,锁可能已过期被其他客户端获取。
- 网络延迟:复杂的同步逻辑,性能不如单节点 Redisson。
- AOF 持久化问题:即使使用 RedLock,如果节点 AOF 未 fsync 就宕机,重启后可能丢失锁数据。
因此,生产环境中绝大多数 Redis 分布式锁直接使用 Redisson 单节点或主从模式,并接受极端情况下的丢锁风险(比如用于非关键业务,或配合业务回滚机制)。
四、ZooKeeper vs Redis 分布式锁对比
| 对比维度 | ZooKeeper(CP) | Redis + Redisson(AP) |
|---|---|---|
| 一致性 | 强一致性(ZAB 协议) | 最终一致性(主从复制延迟可能丢锁) |
| 可用性 | 较低(选举期间不可用) | 高(主从切换快,或单节点一直可用) |
| 性能 | 较低(每次操作需多数确认) | 极高(内存操作 + 单线程) |
| 锁释放 | 临时节点(会话结束自动删) | 主动删除 + 超时释放 + WatchDog 续期 |
| 公平性 | 有序节点保证公平(先请求先得) | 非公平锁(Redisson 默认非公平) |
| 实现复杂度 | 较复杂(需管理会话、Watch) | 简单(Redisson 封装完善) |
| 典型场景 | 对安全性要求极高的场景(如选主、配置管理) | 高并发、高性能场景(如秒杀、防重复提交) |
五、选型建议
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 金融、交易系统 | ZooKeeper / etcd | 锁丢失可能造成资损,必须 CP |
| 高并发秒杀 | Redis 单节点(Redisson) | 性能第一,丢锁概率低,可配合业务幂等 |
| 跨机房部署 | ZooKeeper(CP) | Redis 主从同步跨机房延迟高,丢锁风险增大 |
| 简单防重复 | Redis SET NX EX |
无需 WatchDog,够用就好 |
⚠️ 注意:无论使用哪种锁,业务层都应设计幂等性,作为最后一道防线。
六、总结
- ZooKeeper 锁通过临时顺序节点 + Watch 实现了公平、安全的 CP 锁,避免了羊群效应,但性能相对较低。
- Redis 锁借助 Lua 原子性和 WatchDog 实现了高性能 AP 锁,但在主从架构下存在丢锁风险;RedLock 试图修复但仍有争议。
- CAP 理论帮我们理解两种锁的取舍:ZooKeeper 优先保证一致性与分区容错,Redis 优先保证可用性与分区容错。
分布式锁没有银弹,根据业务对一致性和性能的敏感度做出选择,才是最佳实践。
参考资料:
- 《Redis 设计与实现》
- Redisson 官方文档
- ZooKeeper 官方文档
- Martin Kleppmann 对 RedLock 的批评文章