分布式系统 17:ZooKeeper 服务模型与会话边界
配置更新、服务发现和主节点选举,都需要多个进程对少量共享状态形成一致的解释。ZooKeeper 提供的接口很小:创建节点、读取数据、带版本更新、列出孩子,以及接收变化通知。但这些接口的有效范围不同。一次 TCP 断开不等于会话结束;收到 Watch 不等于拿到了新值;临时节点消失,也不能证明原进程已经停止访问外部资源。
第 16 篇 用受限搜索检查原创 Raft 核心。本篇转向实际产品的 API 合同,使用 Apache ZooKeeper 3.9.6 官方服务端和 Java 客户端完成本机 TCP 实验。第 18 篇再讨论 ZAB 如何组织更新与恢复,不能把前面实现的 Raft 机制直接套进 ZooKeeper。课程结构参考 MIT 6.5840 的 ZooKeeper 讲义及 Stanford CS244B 的论文研读安排。2010 年原论文解释协调服务的设计取舍;本篇涉及的持久 Watch 和 Java 会话终止行为,则按 3.9.6 文档与发布源码核对。
实验的系统模型
服务端、客户端和代理都运行在同一台机器、同一个 JVM 中。故障注入只中断 A 的网络连接,B、服务端和本地文件系统继续运行;没有模拟拜占庭消息、数据损坏、CPU 长时间停顿或整个 JVM 崩溃。代理允许连接时按字节转发,阻断时关闭两端 socket 并拒绝新的连接。客户端库负责重连、心跳和 Watch 恢复,验收代码不伪造协议响应。
这个安排能把“服务仍然工作,但一个客户端失联”分离出来。B 的正常请求是对照,A 的连接和会话状态是被观察对象。事件等待设置 25 秒上限,超过上限会使实验失败;它不会把等待超时自动解释成 ZooKeeper 已过期。会话标识、Stat、返回码和事件轨迹共同决定断言结果。
znode 的状态不只有一串字节
ZooKeeper 的命名空间是一棵树。一个 znode 可以同时有数据和孩子,路径用于定位,数据用于保存小规模协调信息。应用可以把 /service/api 的孩子作为注册项,也可以用 /config/api 的数据存配置引用。znode 不适合存放大对象:读写替换整段数据,较大的数据会增加传输与处理成本。原论文把这种接口定位为构造协调机制的基础操作,而非完整应用数据库。Hunt 等,USENIX ATC 2010,§2.1–2.4
getData 除了返回字节,还能填写 Stat。其中 version 对应数据变化,cversion 对应孩子集合变化,aversion 对应 ACL 变化。它们不能互换使用。修改某个孩子的数据,不等于修改父节点的数据版本;通过父节点的 version 做 CAS,也不会自动验证孩子集合未变。3.9.6 Stat API
图中每个字段回答一种具体变化;没有一个字段能替代整个子树的状态。
flowchart TD
R["/service"] --> A["/service/api"]
A --> D["data: 配置引用"]
A --> V["version: 本节点数据变化"]
A --> C["cversion: 孩子集合变化"]
A --> P["aversion: ACL 变化"]
A --> N["/service/api/instance-1"]
N --> E["ephemeralOwner: 所属 session"]
czxid、mzxid、pzxid 关联创建、数据修改和孩子变化的事务标识。zxid 表达事务顺序,不能当作每个节点从零开始的数据版本。一个 multi 事务的多个子操作可以共享同一个 zxid;3.9.6 DataTree.processTxn 给子事务头复用父事务的 zxid。因此,“每个节点变化都对应不同 zxid”并不准确。固定版 DataTree.java
带版本更新怎样防止覆盖
两个客户端都读到值 zero、版本 0。A 执行 setData(path, one, 0) 后版本变为 1;B 再以版本 0 写入,服务端返回 BadVersion。版本比较与更新在服务端操作中完成,B 无法用一个过期的读取结果无声覆盖 A 的更新。
sequenceDiagram
participant A as 客户端 A
participant Z as ZooKeeper
participant B as 客户端 B
A->>Z: getData
Z-->>A: zero, version 0
B->>Z: getData
Z-->>B: zero, version 0
A->>Z: setData one, expected 0
Z-->>A: 成功,version 1
B->>Z: setData stale, expected 0
Z-->>B: BadVersion,值不变
B->>Z: setData unconditional, expected -1
Z-->>B: 成功,version 2
-1 明确绕过版本比较,适合确实需要无条件替换的操作。它不能作为 CAS 冲突后的通用重试参数,否则原本要防止的覆盖会重新发生。3.9.6 的 checkAndIncVersion 在非 -1 且版本不符时抛出异常,通过后增加版本。固定版 PrepRequestProcessor.java
这个机制的正确性直觉只涉及同一节点生命周期内的条件更新:成功响应说明指定版本条件在更新时满足;明确的 BadVersion 说明该请求未完成这次修改。网络断开导致的未知结果是另一类情况,不能从“没有收到成功”推出“没有修改”。业务仍要重新读取和判断,不能把 CAS 当作请求去重协议。
版本也不是永久身份。节点删除再创建后可以重新从初始版本开始,整数版本还有表示范围。长期保存的 (path, version) 不能自行解决删除重建带来的 ABA 问题;如果业务需要跨生命周期身份,必须另行设计标识和校验规则。
顺序编号属于父节点
创建 PERSISTENT_SEQUENTIAL 或 EPHEMERAL_SEQUENTIAL 节点时,客户端提供前缀,服务端返回包含编号的完整路径。应用必须保存这个返回值。a- 和 b- 两种前缀并不会在同一父节点下获得互不影响的计数器。
本地实验先在 /zk17/seq 下创建 a- 顺序节点,然后创建普通孩子 ordinary,最后创建 b- 顺序节点,得到:
1 | |
固定源码使用父节点的 cversion 生成 %010d 后缀。父节点下普通孩子的创建、删除也会影响这一计数,所以顺序节点编号可以跳跃。不同父节点有各自范围;getChildren 的返回顺序也不能直接当成编号排序结果。固定版 PrepRequestProcessor.java
通常范围内补零便于比较名称,但底层计数是有符号整数。官方 Guide 明确记录溢出后出现负数的边界;实验没有通过大量创建验证该边界。用这类编号做业务设计时,不能把它升级成跨目录、无限期、无间隙的全局序列。3.9.6 Programmer’s Guide,Sequence Nodes
session 跨越连接,临时节点跟随 session
A 创建临时节点后,ephemeralOwner 标识创建它的 session。临时节点不允许孩子;实际调用在本篇实验中返回 NoChildrenForEphemerals。节点的存在期与 session 相关,而非某条 TCP 连接的存续期。短暂断连时客户端可以重连并继续原 session;在持续失联的场景下,服务端确认 session 过期后执行相应清理。正常关闭 session 同样会清理其临时节点;实验专门保留连接故障后的自然过期路径,避免把主动关闭当作过期证据。
客户端请求一个 timeout,服务端按配置协商实际值。实验请求并得到 8000 ms,服务端 tickTime 为 500 ms,显式设置最小 1000 ms、最大 16000 ms。这些是实验配置。不能把某次临时节点删除时刻写成精确的超时承诺:最后一次有效活动、服务端到期处理、通知传输和客户端回调都有各自的时间位置。
服务端清理和客户端得知终止需要分别观察。图中 B 直连服务,A 的连接被阻断;B 能观察到清理,并不要求 A 已经收到同样的信息。
sequenceDiagram
participant A as 客户端 A
participant P as 断连代理
participant Z as ZooKeeper
participant B as 客户端 B
A->>Z: 创建 ephemeral,属于 session S
B->>Z: exists 并注册删除 Watch
Note over A,P: 关闭旧 TCP,拒绝新连接
Z->>Z: session 到期并删除 ephemeral
Z-->>B: NodeDeleted
B->>Z: exists
Z-->>B: 不存在
Note over A: 可能已本地终止,也可能仍在连接
Note over P: 放行后续连接
A->>Z: 若 handle 尚存活,尝试恢复 S
Z-->>A: 若已过期,恢复失败
Note over A: Expired 终止旧 handle;新建 handle 获取新 session
3.9.6 Java 客户端还有必须单列的实现细节:已有 session 在接收空闲达到 expirationTimeout 后,可以本地关闭 handle 并排入 Expired,该内部阈值按协商 timeout 的 4/3 设置。因此,不能断言客户端“必须重连以后才知道过期”。本地终止也不直接执行服务端临时节点删除,验证服务端状态仍需独立观察。固定版 ClientCnxn.java
这一差别有明确修订背景:ZOOKEEPER-4508 修复无法连接任何服务器时客户端无限重试而不报告过期的问题,修复版本包含 3.9.3;ZOOKEEPER-4921 又处理相关的新会话重连问题,修复于 3.9.4。历史 issue 用于解释边界变化,不能据此声称这些旧版本缺陷仍原样存在于 3.9.6。
session 过期也不意味着 A 所在进程已停止。A 可能继续执行不经过 ZooKeeper 的代码。临时节点可以表达注册状态,却不能单独强制外部数据库或存储拒绝旧持有者;第 19 篇会进一步讨论这种边界与 fencing。
默认 Watch 通知一次变化
读取和监听要围绕同一次 API 调用组织。getData(path, watcher, stat) 返回数据并注册数据 Watch;exists 可以对当前不存在的路径注册存在性 Watch,getData 则不能成功读取不存在的节点。默认 Watch 被触发后会消耗,应用要重新读取和注册。
通知中包含事件类型和路径,不包含足以重建完整业务状态的全部旧值、新值。它的用途是提示重新获取状态。A 收到第一次更新通知后,B 可以继续更新;A 再读取时可能直接得到后来的值。
sequenceDiagram
participant A as 客户端 A
participant Z as ZooKeeper
participant B as 客户端 B
A->>Z: getData + Watch
Z-->>A: value 0
B->>Z: setData 1
Z-->>A: NodeDataChanged,Watch 消耗
B->>Z: setData 2
Note over A,Z: 未重装,不产生第二次数据通知
A->>Z: getData + 新 Watch
Z-->>A: value 2
B->>Z: setData 3
Z-->>A: 新 NodeDataChanged
实验没有用“睡一秒没收到”判定一次性行为。A 收到第一次通知后,B 完成第二次写入,再向 A 发起异步 getData。完成回调在 A 的 EventThread 执行;实验等到该回调,确认读取值已更新,同时该窗口内数据通知计数仍为 1。重新注册后再写,通知计数变为 2。
这个屏障成立于本例的条件:单服务端已处理 B 的写入,期间没有继续写入该路径,A 随后的读取响应与通知沿同一客户端处理路径排序。它不阻止将来的新写入,也不是任意并发系统的“再无事件”证明。
回调只把事件记录进队列并唤醒等待者,不能在事件线程内等待另一个回调,否则应用自身就可能阻塞事件处理。检查还按路径、事件类型区分数据通知和 None 类型的连接状态事件,避免把 Disconnected 等状态变化误算成数据变更。
Watch 顺序保证也不能扩张为两个客户端的线程调度保证。A 收到事件与 B 的调用返回,发生在不同连接和不同回调线程上;本篇不对它们建立未经验证的固定先后关系。
持久 Watch 保留注册,不提供离线事件日志
addWatch 的 PERSISTENT 模式不需要每次触发后重新注册;PERSISTENT_RECURSIVE 还覆盖路径的后代。实验连续两次更新同一路径,A 都收到通知;递归监听中,孩子和孙节点的创建也被观察到,实验另检查根路径没有出现 NodeChildrenChanged。官方递归模式语义不为同一变化额外发送冗余的 NodeChildrenChanged。3.9.6 addWatch API
持久注册与离线历史是两个不同要求。A 失联期间,B 可以创建并删除一个原本不存在的节点;A 恢复后读取仍然不存在,单凭最终状态无法推导期间发生过什么。对已有节点的 persistent Watch,同样不能假设离线更新会逐条补发。
flowchart TD
D["A 断连;代理拒绝重连"] --> X["B 创建 transient,再删除"]
D --> Y["B 把 persistent 数据更新两次"]
X --> R["A 恢复同一 session"]
Y --> R
R --> W["恢复 Watch 注册"]
W --> Q["A 异步读取回调屏障"]
Q --> N["transient 不存在;窗口内无创建或删除通知"]
Q --> V["读到最终数据;窗口内无离线更新回放"]
V --> U["新的在线更新正常产生通知"]
3.9.6 DataTree.setWatches 给出了机制解释:普通 data/children Watch 恢复时可以比较当前状态与记录的 zxid,产生相应通知,但这也不是逐条恢复所有中间变化。persistent 两种模式的恢复分支重新加入 Watch,没有读取事务日志逐条重放离线事件。固定版 DataTree.java
ZOOKEEPER-4698 报告了持久 Watch 在断连期间丢失事件的情形。issue 的 affected version 标签本身不能证明 3.9.6 的行为;这里同时采用固定发布源码和本机实验。实际观察是:A 同 session 恢复,读到 B 离线期间最后写入的值,但经过回调屏障仍没有这些离线更新的通知;下一次在线更新能正常触发,说明注册已恢复。
SyncConnected 还不足以单独充当上述屏障。客户端完成握手时可以先排入这个状态事件,恢复 Watch 的请求仍需发送和处理。实验在它之后额外发起 A 的异步读取,等到完成回调才检查计数。有限观察窗口只证明这一条执行记录;“持久 Watch 不是可回放日志”的工程结论还依靠对应源码和 API 边界。
需要可靠处理每一次业务事件的系统,应把业务事件保存在可持久读取的数据结构或事件存储中。Watch 可以促使消费者重新读取位置与状态,但不能替代事件本身,也不能用本地回调计数还原已经遗漏的更新次数。
官方服务与真实 TCP 的验证范围
实验代码位于 examples/distributed-systems/zk17/Zk17.java。所有协议处理来自 3.9.6 官方类,代理仅转发或关闭 TCP 字节流,不修改 ZooKeeper 消息。A 的短断连、长断连都同时关闭当前连接并拒绝新连接;只拒绝新建连接而保留旧 TCP,会让原来的心跳继续工作,无法构成所需故障。
flowchart TD
A["官方客户端 A"] --> P["标准库 TCP 代理<br/>127.0.0.1 随机端口"]
P --> Z["官方独立 ZooKeeperServer<br/>127.0.0.1 随机端口"]
B["官方客户端 B<br/>持续直连"] --> Z
T["验收线程<br/>断言、队列与有界等待"] -.控制断连.-> P
A -.记录事件并唤醒等待者.-> T
B -.记录事件并唤醒等待者.-> T
Z --> F["本次独占 .build/zk17/run-*<br/>事务日志与快照"]
从仓库的 examples/distributed-systems 目录执行,发行包和类文件都保存在固定构建目录。以下是本机 Java 17 路径;其他机器需替换安装位置。
1 | |
2026-09-20 的首轮实际运行中,CAS、顺序节点、临时节点限制、两类持久 Watch、一次性 Watch、短断连和自然过期场景全部通过。长断连后,约第 9935 ms,B 已确认临时节点不存在,A 的 handle 仍为 CONNECTING;放行后约第 10008 ms,A 收到 Expired,随后新 handle 建立了不同 session。这些是从程序起点计算的一次轨迹时间,不是 session 删除延迟的测量指标。
实验没有调用 server.expire、主动关闭 A 或停止整个服务器来制造预期删除。close 只用于断言结束后及异常清理。每次运行保留自己的数据目录;清理尝试覆盖所有已创建客户端、代理和服务,即使某个关闭动作报错也继续清理其余资源。
第一次启动在沙箱的回环 bind 上被拒,返回 Operation not permitted。同一命令通过环境的正规本地监听授权后完成实验,没有更换执行文件规避防护。最终源码增加失败清理保障后,独立复用同一批 class 文件再次运行,所有场景通过,帮助与错误参数退出码也符合预期。两次成功运行都观察到恢复握手导致的 SessionExpiredException;没有实测到本地接收空闲阈值先行终止的分支。原始日志摘录、源码校验值和最终复核情况见 验证记录,资料与论断对应关系见 证据表。
单进程服务没有 quorum,无法验证多数派故障后的可用性、ZAB 恢复、跨主机网络分区或掉电持久性。macOS 也仅作为本机开发验证环境。这里确认的是指定官方版本在真实 TCP 路径上的 API 和会话行为;性能数据、生产容量与完整协议证明均不在这些输出中。
两道练习
练习一:一次性通知后的两次写入。 A 用 getData 读取配置并注册 Watch,B 连续完成两个更新,A 收到一个通知后重新读取。A 能否根据通知次数判断配置只更新过一次?如果 A 随后用旧 version 写入,收到 BadVersion,能否改成 -1 就认为完成了原来的 CAS 意图?
答案要点:通知只要求重新获取状态,默认注册被第一次变化消耗,中间版本可能未被逐个观察。重新读取可以得到更新后的状态,但通知次数不是更新计数。BadVersion 表明预期条件不成立;改用 -1 会改变操作语义。应按业务规则重新计算条件更新,若需要处理每次变化则保留独立事件历史。
练习二:服务端删除与客户端终止。 A 经代理访问服务,B 直连。阻断 A 后,B 看到临时节点被删除,A 尚未收到 Expired。能否判定 A 已停止执行?反过来,A 本地收到 Expired,是否单靠这一事件就足以证明服务端节点已被清理?
答案要点:两项都不能直接推出。服务端生命周期和客户端 handle 是不同观察对象,进程还可能访问其他外部资源。实验用 B 的通知加存在性读取验证清理,用 A 的状态和旧 handle 操作验证本地终止;外部资源能否拒绝旧持有者仍需要单独的授权或 fencing 机制。
第 18 篇进入 ZAB:当服务由多个副本组成,提议、确认、提交和领导者恢复怎样维护更新顺序。这里的 znode 与 session API 是随后解释内部协议时需要保持的应用层边界。


