176 lines
7.9 KiB
Markdown
176 lines
7.9 KiB
Markdown
---
|
||
tags: [arch/distributed, zookeeper-distributed-lock, zab-protocol, ephemeral-node, watch-mechanism, cp-system]
|
||
create time: 2026-08-08 18:00
|
||
update time: 2026-08-08 18:00
|
||
---
|
||
|
||
# ZooKeeper 分布式锁
|
||
|
||
## 概述
|
||
|
||
ZooKeeper 基于 ZAB 协议的强一致性保证,天然适合构建分布式协调服务。本文深入分析 ZK 分布式锁的核心机制——临时顺序节点、Watch 一次性触发、公平锁实现原理,以及与 Redis 锁的系统级对比。
|
||
|
||
## 核心原理
|
||
|
||
### 临时顺序节点创建过程
|
||
|
||
ZK 锁的核心数据结构是 **临时顺序节点(Ephemeral Sequential Node)**:
|
||
|
||
```mermaid
|
||
graph TD
|
||
A[客户端连接 ZK] --> B[在 /locks 下创建 EPHEMERAL_SEQUENTIAL 节点]
|
||
B --> C["节点名: /locks/lock-0000000001"]
|
||
C --> D[获取 /locks 下的所有子节点并排序]
|
||
D --> E{自己是最小的节点?}
|
||
E -- 是 --> F["加锁成功<br/>lock-0000000001 获得锁"]
|
||
E -- 否 --> G["监听比自己小的那个节点<br/>watch(lock-0000000000)"]
|
||
G --> H["等待 Watch 通知"]
|
||
I[持锁客户端断开连接] --> J[ZK 检测到 session 过期]
|
||
J --> K["自动删除自己的 EPHEMERAL 节点"]
|
||
K --> L["被监听者收到 NODE_DELETED 事件"]
|
||
L --> D
|
||
```
|
||
|
||
**关键语义:**
|
||
- **EPHEMERAL(临时性)**:客户端 session 断开(心跳超时),ZK 自动清理节点。不需要手动 unlock,避免死锁。
|
||
- **SEQUENTIAL(顺序性)**:每个创建的节点附带单调递增的序号,保证了全局有序性和公平性。
|
||
|
||
### Watch 机制(一次性触发 + NodeChildrenChanged)
|
||
|
||
Watch 是 ZK 分布式锁能够工作的核心机制:
|
||
|
||
| 特性 | 说明 |
|
||
|------|------|
|
||
| 一次性(One-shot) | 触发后自动注销,必须重新注册。防止重复消费。 |
|
||
| WATCHED 状态 | 客户端调用 `exists` / `getChildren` 时设置 watch=true,服务端记录此订阅关系。 |
|
||
| EventType | 锁场景使用 `NodeChildrenChanged`——子节点列表变化时通知所有相关监听者。 |
|
||
| 网络传输 | ZK 用独立的 watcher event 通道推送,与数据读取通道分离,保证低延迟。 |
|
||
|
||
```java
|
||
// 伪代码:ZK 锁的 watch 逻辑
|
||
Stat stat = zk.exists("/locks/" + prevNode, event -> {
|
||
if (event.getType() == EventType.NodeDeleted) {
|
||
// 上一个锁释放了,重新竞争
|
||
tryAcquire();
|
||
}
|
||
});
|
||
if (stat == null) {
|
||
// 没有更小的节点,直接获得锁
|
||
holdLock();
|
||
}
|
||
```
|
||
|
||
> [!NOTE]
|
||
> Watch 的一次性特性意味着客户端必须在接收到事件后立刻重新注册。如果客户端处理事件耗时过长而错过了 re-register 窗口,可能永远不再收到下一次通知。Redisson 的 ZK 模块会做这个重试。
|
||
|
||
### 公平锁的实现
|
||
|
||
因为节点是 SEQUENTIAL 的,所以按序号大小决定锁的持有权,天然实现了公平性:
|
||
|
||
```
|
||
/zk-lock/lock-0000000001 ← Alice (最小,获得锁)
|
||
/zk-lock/lock-0000000002 ← Bob (监听 lock-0000000001)
|
||
/zk-lock/lock-0000000003 ← Carol (监听 lock-0000000002)
|
||
/zk-lock/lock-0000000004 ← Dave (监听 lock-0000000003)
|
||
```
|
||
|
||
Alice 释放后,Bob 收到 NODE_DELETED → Bob 成为新的最小节点 → 获得锁。整个过程严格遵循 FIFO 顺序。
|
||
|
||
> [!TIP]
|
||
> 面试考点:ZK 的公平性是"最终公平"而非"严格公平"。因为存在网络延迟和序列化差异,实际获得的顺序可能与创建节点的顺序不完全一致(但概率极低)。
|
||
|
||
### CP 性质保证 — ZAB 协议
|
||
|
||
Zookeeper 通过 **ZAB(ZooKeeper Atomic Broadcast)协议** 保证 CP(一致性 + 分区容错性):
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> LOOKING: 节点启动
|
||
LOOKING --> LEADING: Leader Election 完成
|
||
LOOKING --> OBSERVING: Observer 模式
|
||
LEADING --> FOLLOWING: 平滑切换
|
||
FOLLOWING --> LEADING: Leader 故障后选举
|
||
LEADING --> OBSERVING: 降级为 Observer
|
||
FOLLOWING --> OBSERVING: Observer 不选举也不提案
|
||
|
||
state LEADING {
|
||
[*] --> Proposal: 接收客户端写请求
|
||
Proposal --> Broadcast: 广播 proposal
|
||
Broadcast --> AckCollect: 收集过半 ACK
|
||
AckCollect --> Commit: 提交到事务日志
|
||
}
|
||
```
|
||
|
||
**ZAB 的两个核心阶段:**
|
||
1. **Leader Election**:通过 ZXID(事务 ID)比较选出拥有最大 ZXID 的节点作为 Leader。选举过程中所有写操作排队等待。
|
||
2. **Atomic Broadcast**:Leader 将写操作封装为 Proposal,follower 回复 ACK,达到半数后提交并广播给所有节点。
|
||
|
||
**脑裂问题(Split Brain)讨论:**
|
||
|
||
当网络分区导致集群分裂为两部分时:
|
||
|
||
| 场景 | 行为 | 安全性 |
|
||
|------|-----|--------|
|
||
| 多数派分区(≥ N/2+1) | 继续正常工作,选新 Leader | ✅ 安全 |
|
||
| 少数派分区(< N/2+1) | 失去 Quorum,拒绝写操作 | ✅ 安全(CP 优先) |
|
||
| N=3, 两个分区各 1 个 | 都无法达成 Quorum,全部停止 | ✅ 安全,但可用性为零 |
|
||
|
||
N 必须是奇数(3/5/7),这确保任何分区都只有一种结果:要么有 Quorum,要么没有。不会出现两个分区都认为自己有 Quorum 的情况。
|
||
|
||
### Redis 锁 vs ZK 锁全面对比
|
||
|
||
| 维度 | Redis 锁 | ZooKeeper 锁 |
|
||
|------|---------|-------------|
|
||
| 一致性模型 | AP(单实例)/ 不确定(Redlock) | CP(ZAB 强一致) |
|
||
| 实现复杂度 | 简单(SET NX EX) | 中等(需理解临时节点和 Watch) |
|
||
| 性能 | 极高(内存操作,百万级 QPS) | 较低(需要写事务日志和同步复制) |
|
||
| 公平性 | 非公平(先到先得,无顺序保证) | 天然公平(SEQUENTIAL 序号) |
|
||
| 看门狗续期 | Redisson 内部定时任务 | 自动(session 保活即可) |
|
||
| 宕机安全性 | 依赖过期时间(可能被误删) | 自动清理(EPHEMERAL 节点) |
|
||
| 适用规模 | 大规模分布式系统 | 中小规模协调服务 |
|
||
| 运维成本 | 低(Redis 集群成熟) | 高(ZK 集群需要维护 odd-numbered nodes) |
|
||
|
||
> [!WARNING]
|
||
> 不要为了"更高级"而去用 ZK 锁。如果你的业务只需要简单的互斥锁,Redis + SET NX 足够好。ZK 的优势在于它提供的不只是锁——还有顺序节点、Watch 通知、配置管理等一系列协调原语。
|
||
|
||
## 代码示例
|
||
|
||
Java 中使用 Curator 实现分布式锁(最流行的 ZK Java 客户端):
|
||
|
||
```java
|
||
// Curator 封装了复杂的 ZK API
|
||
InterProcessMutex lock = new InterProcessMutex(
|
||
curatorFramework, "/locks/resource-a");
|
||
|
||
try {
|
||
if (lock.acquire(10, TimeUnit.SECONDS)) {
|
||
doBusiness();
|
||
}
|
||
} finally {
|
||
lock.release(); // 递归释放所有重入计数
|
||
}
|
||
```
|
||
|
||
Curator 在底层做的事情:
|
||
1. 创建 `/locks/resource-a` 目录(如不存在)。
|
||
2. 在目录下创建 EPHEMERAL_SEQUENTIAL 子节点。
|
||
3. 获取子节点列表,判断自己是否是最小序号。
|
||
4. 如果是 → 加锁成功;否则 → 对前一个节点注册 Watch。
|
||
5. Watch 触发 → 回到第 3 步重新判断。
|
||
|
||
## 实践场景
|
||
|
||
**秋招高频问题:**
|
||
|
||
- "ZK 为什么不直接用 EXISTS 命令不断轮询?" — EXISTS 不带 watch 会返回当前状态但不订阅变更。如果用 while(!exists) sleep(10ms) 的方式,不仅浪费 CPU,还可能在两次 exists 之间错过节点删除事件,导致无限等待。
|
||
- "ZK 的会话超时怎么设?" — 默认 40 秒太长,建议设为 3~10 秒。短超时能更快感知客户端宕机并释放锁。但要平衡心跳间隔(heartbeat interval = timeout / 3),太短的 heartbeat 会增加不必要的网络开销。
|
||
- "ZK 脑裂为什么不会导致两把锁同时存在?" — ZAB 协议要求写操作必须得到多数派确认后才会提交。脑裂时少数派分区无法获得 Quorum,任何写操作都不会被确认,因此不可能出现两个 Leader 各自分配同一个资源的情况。
|
||
|
||
> [!TIP]
|
||
> 实战经验:ZK 锁最适合用于"需要强一致性的少量并发控制"场景——如数据库主从切换中的 Leader 选举、分布式调度器的 Job 唯一绑定。对于高频短锁(如计数器增减),Redis 明显更适合。
|
||
|
||
## 关联笔记
|
||
|
||
- [[Redis 分布式锁]]
|
||
- [[幂等设计方案]]
|