深入理解分布式锁:ZooKeeper vs Redis
本文从实现原理、可靠性、适用场景等多个维度,深入对比 ZooKeeper 与 Redis 两种主流分布式锁方案,并剖析红锁(RedLock)为何没有成为主流。
一、核心原理对比
Redis 分布式锁
Redis 利用 SET key value NX PX timeout 原子命令实现分布式锁,通过 TTL(过期时间)保证锁不会永久占用。
lua
-- 释放锁需用 Lua 脚本保证原子性,防止误删他人的锁
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
ZooKeeper 分布式锁
ZooKeeper 的数据结构
ZooKeeper 的数据结构像一棵树,每个节点叫 ZNode:
/
├── locks/
│ ├── order_0000000001
│ ├── order_0000000002
│ └── order_0000000003
└── config/
什么是临时顺序节点
ZNode 有两个维度的属性可以组合:
维度一:生命周期
持久节点(Persistent)--- 客户端断开后,节点依然存在
临时节点(Ephemeral) --- 客户端断开后,节点自动删除 ← 锁用这个
维度二:是否有序
普通节点 --- 名字就是你指定的,重复创建会报错
顺序节点(Sequential)--- ZK 自动在名字后面追加递增编号 ← 锁用这个
两者结合就是临时顺序节点 ,比如你创建 /locks/order,ZK 会自动变成:
/locks/order0000000001
/locks/order0000000002
/locks/order0000000003
断开连接后自动删除,且编号全局递增,天然有序。
什么是 Watch 机制
Watch 就是一次性监听,你可以对某个节点注册"它删除时通知我":
客户端 B 对 order0000000001 注册 Watch
↓
order0000000001 被删除
↓
ZK 主动推送事件给客户端 B
↓
B 收到通知,重新竞争锁
注意:Watch 只触发一次,触发后需要重新注册。
加锁完整流程
用 3 个客户端竞争同一把锁举例:
第一步:各自创建临时顺序节点
客户端A 创建 → /locks/order0000000001
客户端B 创建 → /locks/order0000000002
客户端C 创建 → /locks/order0000000003
第二步:各自查看所有子节点,判断自己编号是否最小
A 看到 [001, 002, 003],自己是 001,最小 → 直接获得锁 ✅
B 看到 [001, 002, 003],自己是 002,不是最小 → 监听 001
C 看到 [001, 002, 003],自己是 003,不是最小 → 监听 002
第三步:A 完成业务,释放锁(删除节点)
A 删除 /locks/order0000000001
↓
ZK 通知 B:"001 被删了"
↓
B 重新检查,自己 002 现在是最小 → B 获得锁 ✅
C 继续等待,监听 002
第四步:B 完成,C 获得锁,以此类推
B 删除 /locks/order0000000002
↓
ZK 通知 C → C 获得锁 ✅
为什么监听"前一个"而不是监听"最小节点"?
这是一个关键设计决策。假设所有客户端都监听最小节点 001:
001 删除
↓
B、C、D、E ... 100 个客户端同时收到通知
同时发起请求查询、竞争
→ 羊群效应(Herd Effect),ZK 被瞬间打爆 💥
改成每个节点只监听前一个:
001 删除 → 只通知 002
002 删除 → 只通知 003
每次只唤醒一个客户端,优雅且高效,天然实现公平锁。
关键维度对比
| 维度 | ZooKeeper | Redis |
|---|---|---|
| 一致性模型 | CP(强一致) | AP(最终一致) |
| 锁释放机制 | 临时节点,会话断开自动释放 | 依赖 TTL 过期,需手动删除 |
| 死锁风险 | 极低 | 存在(TTL 设置不当) |
| 脑裂问题 | ZAB 协议保证,不易发生 | 主从切换时可能出现 |
| 性能 | 较低(写需 ZAB 广播) | 高(单机内存操作) |
| 公平性 | 天然公平锁(顺序节点) | 默认非公平 |
二、ZooKeeper 集群与 ZAB 协议
集群架构
ZooKeeper 集群称为 Ensemble ,通常部署奇数个节点(3、5、7),需要过半数节点存活才能正常工作。
Client Client Client
| | |
└─────────────┼─────────────┘
|
┌───────────┼───────────┐
│ │ │
Leader Follower Follower
(主节点) (从节点) (从节点)
| 角色 | 职责 |
|---|---|
| Leader | 唯一处理写请求,发起投票广播 |
| Follower | 处理读请求,参与写投票,可被选为 Leader |
| Observer | 只处理读,不参与投票(用于扩展读性能) |
ZAB 协议
ZooKeeper 使用 ZAB(ZooKeeper Atomic Broadcast) 保证集群一致性:
客户端写请求
↓
Leader 收到,生成事务 Proposal
↓
广播给所有 Follower
↓
超过半数 Follower ACK
↓
Leader Commit,通知所有节点应用
↓
返回客户端成功
正因如此,ZooKeeper 集群不会出现 Redis 主从切换丢锁的问题,任何节点看到的数据都是一致的。
三、Spring Boot 集成 ZooKeeper 分布式锁
方案一:Spring Integration(官方支持)
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-integration</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.integration</groupId>
<artifactId>spring-integration-zookeeper</artifactId>
</dependency>
java
@Configuration
public class ZookeeperLockConfig {
@Bean
public CuratorFramework curatorFramework() {
CuratorFramework client = CuratorFrameworkFactory.newClient(
"localhost:2181", new ExponentialBackoffRetry(1000, 3)
);
client.start();
return client;
}
@Bean
public ZookeeperLockRegistry zookeeperLockRegistry(CuratorFramework client) {
return new ZookeeperLockRegistry(client, "/locks");
}
}
@Service
public class OrderService {
@Autowired
private ZookeeperLockRegistry lockRegistry;
public void createOrder(String orderId) {
Lock lock = lockRegistry.obtain(orderId);
try {
lock.lock();
// 业务逻辑
} finally {
lock.unlock();
}
}
}
方案二:Curator(更主流 ⭐)
xml
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>5.5.0</version>
</dependency>
Curator 是 Netflix 开源的 ZooKeeper 客户端,提供多种锁类型:
java
// 可重入互斥锁(最常用)
InterProcessMutex lock = new InterProcessMutex(client, "/locks/order");
// 读写锁
InterProcessReadWriteLock rwLock = new InterProcessReadWriteLock(client, "/locks/rw");
// 联锁(同时锁多个资源)
InterProcessMultiLock multiLock = new InterProcessMultiLock(Arrays.asList(lock1, lock2));
完整使用示例:
java
@Service
public class OrderService {
@Autowired
private CuratorFramework client;
public void createOrder(String orderId) throws Exception {
InterProcessMutex lock = new InterProcessMutex(client, "/locks/" + orderId);
if (lock.acquire(10, TimeUnit.SECONDS)) {
try {
// 业务逻辑
} finally {
lock.release();
}
}
}
}
| Spring Integration | Curator | |
|---|---|---|
| 抽象层次 | 高,统一 Lock 接口 |
低,直接操作 ZK |
| 灵活性 | 一般 | 强,支持多种锁类型 |
| 生产常用度 | 一般 | ⭐ 更主流 |
四、红锁(RedLock)为何没有成为主流?
红锁的思路
RedLock 与 ZAB 表面上都采用"过半数"原则:
ZAB: 写入 → 广播 → 过半数 ACK → Commit
RedLock: 加锁 → 广播 → 过半数成功 → 认为加锁成功
但两者本质差异巨大:
| ZAB | RedLock | |
|---|---|---|
| 设计层次 | 系统级一致性协议 | 应用层投票技巧 |
| 节点通信 | 节点间持续心跳同步 | 节点完全独立,互不通信 |
| 一致性保证 | 有 epoch 机制,旧命令会被拒绝 | 无协调机制 |
致命问题一:时钟跳跃导致 TTL 提前过期
TTL 是什么
Redis 加锁本质是:
SET lock_key "clientA" NX PX 30000
PX 30000 表示这个 key 30秒后自动删除 ,即 TTL(Time To Live)。Redis 判断"30秒到了",靠的是服务器自己的系统时钟。
NTP 的工作机制
服务器时钟并不是完全准确的,需要通过 NTP(网络时间协议) 定期同步校准。NTP 有两种同步方式:
方式一:slew(渐进调整)------ 正常情况
时钟偏差较小时,NTP 会慢慢加速或减速时钟来追上正确时间,平滑无感知:
真实时间: 0s ──→ 1s ──→ 2s ──→ 3s
服务器: 0s ──→ 1.1s ──→ 2.05s──→ 3s (慢慢追上)
方式二:step(强制跳跃)------ 问题所在
时钟偏差超过阈值(默认 128ms) 时,NTP 认为渐进调整太慢,会直接把时钟拨到正确时间:
服务器时钟: 12:00:00 ──→ 【直接跳到】──→ 12:00:45
| 情况 | NTP 行为 | 是否跳跃 |
|---|---|---|
| 偏差 < 128ms | slew 渐进调整 | ❌ 不跳 |
| 偏差 > 128ms | step 强制拨正 | ✅ 跳跃 |
| 人工改时间 | 立即生效 | ✅ 跳跃 |
| VM 暂停恢复 | step | ✅ 跳跃 |
触发时钟跳跃的常见场景
- 虚拟机被暂停后恢复(如备份快照),恢复后时钟还停在暂停前
- 云服务器热迁移,Guest 时钟短暂冻结
- NTP 上游时间源波动
- 运维手动执行
date命令调整时间
真实时间线案例
12:00:00 Redis 节点3 收到锁请求,TTL = 30秒,预计 12:00:30 过期
12:00:10 VM 被宿主机暂停(触发备份快照)
12:00:50 VM 恢复,系统时钟还显示 12:00:10
12:00:51 NTP 发现偏差 40 秒,执行 step,时钟直接跳到 12:00:51
12:00:51 Redis 检查 TTL:当前时间 12:00:51 > 过期时间 12:00:30
→ 锁立即过期删除!
→ 实际上才过了 10 秒,客户端 A 还以为锁有效
导致的问题
1. 客户端 A 在节点1、2、3 加锁成功,TTL = 30秒
2. 节点3 发生时钟跳跃 → TTL 提前过期,锁被自动删除
3. 节点3 变为空闲,客户端 B 在节点3、4、5 加锁成功
4. 结果:
节点1: A的锁 ✅
节点2: A的锁 ✅
节点3: B的锁 ✅ (A 的锁已过期)
节点4: B的锁 ✅
节点5: B的锁 ✅
A 持有 2 个节点,不足过半,但 A 不知道节点3 已失效
B 持有 3 个节点,过半,B 认为自己拿到了锁
两个客户端同时进入临界区 💥
致命问题二:GC STW 导致锁失效
1. 客户端 A 获得红锁
2. A 发生 GC Stop-the-World,暂停 30 秒
3. 红锁 TTL 过期,客户端 B 获取锁
4. A 的 GC 结束,A 以为自己还持有锁
5. A 和 B 同时操作临界资源 💥
ZooKeeper 同样存在 GC 问题,但可配合 fencing token(每次加锁返回递增 token,写入资源时验证 token)来解决。
没成主流的现实原因
| 原因 | 说明 |
|---|---|
| 部署成本高 | 需要 5 个完全独立的 Redis 实例,不能是主从 |
| 性能差 | 要串行请求 5 个节点,延迟叠加 |
| 正确性存疑 | 理论上无法完全避免时钟和 GC 问题 |
| 替代方案成熟 | 强一致用 ZooKeeper/etcd,普通场景用单机 Redis |
红锁处于一个尴尬位置:比单机 Redis 锁复杂很多,却达不到 ZooKeeper 的可靠性,两头不讨好。
五、选型总结
追求性能、业务允许小概率锁失效 → Redis 单机锁(+ Watchdog 续期)
追求正确性、金融/强一致场景 → ZooKeeper / etcd
需要多 Redis 节点但又不想用 ZK → 慎用 RedLock,需充分评估风险
核心结论
- Redis 锁是"乐观的",靠 TTL 兜底,适合高并发但能容忍极小概率异常的场景
- ZooKeeper 锁是"悲观的",靠 CP 强一致保证正确,但性能代价更高
- RedLock 的本质缺陷在于:各 Redis 节点相互独立,无法在时钟漂移和进程暂停面前提供真正的安全保证
- 分布式系统中有一条重要原则:不要依赖时钟做正确性保证