Zookeeper 的 watch 机制是如何工作的?

#中间件#面试八股

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 会引发惊群,必要时降级为轮询。
笔记加载中…