3.2 原生 API 实战:会话、异步与一次性 Watcher 本节摘要:原生客户端的一切围绕三件事:会话的生命周期、同步与异步两套接口、以及注册即一次性触发的 Watcher。本节用可运行的代码拆开这三件事,重点讲清"为什么 Watcher 只响一次"这个反复制造事故的设计。 从一段日志说起 新手第一次跑原生客户端常撞见同一堵墙:代码里创建了节点,控制台却打出 ,或者程序刚启动就退出了。根因几乎总是一个——new ZooKeeper() 是异步的。构造函数立刻返回,连接还在后台握手中,此时调用任何读写都会失败。理解了"构造即后台建连"这个模型,原生 API 的一半谜团就解开了。 会话:一场有租约的出庭资格 每个原生客户端实例对应一个会话。
本节摘要:原生客户端的一切围绕三件事:会话的生命周期、同步与异步两套接口、以及注册即一次性触发的 Watcher。本节用可运行的代码拆开这三件事,重点讲清"为什么 Watcher 只响一次"这个反复制造事故的设计。
新手第一次跑原生客户端常撞见同一堵墙:代码里创建了节点,控制台却打出 ConnectionLoss,或者程序刚启动就退出了。根因几乎总是一个——new ZooKeeper() 是异步的。构造函数立刻返回,连接还在后台握手中,此时调用任何读写都会失败。理解了"构造即后台建连"这个模型,原生 API 的一半谜团就解开了。
每个原生客户端实例对应一个会话。会话有三态:connecting(正在建连或重连)、connected(可用,SyncConnected 事件即进入此态)、expired(租约到期,已作废)。前两态之间可以反复横跳——网络闪断时客户端自动重连,只要会话没过期,恢复后一切照旧;但从 expired 出来,性质完全不同:服务端已按超时清理了该会话的临时节点与监听,客户端本地的一切都要重建。
import org.apache.zookeeper.*; import java.util.concurrent.CountDownLatch; public class SessionDemo { public static void main(String[] args) throws Exception { CountDownLatch connected = new CountDownLatch(1); // 构造参数:地址串、会话超时毫秒、全局 Watcher(连接状态事件在这里) ZooKeeper zk = new ZooKeeper("127.0.0.1:2181", 15000, event -> { System.out.println("事件: " + event.getType() + " 状态: " + event.getState()); if (event.getState() == Watcher.Event.KeeperState.SyncConnected) { connected.countDown(); // 会话真正可用,放行主线程 } }); connected.await(); // 等会话建立再干活,新手墙就在这行 System.out.println("会话 id: " + zk.getSessionId()); // 程序退出前必须 close,否则服务端要等会话超时才清理临时节点 zk.close(); } }
把超时调小(比如 3000 毫秒)再做一次 GC 压力测试,你会亲眼看到 expired 事件——这比读十篇文章都直观。

原生 API 每个操作都有同步与异步两个版本。同步版抛异常、写法直白;异步版不抛,结果通过回调的 rc 返回码传递,适合高吞吐场景把 IO 等待从业务线程里挤出去:
// 同步:简单直接,失败抛 KeeperException zk.create("/task/t1", "job-a".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT_SEQUENTIAL); // 异步:立即返回,结果走回调。rc 是返回码,ctx 是透传上下文 zk.create("/task/t2", "job-b".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT_SEQUENTIAL, (rc, path, ctx, name) -> { if (rc == KeeperException.Code.OK.intValue()) { System.out.println("创建成功,实际路径 " + name); // 顺序编号在这里 } else { System.out.println("创建失败,rc = " + rc); } }, "任务二");
工程上有个反直觉的建议:长驻服务别为了"高性能"把同步全改异步。事件回调在单线程串行派发,回调里再做阻塞操作会让所有监听排队——异步的正确用法是回调只做入队,重活交给业务线程池。
Watcher 的契约只有一句:注册一次,触发一次,触发即失效。它不是实现缺陷,而是设计取舍——服务端不为监听器保存"持续契约",触发后你若继续使用旧监听器,读到的状态与触发时刻之间可能已经又变了几轮,与其给出容易误导的持续通知,不如逼着调用方"重新读、重新挂",让每次认知都锚定在最新读到的状态上。
// 错误写法:以为挂一次就能一直收通知 zk.getData("/config", event -> { System.out.println("只收到第一次变化,之后永久失聪"); }, null); // 正确写法:读-听循环,每次触发后重读并重挂 void watchForever(ZooKeeper zk, String path) throws Exception { byte[] data = zk.getData(path, event -> { try { watchForever(zk, path); // 递归重挂:先注册再处理,避免漏事件窗口 System.out.println("配置更新为 " + new String(zk.getData(path, false, null))); } catch (Exception e) { // 会话过期或节点消失时的重建逻辑必须落地,这里从简 } }, null); System.out.println("当前值 " + new String(data)); }
这段"读-听循环"正是 Curator NodeCache 内部的骨架(3.3 节对照)。理解了它,你就同时理解了两件事:为什么原生 API 写长监听这么啰嗦,以及封装层到底替你做了什么。
问:异步 create 的回调里拿到的 name 和我传的 path 不一样? 用了顺序模式就会追加编号后缀,回调返回的是服务端实际分配的完整路径——分布式锁正是靠这个真实路径判断自己排在第几位,5.2 节见。
下一节进入 Curator 的世界,看这些繁琐细节如何被封装成顺手的工具。