Zookeeper 的 watch 机制是如何工作的?
结论先行:watch 是客户端在读取时顺带注册的一次性订阅。服务端只在节点发生指定类型变更时,推送一个“事件通知”(不含数据),客户端收到后回调本地监听器,且该 watch 立即失效,需要重新注册。它追求的是轻量与有序,不是可靠消息。
一、注册与触发的完整路径
① 客户端 getData(path, watcher) / exists / getChildren
│ 请求里带 -1 标记“要注册 watch”
▼
② 服务端把 (path, sessionId, 事件类型) 存入 WatchManager
│
▼
③ 其他客户端 setData / create / delete
│
▼
④ Leader 提交事务,各节点数据变更
▼
⑤ 服务端找到该 path 上的 watch,往对应会话的连接推 Notification
▼
⑥ 客户端 WatchManager 回调 process(WatchedEvent),并删除本地 watch
第 ⑤ 步只发事件类型(NodeCreated / NodeDeleted / NodeDataChanged / NodeChildrenChanged),不发数据,客户端需要自己再读一次。
二、四个容易记错的特性
| 特性 | 说明 | 影响 |
|---|---|---|
| 一次性 | 触发后自动失效 | 必须循环注册,否则丢事件 |
| 轻量 | 通知不带数据 | 收到通知要再发一次读请求 |
| 有序 | 同一客户端的事件按发送顺序到达 | 不会出现“先删后建”乱序 |
| 异步 | 回调在事件线程执行,不阻塞 IO | 回调里不能做耗时操作 |
三、事件类型与注册方式
| 注册动作 | 可监听的事件 |
|---|---|
exists | 节点创建、删除、数据变更 |
getData | 节点删除、数据变更 |
getChildren | 子节点新增、删除、自身删除 |
// 原生 API:一次性注册,触发后需重新注册
Watcher w = event -> {
if (event.getType() == Watcher.Event.EventType.NodeDataChanged) {
byte[] data = client.getData().forPath("/app/config"); // 必须再读
apply(data);
registerAgain(); // 必须再注册
}
};
client.getData().usingWatcher(w).forPath("/app/config");
Curator 的 NodeCache / PathChildrenCache / TreeCache 已经把“重新注册”封装好了:
NodeCache cache = new NodeCache(client, "/app/config");
cache.getListenable().addListener(() -> apply(cache.getCurrentData().getData()));
cache.start();
ZK 3.6 之后新增了持久递归 watch(addWatch 配合 PERSISTENT_RECURSIVE),一次注册可反复触发,但多数存量集群还在 3.5,别默认它可用。
四、两个生产级坑
坑一:会话过期 → 服务端清空该会话所有 watch → 客户端必须重建全部订阅
坑二:大量客户端 watch 同一节点 → 一次变更触发 N 条通知 → 惊群
应对:会话过期后走 Curator 的重连重建流程;热点节点改为“客户端只 watch 自己的子节点”,或改用支持推送的配置中心。
常见追问
- 追问:watch 会丢事件吗?窗口期内(通知发出到客户端重注册之间)的变更会丢,所以不能把 watch 当可靠消息,关键数据要配合定时轮询兜底。
- 追问:为什么只通知不带数据?减少网络与内存开销,服务端不需要为每个 watch 缓存数据快照。
- 追问:能监听子节点的子节点吗?3.6 前不能,watch 不递归;需要递归就用 TreeCache 自己逐层注册。
小结
- watch 是一次性、轻量、有序、异步的订阅,触发即失效。
- 通知只带事件类型,收到后要重读数据并重新注册。
- 会话过期会清空全部 watch,重连后要整体重建。
- 热点节点上的 watch 会引发惊群,必要时降级为轮询。