本章定位:顺序节点是 ZooKeeper 实现分布式锁、队列和全局唯一 ID 生成的核心机制。理解其编号原理和使用方式是构建分布式协调应用的关键。
定义与作用
顺序节点(Sequential ZNode) 是在创建时由 ZooKeeper 自动在路径末尾追加 10 位单调递增数字的 ZNode。格式为 path前缀 + 0000000001。
create -s /tasks/task- ""
# 实际创建: /tasks/task-0000000001
核心特征:
- 计数器由父节点维护,每个父节点独立计数
- 严格单调递增,不重号(单个父节点范围内)
- 10 位数字零填充,支持字典序排序
- 可与 Persistent 或 Ephemeral 组合
核心原理
序号生成机制
序号由 Leader 原子分配,保证全局唯一和严格递增。即使多个客户端同时创建,也能得到不重复的序号。
计数器特性
| 属性 | 说明 |
|---|---|
| 存储位置 | 父节点的 Stat 中,由 cversion 字段关联 |
| 数值范围 | 32 位有符号整数(-2147483648 ~ 2147483647) |
| 溢出行为 | 达到最大值后翻转为负数 |
| 格式 | %010d(10 位零填充) |
| 起始值 | 从 0 开始,即 0000000000 |
完整示例
示例一:分布式任务队列
场景说明:多个 Worker 从任务队列中按顺序取任务执行。
创建任务(Producer):
zkCli.sh -server 127.0.0.1:2181
[zkshell: 0] create /tasks "Task Queue Root"
Created /tasks
[zkshell: 1] create -s /tasks/job- "process_order_001"
Created /tasks/job-0000000000
[zkshell: 2] create -s /tasks/job- "process_order_002"
Created /tasks/job-0000000001
[zkshell: 3] create -s /tasks/job- "process_order_003"
Created /tasks/job-0000000002
[zkshell: 4] ls /tasks
[job-0000000000, job-0000000001, job-0000000002, zookeeper]
消费任务(Worker):
// Worker 获取序号最小的任务
List<String> tasks = zk.getChildren("/tasks", false);
Collections.sort(tasks);
String firstTask = tasks.get(0);
byte[] data = zk.getData("/tasks/" + firstTask, false, null);
System.out.println("Processing: " + new String(data));
// 处理完成后删除
zk.delete("/tasks/" + firstTask, -1);
操作前后对比:
| 阶段 | /tasks 子节点(按序排列) | 说明 |
|---|---|---|
| 生产阶段 | job-0000000000 ~ job-0000000002 | 3 个待处理任务 |
| Worker 1 取走 job-0 | job-0000000001, job-0000000002 | 按序消费 |
| Worker 2 取走 job-1 | job-0000000002 | 继续按序消费 |
示例二:全局唯一 ID 生成器
场景说明:分布式系统中不同服务实例生成不重复的订单 ID。
# 创建 ID 根节点
create /order-ids "Order ID Root"
# 各服务实例生成 ID
# Service A
create -s /order-ids/ORD- ""
# → /order-ids/ORD-0000000000
# Service B
create -s /order-ids/ORD- ""
# → /order-ids/ORD-0000000001
# Service A 再次生成
create -s /order-ids/ORD- ""
# → /order-ids/ORD-0000000002
Java 实现:
public String generateOrderId() {
String path = zk.create("/order-ids/ORD-",
null,
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.PERSISTENT_SEQUENTIAL);
// path 为 "/order-ids/ORD-0000000000"
return path.substring("/order-ids/".length());
}
操作前后对比:
| 服务实例 | 生成的 ID | 是否会重复 |
|---|---|---|
| Service A 第 1 次 | ORD-0000000000 | 否 |
| Service B 第 1 次 | ORD-0000000001 | 否 |
| Service A 第 2 次 | ORD-0000000002 | 否 |
示例三:公平分布式锁队列
场景说明:通过 Ephemeral Sequential 实现排队,序号最小的获取锁。
public class FairLock {
private ZooKeeper zk;
private String lockPath = "/fair-lock";
private String myNode;
public void acquire() throws Exception {
// 创建临时顺序节点
myNode = zk.create(lockPath + "/req-", null,
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL_SEQUENTIAL);
List<String> children = zk.getChildren(lockPath, false);
Collections.sort(children);
String nodeName = myNode.substring(lockPath.length() + 1);
if (children.get(0).equals(nodeName)) {
// 序号最小,获取锁
return;
}
// 否则等待前一个节点释放
int idx = children.indexOf(nodeName);
String prevNode = lockPath + "/" + children.get(idx - 1);
CountDownLatch latch = new CountDownLatch(1);
zk.exists(prevNode, event -> latch.countDown());
latch.await();
}
}
执行时序:
| 时刻 | Client A | Client B | Client C | 锁持有者 |
|---|---|---|---|---|
| T1 | req-0001 | req-0002 | req-0003 | A |
| T2 | 释放 | — | — | B(Watch A 获通知) |
| T3 | — | 释放 | — | C(Watch B 获通知) |
易错场景与面试考点
易错场景
1. 将序号误解为全局计数器
顺序节点的序号是按父节点递增的,不同父节点的计数器独立:
create -s /tasks/t- "" # /tasks/t-0000000000
create -s /jobs/j- "" # /jobs/j-0000000000 —— 从 0 开始
两个父节点下的序号各自独立,没有全局统一计数器。
2. 字典序排序陷阱
10 位数字排序天然正确(因为是零填充),但非零填充的序号会有问题:
# 错误:非零填充
task-1, task-10, task-2 → 字典序 = [task-1, task-10, task-2]
# 正确:零填充
task-0000000001, task-0000000002, task-0000000010
→ 字典序 = [task-0000000001, task-0000000002, task-0000000010]
ZooKeeper 默认的零填充格式保证了字典序与数值序一致。
3. 大量顺序节点未及时清理
每个创建顺序节点的请求都需要 Leader 处理。如果只创建不删除,节点数量无限膨胀会导致:
- 获取子节点列表的时间增长
- 内存占用增加
- 计数器溢出风险
应在任务完成后及时删除已处理的顺序节点。
面试高频题
Q:ZooKeeper 的顺序节点如何保证在分布式环境下的唯一性?
A:所有创建操作都必须经过 Leader。Leader 作为单点串行化所有写请求,原子地分配序号并写入事务日志。即使多个 Follower 收到客户端请求,也会转发给 Leader 处理,因此序号不会重复。
Q:ephemeral_sequential 和 persistent_sequential 的使用场景有什么区别?
A:
- Ephemeral Sequential:适合临时性竞争场景(分布式锁),客户端断开后自动释放
- Persistent Sequential:适合持久性场景(全局 ID 生成器、任务队列),需要保留历史记录
Q:计数器溢出会怎样?
A:计数器是 32 位有符号 int,溢出后变为负数,节点名会变成 prefix--2147483648。由于 ZooKeeper 的使用场景极难达到 20 亿次创建(即使是高频场景也需要持续运行数十年),实际发生概率极低。
小结
| 要点 | 说明 |
|---|---|
| 序号格式 | 10 位零填充(%010d) |
| 计数器范围 | 父节点独立维护,32 位有符号 int |
| 原子性保证 | Leader 串行化创建请求 |
| 组合使用 | 与 Ephemeral 组合实现公平分布式锁 |
| 字典序友好 | 零填充保证字符串排序 = 数值排序 |
顺序节点是分布式锁的基石。下一节将全面对比四种节点类型及其最佳实践。