Files
autumn-recruitment/05.架构/distributed-lock/ZooKeeper 分布式锁.md
T

176 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 分布式锁]]
- [[幂等设计方案]]