本章定位:Watcher 是 ZooKeeper 实现分布式协调的"神经末梢"。它允许客户端监听 ZNode 的变化,是实现配置中心、服务发现和分布式锁的基础。
定义与作用
Watcher 是 ZooKeeper 提供的事件通知机制。客户端可以对 ZNode 注册 Watcher,当 ZNode 发生指定事件时,服务端向客户端推送通知。
Watcher 的核心设计特点:
| 特性 | 说明 |
|---|---|
| 一次性触发(One-time Trigger) | Watcher 触发后自动取消,需重新注册 |
| 异步通知 | 通知不包含变化后的数据,需再次 getData |
| 顺序保证 | 通知先于该事件之后的所有数据变更到达 |
| Session 绑定 | Session 过期时所有 Watcher 失效 |
核心原理
Watcher 通知流程
图中展示了 Watcher 的三个关键步骤:注册 → 触发 → 重新注册。通知中不包含数据,客户端必须主动拉取。
支持的 Watcher 类型
| 注册方法 | 可监听的路径 | 触发事件 |
|---|---|---|
| exists | 路径本身 | Created, Deleted, DataChanged |
| getData | 路径本身 | Deleted, DataChanged |
| getChildren | 子节点列表 | Deleted, ChildrenChanged |
持久递归 Watcher(Persistent Recursive Watcher)—— 3.6.0+
ZooKeeper 3.6.0 引入了持久递归 Watcher,解决了标准 Watcher 的两大痛点:①一次性触发后需要手动重新注册;②无法一次性监听整棵子树。它是 Curator TreeCache 的底层依赖能力,让配置中心等需要监听整棵子树的场景变得极其简洁。
核心区别:
| 特性 | 标准 Watcher | 持久递归 Watcher |
|---|---|---|
| 触发后是否删除 | 是(一次性) | 否(持久化,一直监听) |
| 触发事件 | NodeCreated/Deleted/DataChanged/ChildrenChanged | NodeCreated/Deleted/DataChanged(不含 NodeChildrenChanged) |
| 监听范围 | 单个 ZNode 或直接子节点列表 | 递归监听整棵子树 |
| API | getData(..., watcher) / exists(..., watcher) | addWatch(path, mode) |
| 移除方式 | 触发后自动移除 / Session 过期 | 显式调用 removeWatches |
| 引入版本 | 所有版本 | 3.6.0+ |
标准 Watcher vs 持久递归 Watcher 的触发行为差异:
关键差异:标准 Watcher 触发一次后"死亡";持久递归 Watcher 持续存活,直到显式
removeWatches或 Session 过期。
addWatch API 用法:
# 命令行
[zkshell: 0] addWatch /config PERSISTENT_RECURSIVE
# 成功,对 /config 及其所有子节点注册持久递归 Watcher
// Java 原生 API(ZooKeeper 3.6.0+)
zk.addWatch("/config", event -> {
System.out.println("Event on: " + event.getPath() +
", type: " + event.getType());
}, AddWatchMode.PERSISTENT_RECURSIVE);
适用场景:
| 场景 | 用法 | 说明 |
|---|---|---|
| 配置中心 | addWatch(/config, PERSISTENT_RECURSIVE) | 监听整个配置子树,任何配置项新增/修改/删除都感知 |
| 服务注册中心 | addWatch(/services, PERSISTENT_RECURSIVE) | 监听所有服务的上下线,无需对每个服务单独注册 |
| 分布式锁监控 | addWatch(/locks, PERSISTENT_RECURSIVE) | 监听锁节点的创建和删除 |
命令示例:配置子树的递归监听:
# Terminal 1 —— 注册持久递归 Watcher
[zkshell: 0] addWatch /config PERSISTENT_RECURSIVE
# Terminal 2 —— 修改不同层级的节点
[zkshell: 0] set /config/db/host "db2.internal"
[zkshell: 1] create /config/new-app/feature-flag "true"
# Terminal 1 依次收到通知
WATCHER::
WatchedEvent state:SyncConnected type:NodeDataChanged path:/config/db/host
WATCHER::
WatchedEvent state:SyncConnected type:NodeCreated path:/config/new-app/feature-flag
# Watcher 仍然存活,无需重新注册
removeWatches 操作:
# 移除指定路径的持久递归 Watcher
[zkshell: 0] removeWatches /config PERSISTENT_RECURSIVE
// Java API
zk.removeWatches("/config", watcher,
Watcher.WatcherType.Any, true); // local=true 仅移除本地
注意:removeWatches 默认移除全局(服务端 + 客户端),设置 local=true 则仅移除客户端记录、不通知服务端清理。
易错场景:事件风暴
持久递归 Watcher 在节点频繁创建/删除时可能引发事件风暴。假设 /locks 下有 100 个临时节点每秒有 50 个被创建和 50 个被删除——持久递归 Watcher 会将每一个事件推送给客户端,瞬时产生每秒 100 条通知。在这种高频写入场景下,建议混合策略:根节点用持久递归 Watcher 感知拓扑变化,叶子节点用标准 Watcher 按需监听。
完整示例
示例一:配置中心 Watcher 循环监听
场景说明:监控 /app/config 节点的数据变更,实现配置热更新。
# Terminal 1 —— 注册 Watcher 并读取
zkCli.sh -server 127.0.0.1:2181
[zkshell: 0] get -w /app/config
port=8080
# Watcher 已注册
# Terminal 2 —— 修改数据
zkCli.sh -server 127.0.0.1:2181
[zkshell: 0] set /app/config "port=9090"
# Terminal 1 收到通知
WATCHER::
WatchedEvent state:SyncConnected type:NodeDataChanged path:/app/config
# 重新注册 Watcher 并获取新数据
[zkshell: 1] get -w /app/config
port=9090
操作前后对比:
| 时间点 | Terminal 1 状态 | /app/config 数据 |
|---|---|---|
| 初始化 get -w | 读取 port=8080 | port=8080 |
| Terminal 2 set | 收到 NodeDataChanged | port=9090 |
| 重新 get -w | 读取 port=9090 | port=9090 |
示例二:子节点 Watcher — 服务发现
场景说明:监控 /services 下子节点变化,感知服务上下线。
# Terminal 1 —— 监控服务注册中心
zkCli.sh -server 127.0.0.1:2181
[zkshell: 0] ls -w /services
[order-service-1, order-service-2]
# Watcher 已注册在子节点列表上
# Terminal 2 —— 新服务上线
zkCli.sh -server 127.0.0.1:2181
[zkshell: 0] create -e /services/order-service-3 "http://192.168.1.12:8080"
# Terminal 1 收到通知
WATCHER::
WatchedEvent state:SyncConnected type:NodeChildrenChanged path:/services
# 重新注册并获取最新列表
[zkshell: 1] ls -w /services
[order-service-1, order-service-2, order-service-3]
操作前后对比:
| 事件 | /services 子节点列表 | 通知 |
|---|---|---|
| 初始注册 | [order-service-1, order-service-2] | — |
| 新服务上线 | [order-service-1, order-service-2, order-service-3] | NodeChildrenChanged |
| 重新注册 | 同上 | Watcher 续期 |
示例三:Java API 持续监听
场景说明:实现一个不中断的配置监听器。
public class ConfigWatcher implements Watcher {
private ZooKeeper zk;
private String configPath;
public ConfigWatcher(ZooKeeper zk, String path) {
this.zk = zk;
this.configPath = path;
}
@Override
public void process(WatchedEvent event) {
if (event.getType() == EventType.NodeDataChanged) {
try {
byte[] data = zk.getData(configPath, this, null);
System.out.println("Config changed: " +
new String(data));
// 已在 getData 中重新注册了 this Watcher
} catch (Exception e) {
e.printStackTrace();
}
}
}
public void start() throws Exception {
byte[] data = zk.getData(configPath, this, null);
System.out.println("Initial config: " +
new String(data));
}
}
// 使用
ZooKeeper zk = new ZooKeeper("127.0.0.1:2181", 3000, event -> {});
new ConfigWatcher(zk, "/app/config").start();
Thread.sleep(Long.MAX_VALUE);
执行结果(每次配置变化时输出):
Initial config: port=8080
Config changed: port=9090
Config changed: port=9090,debug=true
操作前后对比:
| 次数 | /app/config 变化 | 监听器输出 | Watcher 状态 |
|---|---|---|---|
| 初始 | port=8080 | Initial config: port=8080 | 已注册 |
| 第 1 次变更 | port=9090 | Config changed: port=9090 | 自动续期 |
| 第 2 次变更 | port=9090,debug=true | Config changed: port=9090,debug=true | 自动续期 |
易错场景与面试考点
易错场景
1. 忘记重新注册 Watcher
// 错误:Watcher 触发后失效,不再监听后续变更
byte[] data = zk.getData("/config", watcher, null);
// process 中直接使用 data,忘记重新 getData
public void process(WatchedEvent event) {
if (event.getType() == EventType.NodeDataChanged) {
// data 是旧的!需要重新 getData
}
}
2. Watcher 事件与数据不一致
Watcher 通知是异步的,客户端收到 NodeDataChanged 后重新 getData,可能再次获得新值(如果期间又被修改过)。这是正常行为,非 BUG。应用层需处理这种"事件合并"。
3. exists Watcher 与 getData Watcher 的区别
# get /node(节点存在时)—— 监听 DataChanged 和 Deleted
get -w /node
# exists /node(节点可能存在也可能不存在)—— 监听 Created、Deleted 和 DataChanged
stat -w /node
如果节点可能不存在但需要监听创建事件,必须使用 exists。
4. Watcher 中的阻塞操作
Watcher 回调在 ZooKeeper 的 EventThread 中执行,不应包含耗时操作。长时间阻塞会导致其他 Watcher 无法被处理。
// 错误:在 Watcher 中进行网络请求
public void process(WatchedEvent event) {
httpClient.get("http://remote/reload"); // 阻塞 EventThread
}
面试高频题
Q:为什么 Watcher 是一次性的?
A:设计上的权衡。如果 Watcher 持久化,在高频变更场景下会产生大量通知风暴。一次性触发迫使客户端显式重新注册,起到"消费确认"的作用,也避免服务端需要持续追踪和管理大量 Watcher 状态。
Q:Watcher 通知会丢失吗?
A:理论上可能。如果在 Watcher 触发到客户端重新注册之间,发生了多次变更,客户端只会收到一次事件通知(首次触发)。但客户端重新 getData 会获取最新值,不会导致数据最终不一致。
Q:exists 和 getData 的 Watcher 可以同时注册吗?
A:可以,它们互不影响,各自独立触发。例如对同一节点同时注册 exists 和 getData Watcher,数据变更时两个 Watcher 都会触发。
Q:Curator 的 Cache 机制解决了什么痛点?
A:Curator 的 TreeCache / NodeCache 封装了 Watcher 的循环注册逻辑,自动处理重连后的重新注册,提供本地缓存镜像和事件回调。本质上是对原生 Watcher 的高层封装。
小结
| 要点 | 说明 |
|---|---|
| One-time Trigger | 标准 Watcher 触发后失效,必须重新注册 |
| 持久递归 Watcher | 3.6.0+ addWatch,持久化且递归监听整棵子树 |
| 异步通知 | 不包含数据,需再次 getData |
| 三类注册 | exists / getData / getChildren |
| 事件类型 | Created / Deleted / DataChanged / ChildrenChanged |
| Session 绑定 | 过期后所有 Watcher 失效 |
| 通知顺序 | 保证事件先于后续数据变更到达 |
Watcher 是 ZooKeeper 作为协调服务的核心。下一章进入 ZooKeeper 内部——ZAB 协议与一致性保证,揭示数据如何在集群间同步。