ZooKeeper znode、Session、Watch 与 ACL 工具手册
从“服务节点还在,调用却全部失败”开始
注册中心页面里仍显示一个支付服务实例,流量却不断打到已经退出的进程。另一个团队遇到相反现象:进程只是短暂网络抖动,临时节点却消失,重连后没有重新注册。两类事故都不能靠“ZooKeeper 端口能连”解释,需要沿着真正的状态链排查:
客户端 TCP 连接
-> ZooKeeper session
-> session 持有的 ephemeral znode
-> 读操作注册的 watch
-> 变更 zxid 与节点 versionZooKeeper 是分布式协调服务。它用层级命名空间保存体积很小的协调数据,并提供 session、临时节点、顺序节点、watch、ACL 和原子多操作。服务发现、选主、锁和成员关系可以建立在这些原语上;大 JSON、业务流水、高频事件和大对象应该放在数据库、对象存储或消息系统中。官方程序员指南指出单个 znode 有小于 1 MiB 的保护,但平均数据应远小于这个上限,因为大数据会放大网络、磁盘、内存和尾延迟。
Apache 当前把 3.9.5 称为 current release,把 3.8.6 称为 latest stable release。新部署若需要 3.9 的快照恢复 AdminServer API,应固定 3.9.5 并完成兼容验证;维护旧平台可继续评估 3.8.6,但不要把两条发行线的运维能力混写成相同。两个版本均可从 Apache 发布页 获取,Docker Official Image 也提供对应固定标签。ZooKeeper 使用 Apache License 2.0。
第一次实验准备 Docker Compose、空闲的 2181 和 8080 端口即可。2181 是客户端入口,2888 用于 follower 与 leader 通信,3888 用于选主,8080 是 AdminServer。单节点只用于学习对象语义;生产可用性必须由跨故障域 ensemble 提供。
先启动一个可丢弃的开发实例
固定镜像补丁版本,不使用会漂移的 latest:
services:
zookeeper:
image: zookeeper:3.9.5-jre-17
container_name: te-zookeeper
restart: unless-stopped
ports:
- "127.0.0.1:2181:2181"
- "127.0.0.1:8080:8080"
environment:
ZOO_TICK_TIME: 2000
ZOO_4LW_COMMANDS_WHITELIST: srvr,mntr,ruok
ZOO_AUTOPURGE_SNAPRETAINCOUNT: 3
ZOO_AUTOPURGE_PURGEINTERVAL: 1
volumes:
- zookeeper-data:/data
- zookeeper-datalog:/datalog
volumes:
zookeeper-data:
zookeeper-datalog:镜像里的 JRE 17 是这个容器标签的运行时选择,不等于 ZooKeeper 3.9 只支持 Java 17。使用二进制发行包时,应按该发行线的管理员指南和支持矩阵选择 JDK,并让服务端、CLI 和客户端依赖分别可追踪。
tickTime 是服务端心跳和超时的基本时间单位。客户端请求的 session timeout 会被服务端协商,当前实现通常限制在 2 * tickTime 到 20 * tickTime 之间。它不是“节点删除延迟”的精确承诺,进程暂停、网络分区、GC 和重连都会影响客户端何时获知过期。
启动并连接:
docker compose up -d zookeeper
docker compose logs --tail=80 zookeeper
docker exec -it te-zookeeper zkCli.sh -server 127.0.0.1:2181CLI 出现 SyncConnected 后,先建立自己的项目根路径:
create /te te-root
create /te/order order-root
create /te/order/dev dev-root
create /te/order/dev/config config-root
create /te/order/dev/instances instances-root若返回 Node already exists,先用 get、getAcl 和 stat 判断它是否来自旧卷,不要把残留数据当成当前实验结果。开发环境需要完全重置时:
docker compose down
docker volume ls --filter name=zookeeper
docker compose down -v最后一条会删除 znode、ACL、事务日志和快照,只能用于确认归属的本地卷。共享 ensemble 上删除 dataDir 或 dataLogDir 可能破坏成员身份和恢复链,不能照抄。
这只是单节点实验清理,不是数据回滚。一个 znode 的配置回滚应先读取当前 dataVersion,再用带版本条件的 set 写回审查过的旧值;版本不匹配就停止,避免覆盖更新。ACL 回滚同样要比较 aclVersion。整集群 snapshot/restore 只用于灾难恢复或新集群播种,不能代替单个节点的发布回滚。
先读懂 znode 的数据和 Stat
在 CLI 中写入并读取配置:
create /te/order/dev/config/payment-timeout 1500
get -s /te/order/dev/config/payment-timeout
set -s /te/order/dev/config/payment-timeout 2000
get -s /te/order/dev/config/payment-timeout预期第二次读取的数据变成 2000,dataVersion 增加。Stat 不是装饰信息:
czxid:创建该 znode 的事务 ID。mzxid:最后修改数据的事务 ID。pzxid:最后修改子节点集合的事务 ID。
dataVersion:节点数据修改次数,用于条件更新。cversion:子节点列表修改次数。aclVersion:ACL 修改次数。
ephemeralOwner:临时节点所属 session ID;持久节点为 0x0。dataLength、numChildren:数据长度和直接子节点数。
zxid 是全局事务顺序标记,高位代表 epoch,低位是该 epoch 的计数;leader 更替会进入新 epoch。dataVersion 只描述一个 znode 当前生命周期内的数据修改。排查“谁先发生”看 zxid,做单节点乐观并发控制看 version,二者不能互换。
路径像文件系统,但 znode 可以同时有数据和子节点。连接串还可以带 chroot:
ZOOKEEPER_CONNECT=zk-1.example.invalid:2181,zk-2.example.invalid:2181,zk-3.example.invalid:2181/te/order/prod应用在 chroot 后访问 /config/payment-timeout,服务端真实路径是 /te/order/prod/config/payment-timeout。初始化时必须先创建 chroot 根;日志和运维台账同时记录逻辑路径与真实路径,否则不同客户端会对“节点不存在”得出相反结论。
正向实验:Session 结束会清理临时节点
打开 CLI A,创建一个临时顺序节点:
create -e -s /te/order/dev/instances/worker- 127.0.0.1:9000预期返回类似 /te/order/dev/instances/worker-0000000000。-e 把节点生命周期绑定到当前 session,-s 让服务端在父路径下追加固定宽度的单调序号。用 get -s 查看它,ephemeralOwner 应为非零值。
在 CLI B 观察父节点:
get -w /te/order/dev/instances
ls -w /te/order/dev/instances数据 watch 与子节点 watch 是两类订阅。这里真正关心成员增删的是 ls -w 注册的 child watch。让 CLI A 用 quit 正常结束 session,CLI B 预期收到一次父节点子项变化事件,再执行:
ls /te/order/dev/instances临时节点应已消失。这个实验只证明“session 正常结束会清理临时节点”;它没有模拟真实网络分区。网络断开时客户端先进入 Disconnected,若在协商 timeout 内连回任一 ensemble 成员,原 session 和临时节点可以继续;超过 timeout 后由服务端判定 Expired 并删除临时节点。客户端只有重新连上时才可能收到过期通知。
因此项目代码不能在每次 Disconnected 时创建新 session,也不能只在进程启动时注册一次。正确状态机是:短暂断开由客户端库重连;收到 Expired 后创建新 session、重新认证、重建临时节点、重新读取状态并注册 watch。
Watch 的一次性与断线窗口
标准 watch 是读操作的副作用:exists、getData、getChildren 分别注册对应事件,触发一次后即被清除。先在 CLI A 执行:
get -w /te/order/dev/config/payment-timeoutCLI B 连续修改两次:
set /te/order/dev/config/payment-timeout 2100
set /te/order/dev/config/payment-timeout 2200预期 CLI A 只收到第一次变更对应的一次通知。再次读取会看到最终值 2200,但第二次修改不会自动再发一个标准 watch 事件。这正是“把 watch 当消息队列”会漏事件的原因:通知只是在提醒客户端重新读取当前状态,不承诺把每个业务事件交付一次。
断线重连时客户端库通常会重新注册 watch,但存在一个明确窗口:对尚不存在节点注册的 exists watch,若节点在客户端断线期间被创建又删除,客户端可能完全看不到。3.6 起提供 persistent 和 persistent recursive watch,触发后不会自动移除,但它仍是状态通知机制,会消耗服务端内存和分发资源,也不能替代持久事件日志。
稳健的项目接入流程应把 session 状态和业务状态分开:
SyncConnected
-> 重新认证
-> 全量读取目标路径及 Stat
-> 构建本地快照
-> 注册对应 watch
Disconnected
-> 暂停依赖“我仍持有锁/领导权”的危险动作
-> 等待客户端库重连
Expired
-> 丢弃旧 session 身份
-> 新建 session
-> 重建 ephemeral 节点、watch 和本地快照若业务要求每个事件都不可丢,ZooKeeper 只保存当前协调状态,把事件交给消息系统或数据库 outbox。
反向实验:旧 version 必须拒绝并发覆盖
先创建一个实验节点并查看版本:
create /te/order/dev/lab version-0
get -s /te/order/dev/lab新节点的 dataVersion 预期为 0。用 CAS 修改:
set -v 0 /te/order/dev/lab version-1
set -v 0 /te/order/dev/lab stale-writer第一条成功后 version 已变为 1,第二条继续携带旧 version,预期返回 BadVersion 或 CLI 的 version No is not valid。再读取节点,应仍是 version-1,不会被旧写入者覆盖。
这个失败是并发控制证据,不是“多试几次就好”。调用方应重新读取数据和 Stat,判断业务意图后再提交新 version。-1 表示忽略版本检查,适合明确接受覆盖的管理动作,不应成为项目默认值。删除和 ACL 更新同样有 version/aclVersion 竞争语义。
需要跨多个 znode 原子修改时使用 multi API,让全部操作一起成功或一起失败;不要用多个独立 set 模拟事务。锁和选主还要处理客户端在 ConnectionLoss 后“不知道请求是否已经提交”的歧义,通常通过唯一顺序节点和重新读取来判定,而不是盲目重试 create。
ACL 正反验证:父节点安全不代表子节点安全
ZooKeeper ACL 的权限位是 c 创建子节点、d 删除子节点、r 读取、w 写数据、a 管理 ACL。常用 scheme 包括 world、auth、digest、ip、x509 和 SASL。ACL 挂在单个 znode 上,不会自动继承给后来创建的子节点。
在管理 CLI 中添加 digest 身份并保护项目节点:
addauth digest te-order-dev:REPLACE_WITH_ZK_PASSWORD
setAcl /te/order/dev auth:te-order-dev:cdrwa
getAcl /te/order/devauth scheme 会采用当前连接已经认证的身份,ACL 表达式本身会被服务端忽略,但语法上仍须保留一个非空占位,因此这里写用户名而不再重复密码。若改用 digest scheme 写 ACL,保存的是 username:base64(SHA1(username:password)) 形式的身份摘要,不是可直接照抄的明文密码。addauth 仍会让凭证出现在交互输入和终端审计面,以上命令只适合隔离实验;共享环境应由受控初始化程序从秘密存储读取凭证,创建节点时一次性写入 ACL。
另开一个未认证 CLI,执行:
get /te/order/dev
create /te/order/dev/unauthorized bad预期返回 NoAuth。然后检查一个既有子节点:
getAcl /te/order/dev/config如果它仍显示 world:anyone,说明父 ACL 并未保护它。可以在经过影响面确认后用 CLI 的递归 ACL 操作处理现有树,但项目初始化更应在创建每个关键节点时显式带 ACL,并对新子节点做回归验证。递归改 ACL 属于高风险批量变更,错误权限可能立即锁死应用或扩大暴露面。
密码、Kerberos keytab、客户端私钥和 superDigest 不能进入仓库、镜像层、命令历史或普通日志。superDigest 可绕过 ACL,只能作为严格审计的应急能力。共享环境应优先使用 TLS/mTLS 或企业身份体系,digest 只保护认证语义,不加密网络中的数据和凭证。
从单节点走到三节点 ensemble
ZooKeeper 官方建议至少三个独立服务器构成可容错 ensemble,并优先使用奇数投票成员。三节点需要两个成员形成多数派,可容忍一个故障;五节点需要三个,可容忍两个。四节点仍只能容忍一个故障,却增加复制和连接成本。
开发机可用三个容器理解配置,但同一宿主机上的三个容器不构成生产冗余。每个成员必须有唯一 myid,并共享一致的成员清单:
server.1=zk-1:2888:3888;2181
server.2=zk-2:2888:3888;2181
server.3=zk-3:2888:3888;2181Docker Official Image 对应使用 ZOO_MY_ID 与 ZOO_SERVERS。客户端连接串列出全部客户端地址,不固定只连 leader:
ZOOKEEPER_CONNECT=zk-1.example.invalid:2181,zk-2.example.invalid:2181,zk-3.example.invalid:2181/te/order/prod
ZOOKEEPER_SESSION_TIMEOUT_MS=10000
ZOOKEEPER_CONNECTION_TIMEOUT_MS=3000示例超时值只用于说明字段关系,生产值要根据 tickTime、网络分位数、GC 停顿、故障检测目标和误过期成本测量。三个成员还要分散宿主机、机架/可用区、电源和网络故障域;否则逻辑多数派会被同一个物理故障一起带走。
Observer 可以承接读和客户端连接,但不参与提案投票,不能增加故障容忍度。增加投票成员也不会线性提升写吞吐,因为写入仍要经过 leader 和 quorum;远距离多数派会直接放大提交延迟。
生产链路还要分别保护客户端通信和成员通信。客户端 TLS 使用 Netty 与安全端口,quorum TLS 另行启用;只配置其中一组不会自动保护另一组:
secureClientPort=2281
serverCnxnFactory=org.apache.zookeeper.server.NettyServerCnxnFactory
ssl.keyStore.location=/run/zookeeper/tls/server.p12
ssl.keyStore.passwordPath=/run/secrets/zk_keystore_password
ssl.trustStore.location=/run/zookeeper/tls/client-ca.p12
ssl.trustStore.passwordPath=/run/secrets/zk_truststore_password
ssl.clientAuth=need
sslQuorum=true
ssl.quorum.keyStore.location=/run/zookeeper/tls/quorum.p12
ssl.quorum.keyStore.passwordPath=/run/secrets/zk_quorum_keystore_password
ssl.quorum.trustStore.location=/run/zookeeper/tls/quorum-ca.p12
ssl.quorum.trustStore.passwordPath=/run/secrets/zk_quorum_truststore_password
ssl.quorum.clientAuth=needJava 客户端连接 2281 时必须设置 zookeeper.client.secure=true、使用 org.apache.zookeeper.ClientCnxnSocketNetty,并提供客户端 keyStore/trustStore。正向验证应同时看到证书主机名匹配、授权路径可读和服务端 quorum TLS 日志;使用不受信证书应在握手阶段失败,使用受信但无 ACL 的身份应返回 NoAuth。存量集群启用 quorum TLS 时,先用 portUnification=true 逐成员滚动,再逐成员启用 sslQuorum,最后确认全部成员走 TLS 后关闭端口统一;每一步都必须保有多数派。
ZAB、读一致性与故障证据
ZooKeeper 的原子广播协议通常称为 ZAB。leader 为写请求生成 zxid,按顺序向 followers 发送 proposal;多数派把 proposal 记录到持久存储并 ACK 后,leader 才发送 COMMIT。新 leader 激活前必须与 quorum 同步并提交 NEW_LEADER,未提交且不在新多数派历史中的尾部提案会被丢弃。
这解释了三个现场现象:
少数派隔离后不能继续提交写入,避免两个分区各自形成有效历史。写延迟受 leader 到多数派最慢必要路径和事务日志 fsync 影响。zxid 同时包含 epoch 与计数,leader 切换后仍能提供全局有序的事务标记。
ZooKeeper 写操作具有线性一致性,但普通读由当前连接的服务器本地响应,可能读到稍旧状态。官方内部文档明确指出读不是 quorum 操作;sync 后读是常见实践,但严格理论上 sync 本身也不等于一次 quorum 写。对“必须确认刚刚全局生效”的安全决策,可通过一个实际 quorum 写入建立顺序屏障,或重新设计协议,不能把 ruok、本地读成功或客户端 SyncConnected 当成强一致证明。
故障定位时优先看状态字段而不是猜测:
客户端:连接状态、协商 session timeout、session ID、重连与过期次数。znode:zxid、version/cversion/aclVersion、ephemeralOwner、数据大小和子节点数。服务端:leader/follower/observer 角色、outstanding requests、watch 数、延迟、连接数、事务日志和快照增长。
ensemble:是否仍有可通信多数派、leader 变化、follower 同步和磁盘 fsync。
四字命令和 AdminServer 只能做受控诊断
从 3.5.3 起,Four Letter Words 必须显式加入 4lw.commands.whitelist;默认只开放 srvr。在前面的本机 Compose 中,可验证:
printf ruok | nc 127.0.0.1 2181
printf srvr | nc 127.0.0.1 2181
printf mntr | nc 127.0.0.1 2181
printf conf | nc 127.0.0.1 2181前三条在白名单内,ruok 预期返回 imok;conf 未在白名单中,预期被拒绝。imok 只说明进程能处理这个命令,不证明 leader 存在、quorum 健康或读写可用。mntr、conf、dump 等还可能暴露内部配置、session 和 watch 信息,因此不能使用 * 白名单,也不能把 2181 暴露到不可信网络。
官方已提示 Four Letter Words 将逐步弃用,新的诊断集成优先使用 AdminServer 的 /commands/* HTTP 接口、JMX 和指标采集。AdminServer 同样要绑定管理网络、配置认证授权并限制来源;把 8080 公开出去并不比开放 4lw 更安全。
快照、事务日志与恢复
ZooKeeper 周期性生成内存数据树快照,增量变化写入事务日志;重启时加载快照并重放后续日志。autopurge.snapRetainCount 保留最近快照及对应日志,最小值为 3;autopurge.purgeInterval 为正数才启用自动清理。清理过于保守会吃满磁盘,过于激进又会缩短人工恢复与取证窗口。
3.9 文档提供经 AdminServer 执行的在线 snapshot/restore,并要求调用者在根路径拥有 ALL 权限,支持 digest、x509 和 IP 认证。快照应从 zxid 最高的在线成员取得:
curl -fsS \
-H 'Authorization: digest root:REPLACE_WITH_ADMIN_PASSWORD' \
'https://zk-admin.example.invalid:8080/commands/snapshot?streaming=true' \
--output zookeeper.snapshot凭证示例不能直接进入共享 shell 历史;实际自动化应从秘密文件或凭证代理注入。恢复用于多数派丢失或创建带种子数据的新集群,所有成员必须使用同一份快照。恢复前阻断客户端流量并保留原 dataDir、dataLogDir,逐成员调用恢复,全部完成后再开放流量:
curl -fsS \
-H 'Authorization: digest root:REPLACE_WITH_ADMIN_PASSWORD' \
-H 'Content-Type: application/octet-stream' \
-X POST \
'https://zk-admin.example.invalid:8080/commands/restore' \
--data-binary '@zookeeper.snapshot'这是高风险流程示意,不应直接在生产执行。3.8 线和更早部署要按对应版本管理员指南采用备份/恢复办法,不能假定 3.9 AdminServer API 可用。无论哪条发行线,恢复验收都要检查 ensemble 成员身份、最高 zxid、关键路径、ACL、临时节点重建、客户端 session 过期处理和 watch 重新注册。只保存一个文件而从未隔离恢复,不能证明备份可用。
容量、成本与长期治理
ZooKeeper 的授权成本不是主要矛盾,成本来自 JVM 与专用资源、低延迟事务日志磁盘、三或五份副本、跨故障域网络、备份存储、升级测试和故障值班。容量模型至少包含 znode 数量与平均大小、写入率、watch 数与扇出、session/连接数、ephemeral 数量、事务日志增长、快照耗时和恢复时间目标。
长期趋势比单次阈值更有价值:
znode 总量、数据大小和 watch 数是否随发布次数单调增长。活跃连接、session 和 ephemeral 数在实例下线后是否回到基线。outstanding requests、平均/最大延迟和 fsync 是否与写峰值同步恶化。
leader 变化和 session expiration 是否突然增多。dataDir/dataLogDir 增长、快照时长和 autopurge 是否按预期工作。ACL 审计中 world:anyone、超级身份和孤儿 chroot 是否持续减少。
团队台账要把 ensemble、server ID、chroot、znode 类型、ACL、session timeout、watch 恢复策略、备份、owner 和退出路径关联起来。升级前同时核对服务端、客户端库、框架封装和配方兼容性,先做混合版本与会话恢复验证,再逐成员滚动;不要同时重启多数派。
退出 ZooKeeper 时先盘点真实路径和消费者,冻结新建节点,迁移持久协调数据,让客户端切换并观察完整业务窗口,再撤销 ACL、关闭 watch、停止 ensemble,最后按保留策略删除备份和资源。能够证明临时节点随 session 正确重建、一次性 watch 不会被当成事件日志、旧 version 会拒绝覆盖、子节点 ACL 逐个生效、多数派与恢复经过隔离演练,才算真正掌握了这套协调工具。
