我面试分布式方向的时候,常拿这个问题开场:Zookeeper有哪几种节点类型?十个候选人里九个能脱口而出——持久节点、临时节点、持久顺序节点、临时顺序节点。但等我追问一句:“一个客户端进程被你直接 kill -9,它在 ZooKeeper 上创建的临时节点,到底多久会被删?”原本流利的回答就开始卡壳了。
概念背得再熟,落到真实场景里,节点类型的生命周期边界、会话机制、删除触发源才是真正决定系统行为的东西。这篇不是为了帮你再背一遍四种节点,我想把这些节点丢进实际的注册中心、分布式锁、主备选举、集群配置场景里,说清楚每种节点的设计动机、底层原理和选型依据,顺便把我踩过的坑都摊开讲。
文章比较适合两类人:一类是刚学完 ZooKeeper 基础、能把四种节点名称说出来但不知道怎么选型的开发者;另一类是线上遇到过“服务假死”“锁不释放”“会话过期误删数据”这类问题,想搞清楚背后机制的运维和开发。
1. 面试能答出四种节点,不等于理解了节点的“行为边界”
1.1 四种节点不是四个名词,是四种生命周期策略
先把基础对齐。在 ZooKeeper 里,数据的基本单元叫 ZNode,节点按目录树组织,比如 /config/db、/services/order/providers。创建节点的时候通过 CreateMode 指定类型,代码里对应四个枚举:
PERSISTENT:持久节点EPHEMERAL:临时节点PERSISTENT_SEQUENTIAL:持久顺序节点EPHEMERAL_SEQUENTIAL:临时顺序节点
命令行创建对应加参数:
bash复制# 持久节点
create /config/db "jdbc:mysql://localhost:3306/app"
# 临时节点
create -e /services/order/192.168.1.10:8080 ""
# 持久顺序节点
create -s /seq/order-
# 临时顺序节点
create -e -s /locks/lock-
很多人就是背到这里为止。但用表格把四种节点的“行为差异”拉出来看,会发现真正关键的是后面几列:
| 节点类型 | 数据是否持久 | 是否自动编号 | 生命周期由谁决定 | 典型用途 |
|---|---|---|---|---|
| 持久节点 | 是 | 否 | 只能显式 delete | 配置、路由规则、集群元数据 |
| 临时节点 | 否 | 否 | 跟随创建它的 session | 服务注册、选主、分布式锁占位 |
| 持久顺序节点 | 是 | 是 | 只能显式 delete | 全局任务编号、事件序号 |
| 临时顺序节点 | 否 | 是 | 跟随 session,且编号单调递增 | 分布式锁、公平队列、Leader 选举 |
注意看临时节点的生命周期描述:不是“客户端挂了就删”,而是“创建它的会话结束后由服务端清理”。这俩差别很大,后面我会单独展开。
1.2 客户端崩溃后,临时节点何时被删除
ZooKeeper 的连接模型不是 TCP 连接和节点一一绑定,而是引入了一个叫 Session(会话) 的中间层。客户端启动时通过 new ZooKeeper(connectString, sessionTimeout, watcher) 发起连接,服务端会分配一个全局唯一的 sessionId,这个 session 才是临时节点的“宿主”。
客户端进程被 kill -9 之后,TCP 连接断了,但服务端不会立刻删除该 session 下的临时节点。服务端要等 session 超过 sessionTimeout 还没收到任何心跳或其他请求,才会判定会话过期,触发临时节点清理。
所以真实的时间线是这样的:
- 客户端进程崩溃,连接断开。
- ZooKeeper 服务端在 sessionTimeout 内没有收到该 session 的任何消息。
- 服务端标记 session 过期,遍历该 session 下的所有临时节点,逐个删除。
- 如果客户端设置了
exists /services/order/xxx这样的 Watch,相关客户端会收到NodeDeleted事件通知。
也就是说,从进程真正死亡到节点被删除,中间至少隔着一个 sessionTimeout。默认情况下,Java 客户端如果自己不传,超时时间大约是 15 秒左右,Curator 框架默认也接近这个值。这意味着:如果你用临时节点做的服务注册,consumer 可能要等十几秒才会感知到 provider 已经挂了。
这也是为什么线上经常出现“服务明明已经死了,注册中心还显示在线”的现象。不是 ZooKeeper 设计有问题,而是生产者把 sessionTimeout 调大了,牺牲故障感知速度来换取稳定性,结果故障转移的时间窗口被拉长。
还有一个很多人不知道的细节:临时节点不能创建子节点。想往临时节点下面挂数据?ZooKeeper 直接拒绝,报 Ephemerals cannot have children。原因后面说。
1.3 用一条命令快速判断节点到底是不是临时的
判断一个已存在的节点类型,最高效的方法是看 stat 信息里的 ephemeralOwner 字段:
bash复制[zk: localhost:2181(CONNECTED) 5] get -s /services/order/192.168.1.10:8080
192.168.1.10:8080
cZxid = 0x2000000a2
ctime = Mon May 12 14:22:09 CST 2025
mZxid = 0x2000000a2
mtime = Mon May 12 14:22:09 CST 2025
pZxid = 0x2000000a2
cversion = 0
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x3000a1b2c3d4e5f6
dataLength = 17
numChildren = 0
ephemeralOwner 的值在底层就是创建该临时节点的 sessionId。如果它是 0x0,代表这是持久节点;如果是非 0 的长整型,代表这是临时节点,而且你可以从这串数字反查 session。
排查线上问题的时候,这一招非常实用。有的人代码里逻辑很复杂,到底创建的是临时还是持久,写的时候不觉得,出问题了一脸懵,一条 get -s 能省很多事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 底层机制:四种节点在 DataTree 里是怎么被“区别对待”的
2.1 ZNode 不只是树的节点,节点上还挂着版本号和状态
ZooKeeper 服务端启动后,整棵数据树保存在内存里,对应的核心类是 DataTree。DataTree 内部维护了一个 ConcurrentHashMap<String, DataNode>,key 是节点的完整路径,比如 /services/order/192.168.1.10:8080,value 是节点对象。
每个 DataNode 除了保存业务数据 byte[] data,还维持了四个核心状态字段:
dataVersion:节点数据被更新的次数cversion:子节点列表变化的次数aclVersion:节点 ACL 权限变化的次数ephemeralOwner:如果是临时节点,记录 sessionId;持久节点为 0
这些版本号不是摆设,它们支撑起了 ZooKeeper 的乐观锁能力。比如你 setData 的时候可以带上 version = 3,如果服务端发现当前版本不是 3,会直接抛 BadVersionException,防止多个客户端互相覆盖数据。
理解了这点,再看“节点类型决定行为”就有意思了:持久和临时的根本差别,其实只是 ephemeralOwner 这个字段填没填 sessionId。
2.2 “临时”这个概念,藏在一张 ephemerals 映射表里
Java 客户端创建临时节点时,服务端处理请求后并不会像业务数据库那样给这张表加一个“类型”字段。它做的事有两件:
- 把节点写入
DataTree.nodes,正常挂在目录树里。 - 把
(sessionId, path)的对应关系,记录到DataTree.ephemerals这张Map<Long, HashSet<String>>表里。
ephemerals 的 key 是 sessionId,value 是该 session 创建过的所有临时节点路径集合。
服务端在 session 过期时,做的事情非常简单粗暴:拿这个 sessionId 去 ephemerals 里查出所有路径,然后从 nodes 里逐个删除。同时给这些路径上的 Watch 发送 NodeDeleted 通知。
因为临时节点关联的是 session,而不是具体的 TCP 连接,所以 ZooKeeper 支持一个比较有迷惑性的行为:客户端断线重连后,如果 session 还没过期,临时节点依然存活。ZooKeeper 客户端在做会话恢复时,会带着旧的 sessionId 去重连,服务端会判断这个 session 是否还在超时时间内,是的话就允许继续使用,临时节点不会被删。
这也是为什么很多时候代码里出现短暂网络抖动,服务注册节点没掉,一切看起来正常。但一旦抖动时间超过 sessionTimeout,服务端就会“翻脸不认人”,把 session 和临时节点一起清掉。
2.3 顺序号不是全局自增,而是父节点范围内的单调计数器
再来看顺序节点。很多人以为 ZK 有一个全局的序号发号器,其实不是。
创建顺序节点的时候,路径末尾会补上一个十进制数字,固定 10 位,高位补零。例如:
bash复制create -s /seq/job-
Created /seq/job-0000000001
create -s /seq/job-
Created /seq/job-0000000002
这个序号由父节点维护。准确说,父节点 DataNode 的 cversion 字段在每次子节点列表变化时都会递增,创建顺序节点时,ZK 会读取父节点当前的 cversion,把它格式化成 10 位数字拼到路径末尾,然后再把父节点的 cversion 加一。
也就是说,序号是父节点级别的。不同的父节点下,顺序号各算各的:
bash复制create -s /seqA/item-
Created /seqA/item-0000000001
create -s /seqB/item-
Created /seqB/item-0000000001
两个节点互不影响。而且这个序号是单调递增的,就算你把子节点删了,再创建新节点,序号也只会继续变大,不会复用之前空出来的位置。
这部分对分布式锁的实现很关键。因为锁场景要求“后来的请求序号一定比先来的大”,顺序号必须单调,即使之前有请求失败退出、把节点删掉了,后面新来的序号依然更大,公平性才有保证。
细心的读者会发现,创建顺序节点时父节点的 cversion 也会变。有人因此在业务里对父节点设置了 Watch,结果子节点一变化,父节点的 Watch 也会触发,排查的时候容易被绕进去。
3. 选型实战:注册中心、配置中心和主备选举分别该用哪种节点
3.1 服务注册为什么必须用临时节点
很多人在面试时能说出“Dubbo 用 ZooKeeper 做注册中心”,但问到为什么用临时节点而不是持久节点,就开始含糊了。
回到本质:服务注册中心需要解决的第一个问题是动态感知服务上下线。一个 Java 服务启动时,把自己注册上去;进程崩溃、被 kill、机房断电时,这个注册信息要能自动消失,这样调用方才知道“这个 provider 不在了”。
如果用持久节点做注册,进程崩溃后没有人执行 delete,这个节点就永远留在注册中心里。consumer 按照注册列表去调用,会一直触达失败节点。当然可以做健康检查、心跳续约,但那需要额外的机制,复杂度远高于让 ZooKeeper 的 session 机制直接替你兜底。
于是方案自然落到了临时节点。服务提供者启动时创建临时节点:
bash复制create -e /dubbo/com.example.OrderService/providers/192.168.1.10:20880
consumer 在 /dubbo/com.example.OrderService/providers 目录上注册 getChildren 的 Watch,一旦子节点列表发生变化——有新的 provider 上线、旧的 provider 崩溃后节点被服务端清掉——consumer 会立刻收到 NodeChildrenChanged 事件,然后重新拉取列表。
这个模型里,临时节点的“自动删除”不是 bug,而是它最核心的价值:故障由机器兜底,不需要人为干预。你唯一需要做的是合理设置 sessionTimeout,平衡故障感知延迟和误删风险。
3.2 元数据和集群配置反而是持久节点的主场
但并不是所有注册信息都适合临时节点。我见过不少新人把一切往临时节点里塞,结果上线后发现一抖动,相关临时节点就“消失”了,业务数据跟着丢了。
以 Dubbo 注册中心为例,目录结构里:
/dubbo/interface/providers/...下的服务实例节点,是临时节点。/dubbo/interface/configurators/...下的路由规则、权重配置、黑白名单,是持久节点。/dubbo/interface/routers/...下的路由规则,也是持久节点。
这就很有代表性了。服务实例是“进程级别”的存在,进程死掉,实例就该消失;而配置和规则是“集群级别”的存在,即使当前没有任何 provider,这条规则也应该稳稳地保存在 ZooKeeper 里,等下次有新节点启动时还能读取到。
我自己做运维配置中心时,见过一个经典失误:把数据库连接池的动态配置存放在临时节点上。客户端连接断开,节点被删,配置丢失,重新连上来时读不到配置,服务一直用本地兜底值,排查了大半天才发现是把节点类型选错了。
临时节点适合表达“跟随某个客户端会话存活”的临时状态;持久节点适合表达“集群层面需要长期保留的静态事实”。选错类型的代价,不是 API 调不通,而是整个系统的状态语义变错。
3.3 从 Hadoop 主备环境看临时节点如何做选主
Hadoop HDFS 的高可用架构里,NameNode 主备切换是 ZooKeeper 节点的典型应用。两个 NameNode,一主一备,正常情况下只有一个 Active 对外提供服务,另一个 Standby 时刻准备顶上来。
实现这个机制的核心组件是 ZKFailoverController。每个 NameNode 节点上都会启动一个 ZKFC,ZKFC 做的事情本质上就是:两个候选者一起去 ZooKeeper 的某个固定路径下创建临时节点争抢主节点。
bash复制# 两个 NameNode 的 ZKFC 同时在 /hadoop-ha/mycluster/ActiveStandbyElectorLock 下创建临时节点
create -e /hadoop-ha/mycluster/ActiveStandbyElectorLock ""
ZooKeeper 保证了同一路径只能有一个节点创建成功。创建成功的那个成为 Active NameNode,创建失败的那个成为 Standby,同时对这个路径注册一个 exists Watch。
当 Active NameNode 宕机,它的 session 断开,等到服务端判定 session 超时后,临时节点被自动删除,Standby 那边的 ZKFC 收到 NodeDeleted 事件,立刻重新尝试创建临时节点,创建成功就完成主备切换。
这个案例里,临时节点聪明的地方在于:它把“持有锁”和“持有者存活”绑定在了一起。不需要 Active 节点在死前主动发一条消息说“我要退了”,机器断电、进程被 kill -9,ZooKeeper 的会话超时机制会自动把锁释放,备用节点就能接管。
我自己也模仿这套逻辑做过一套轻量选主:多个工作节点竞争同一个路径的临时节点,胜者可调度全部任务,败者只做 Watch 监听。这个组件跑了两年多,唯一一次出现问题就是 sessionTimeout 调的过大,主节点宕机后过了近一分钟备节点才顶上,所以针对选主场景,建议在容错允许范围内适当调小 sessionTimeout。
持久节点在这种场景下做不了主备切换,因为你没法靠“持有者死没死”来判断要不要踢掉旧节点,还得自己写心跳和故障检测。
3.4 消费端的 Watch 使用顺序:先监听,再查询,别搞反
跟节点类型紧密相关的一个实操点是 Watch 的处理顺序。你去看很多问题的根因,不是节点类型选错,而是设置 Watch 和读取数据的顺序颠倒了。
ZooKeeper 的 Watch 是一次性的:触发之后如果想要继续监听,必须在回调里重新注册。如果业务代码逻辑是:
- 先
getChildren拿到最新列表 - 再注册 Watch
那在这两步之间,如果恰好有节点状态发生了变化,这个事件就永远错过了。
正确做法是:
- 先注册 Watch
- 再读取数据
ZooKeeper 保证:一旦注册了 Watch,从那个时刻起到你读取数据之间发生的所有变化,都会先以事件形式推给客户端,你绝对不会漏掉。
以服务消费者为例:
java复制// 错误示范:先读后 watch,中间的变化会丢
List<String> children = zk.getChildren(path, false);
zk.getChildren(path, true);
// 正确示范:先注册 watcher,再拿数据
zk.getChildren(path, event -> {
if (event.getType() == Watcher.Event.EventType.NodeChildrenChanged) {
// 再次先注册,再拉数据
refreshProviders();
}
});
refreshProviders();
这个小细节如果没处理好,ZooKeeper 数据模型再清楚也会线上翻车。
4. 顺序节点的高阶玩法:从分布式锁到公平队列的完整演化
4.1 一个临时节点做分布式锁,会踩到“惊群效应”
分布式锁是最容易把“只懂概念”和“真的懂”区分出来的场景。
先看最容易想到的版本:所有客户端竞争同一个锁路径,谁能成功创建临时节点,谁就拿到锁。拿不到锁的客户端对这个路径注册一个 exists Watch,节点被删除后收到事件,再重新尝试创建。
java复制// 核心逻辑:谁能创建成功,谁就持有锁
String lockPath = "/locks/goods-stock-lock";
try {
zk.create(lockPath, new byte[0],
ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
// 拿到锁,执行任务
} catch (KeeperException.NodeExistsException e) {
// 没拿到锁,监听锁释放
zk.exists(lockPath, true);
}
这个方案思路对,但没有办法应对大量并发请求。当锁释放的那一刻,所有在 etcd / ZooKeeper 上监听的客户端都会同时被唤醒,数千个请求一起涌入 ZooKeeper 抢着创建节点。但实际上只有一个人能成功,其余 999 个人只能再次失望地注册 Watch,等待下一次通知。
这就是惊群效应。请求量小还好,请求量一大,一个锁的释放可能瞬间打垮 ZooKeeper 集群,或者导致大量不必要的网络开销。
4.2 临时顺序节点锁:把“一拥而上”改成“排队叫号”
临时顺序节点解决这个问题的思路很像银行排队取号。
所有客户端不在同一个固定路径上抢了,而是往同一个父节点下创建临时顺序节点,比如:
bash复制create -e -s /locks/goods-stock-lock/lock-
Created /locks/goods-stock-lock/lock-0000000001
create -e -s /locks/goods-stock-lock/lock-
Created /locks/goods-stock-lock/lock-0000000002
create -e -s /locks/goods-stock-lock/lock-
Created /locks/goods-stock-lock/lock-0000000003
然后每个客户端去 getChildren 拉取 /locks/goods-stock-lock 下的所有子节点,按序号排序。如果自己是最小的那个序号,代表自己排到了第一位,成功获得锁;如果不是最小的,就找到比自己的序号小的那个相邻节点,只监听它一个。
这意味着锁释放时,只有它的下一个序号的客户端会被唤醒,其他客户端继续安静等待,完全没有惊群效应。
简化后的核心代码大概长这样:
java复制public class ZkSequentialLock {
private final ZooKeeper zk;
private final String lockRoot = "/locks/goods-stock-lock";
private String currentNodePath;
public void lock() throws Exception {
// 1. 创建临时顺序节点
currentNodePath = zk.create(lockRoot + "/lock-",
new byte[0],
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL_SEQUENTIAL);
// 2. 判断自己是不是最小的
while (true) {
List<String> children = zk.getChildren(lockRoot, false);
Collections.sort(children);
String currentNode = currentNodePath.substring(lockRoot.length() + 1);
int index = children.indexOf(currentNode);
if (index == 0) {
// 最小,拿到锁
return;
}
// 3. 监听前一个节点
String prevNodePath = lockRoot + "/" + children.get(index - 1);
CountDownLatch deletedLatch = new CountDownLatch(1);
Stat stat = zk.exists(prevNodePath, event -> {
if (event.getType() == Watcher.Event.EventType.NodeDeleted) {
deletedLatch.countDown();
}
});
// 如果前一个节点恰好已经被删了,重新选举
if (stat == null) {
continue;
}
// 4. 前一个节点释放后,再次自旋检测
deletedLatch.await();
}
}
public void unlock() throws Exception {
zk.delete(currentNodePath, -1);
}
}
这里有个依赖点:ZooKeeper 的顺序号是 10 位定长补零的,所以 Collections.sort 直接按字符串排序就能得到正确的序号顺序。如果某一天顺序号不是这种定长格式,字符串排序就会出问题,但 ZooKeeper 的内部实现保证了这一点,你可以放心。
加锁成功后的执行过程中如果进程崩溃,临时节点会随 session 过期被删除,锁自动释放,后面的客户端会被 Watch 唤醒继续竞争,不会产生死锁。
4.3 锁方案的隐藏缺陷:GC 暂停可能导致锁被误释放
用临时节点做分布式锁已经比单节点方案可靠多了,但它在极端场景下有一个非常经典的坑,这也是为什么我说“会概念”不等于“会用”。
假设有两个客户端 A 和 B 竞争同一把锁。A 成功创建了临时节点,拿到了锁,开始执行业务代码。如果 A 的 JVM 在这期间发生了一次长时间 Full GC,暂停时间超过了 sessionTimeout,A 和 ZooKeeper 之间的心跳全部断掉,ZooKeeper 服务端就会判定 A 的 session 过期,删除临时节点。
此时 A 的 JVM 还在,只是 GC 暂停结束后的线程并不知道自己已经失去了锁。A 继续执行后续代码——可能是扣库存、写数据库、调用第三方接口——但与此同时 B 已经通过 Watch 感知锁被释放,成功创建了自己的临时节点,也开始执行同样的业务。
两个客户端同时认为自己持有锁,分布式锁的互斥性被打破,这在金额操作、库存扣减等场景下会引发严重后果。
业界对这个问题没有万能解药,常见的缓解思路包括:
- 把 sessionTimeout 调大,让长 GC 不至于立刻导致 session 过期,但这会牺牲故障感知速度。
- 在锁节点内写入持有者标识,业务代码在关键资源操作前校验自己是否仍然持有锁,类似 fencing token 的思路。
- 把 ZooKeeper 锁用于互斥性要求不极端的场景,真正需要强一致的地方配合数据库锁或业务幂等。
我自己在做秒杀库存这类强一致场景时,更倾向于用数据库乐观锁或 Redis Redlock 配合业务幂等方案兜底,单纯依赖 ZooKeeper 临时节点的“自动释放”能力并不总是够用。
4.4 顺序节点不只是锁的基石:全局任务编号与公平队列
顺序节点还有个价值被很多人忽略了:它能提供一种父节点范围内单调递增的全局编号。
有段时间我用 ZooKeeper 给定时任务生成执行批次号。多个 worker 节点同时触发任务时,每个 worker 执行 create -s /tasks/batch-,返回值里末尾的数字就是唯一的、严格递增的批次号。下游系统看到批次号就能判断执行先后顺序,而且即使任务执行失败重跑,新批次号也不会和旧的冲突。
用命令演示更直接:
bash复制create -s /tasks/batch-
Created /tasks/batch-0000000041
create -s /tasks/batch-
Created /tasks/batch-0000000042
同一个父节点下的顺序号一定不同,且整体单调递增。你可以直接把后半段数字当任务 ID 或消息序号的生成器。
如果不强制要求全局唯一、只需要局部有序的场景,这种方案比去 Redis 里做 INCR 指令更简单直接,而且天然支持多客户端并发创建而不冲突。
把选主和公平队列结合起来还可以做任务调度:多个 worker 在 /workers/task- 下创建临时顺序节点,谁序号最小谁处理队列头部的任务。这套语义用 ZooKeeper 表达非常自然,很多老一代分布式任务调度框架就是这么干的。实际生产里引入 Curator 的话有现成的 InterProcessMutex 和 InterProcessSemaphoreMutex 可以直接用,但能看懂底层实现,排障时才能快速判断是不是框架用错了。
5. 线上踩坑合集:从 session 超时误判到顺序节点垃圾堆积
5.1 sessionTimeout 不是配置得越短越好
前面反复提到 sessionTimeout,这是分布式系统中一个需要反复调优的参数。踩过的坑实在太多,我单独拎出来说。
如果把服务注册的心跳比喻成手机打车时的位置上报,sessionTimeout 就是平台判定“司机已经消失”的时间阈值。阈值设得太短,司机只是路过隧道几秒没信号,平台就强制把订单取消,乘客体验极差;阈值设得太长,司机真的出了事故,平台几十分钟后才察觉,乘客在路边干等。
我见过一个业务,上线初期把 ZooKeeper 客户端的 sessionTimeout 设成了 4000 毫秒,初衷是想让故障感知快一些。结果服务发布时 JVM 一次正常的 Full GC 花了 2 秒,紧接着网络有波动,4 秒内没发出心跳,session 直接过期。注册在 ZooKeeper 上的服务节点全部被清掉,流量被路由到其他节点,集群整体抖动。
如果使用 Java 原生 ZooKeeper 客户端,sessionTimeout 的最小值会被服务端的 tickTime * 2 兜底。假设 tickTime 是 2000 毫秒,你就算配置 1000 毫秒,服务端也会强制按 4000 毫秒处理。
真实生产环境,我一般建议:
- 用 Curator 客户端,
sessionTimeoutMs不要低于 10 秒,常见取值 10 到 30 秒。 - 服务端会针对这个值做二次校验,超过服务端
maxSessionTimeout的部分会被按最大值处理。 - 故障转移延迟要求特别高的主备场景,比如 HDFS Active/Standby 切换,可以按业务恢复 RTO 反推 sessionTimeout 上限。
java复制RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
CuratorFramework client = CuratorFrameworkFactory.builder()
.connectString("node1:2181,node2:2181,node3:2181")
.sessionTimeoutMs(15000)
.connectionTimeoutMs(5000)
.retryPolicy(retryPolicy)
.build();
大方向是:宁可故障感知慢一点,也不要让正常波动引发大面积误删。
5.2 Watch 是一次性的,重连之后的 Watch 需要重新注册
ZooKeeper Watcher 的一大特点是 one-time trigger。事件触发一次后,监听关系就自动失效,再想监听必须重新注册。
这看起来简单,但真实系统里有个隐蔽的坑:客户端断线重连过程中,即使 session 没有过期,在断连期间错过的 Watch 事件也不会在重连后补发给你。ZooKeeper 只保证 Watch 注册之后的变更会通知,不保证断连期间的历史变更都完整推送给不在线客户端。
所以重连后最安全的动作是:把自己关心的路径全部重新注册一遍 Watch,然后重新读取数据,而不是依赖旧 Watch 继续生效。
Curator 的 CuratorCache 或 PathChildrenCache 在一定程度上封装了重连后的监听重建逻辑,但用原生客户端写业务代码时,这个坑必须处理到位。我自己写服务发现组件时,在重连回调里会主动刷新所有 provider 列表并重建 watcher,才能保证故障时 consumer 能及时感知到服务节点变化。
还有一个顺序问题前面提过一次,这里再强调:先注册监听,再读取数据,顺序不能反。如果你先读到旧数据再注册监听,中间恰好发生了什么变化,你拿到的还是旧数据,丢失的那个事件不会因为你后续注册了 Watch 而追回来。
5.3 临时节点之下不允许建子节点,这个限制为什么存在
有人把一份复杂的业务状态设计成:
text复制/tmp/session-node
/child-status 临时节点的子节点
结果代码一跑,ZooKeeper 直接抛 KeeperException。这个限制很多新手不理解——为什么持久节点可以有子节点,临时节点不行?
这个限制的核心原因是递归清理的成本和不确定性。临时节点的生命周期跟随 session。session 一旦过期,服务端需要把该 session 创建的所有节点清空。如果允许临时节点下挂子节点,且子节点也是临时节点,那回收时等于要遍历一棵可能很深的子树,逐个判断哪些子节点归属于同一个 session,很容易产生悬挂引用、清理不完整。
从数据结构设计角度,这是一个明确的安全边界:临时节点是“叶子语义”,不允许它成为树的中间节点。这样 session 过期时,服务端只需要查 ephemerals 表,把一个 sessionId 下的所有路径直接删掉,不会牵连树上的其他分支。
业务上如果确实需要给服务实例挂属性,要么把属性放到 Data 数据里,要么在另一个持久节点下按 instanceId 组织数据,不要把结构设计成临时节点下挂子节点。
5.4 顺序节点的编号会持续增长,垃圾数据必须处理
用顺序节点做分布式锁时,每次加锁都会在 /locks/... 下新增一个节点。正常情况下锁释放后会 delete,节点数不会膨胀。但一旦有持锁客户端在拿到锁之后发生了长 GC、网络分区、或者代码里 unlock 没有进 finally,临时顺序节点就会留在 ZooKeeper 的目录树上,直到
