ZooKeeper:谁提交状态,谁仍有资格执行
A持有临时节点后暂停,B发现节点消失并接管;A恢复时还能不能写数据库?回答之前要分开三个事实:哪次znode更新已经提交、哪个session仍有效、外部数据库接受谁的业务效果。它们不是同一个裁决点。
机制源码固定 release-3.9.2、历史文档固定 3.9.2;新增运行实验固定 ZooKeeper 3.9.6(2026-10-30 官方 current GA,不冒称 latest stable)+ JDK 17。两条证据不拼成同一次调用链,影响结论的会话差异在下文单列。本页按状态身份→复制提交→会话与watch恢复展开;通用授权及资源协议只维护在租约与fencing。协调元数据适合放这里,业务流水、大对象和持久事件日志应使用各自的权威存储。
可复制的客户端与 ACL 配置
原生客户端的最小边界不只是一个地址。connectString 是逗号分隔的服务端列表,可在末尾追加 chroot(如 /study);sessionTimeout 是请求值,服务端会按自身上下界协商;默认 watcher 同时接收连接状态和显式注册的数据事件。构造器返回不代表已经连通,业务入口须等待 SyncConnected,并在 Disconnected 时暂停依赖协调资格的新工作。
CountDownLatch connected = new CountDownLatch(1);
ZooKeeper zk = new ZooKeeper(
"127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183/study",
5_000,
event -> {
if (event.getState() == Watcher.Event.KeeperState.SyncConnected) {
connected.countDown();
}
// Disconnected、Expired、AuthFailed 必须进入不同恢复分支
}
);
if (!connected.await(10, TimeUnit.SECONDS)) {
zk.close();
throw new TimeoutException("ZooKeeper connection timeout");
}这里的 5000 单位为毫秒,但不是“断线5秒后本进程精确收到过期事件”的承诺。连接 deadline、单次操作超时与 session timeout 也不是同一预算。创建响应丢失属于 UNKNOWN,不能把无限重试写成连接恢复。
ACL 附着于单个 znode,不自动继承到子节点。权限位为 CREATE/READ/WRITE/DELETE/ADMIN;CREATE/DELETE裁决父节点上的子节点变更,READ/WRITE/ADMIN检查目标节点。world:anyone适合公开读等明确场景,不应成为生产默认权限。digest 可用于本地演示,但口令会随 addAuthInfo 发给服务端,真实链路还需 TLS;企业 Kerberos 场景应核实 SASL 主体、keytab 和服务端配置,不能把 digest 例子称为 SASL 已验证。
String id = DigestAuthenticationProvider.generateDigest("learner:secret");
List<ACL> acl = new ArrayList<>();
acl.add(new ACL(ZooDefs.Perms.ALL, new Id("digest", id)));
zk.addAuthInfo("digest", "learner:secret".getBytes(StandardCharsets.UTF_8));
zk.create("/secure", data, acl, CreateMode.PERSISTENT);匿名客户端和错误 digest 读取 /secure 应得到 NOAUTH,正确身份可读。本页真实三节点实验已运行这一正反例;它没有验证 SASL/Kerberos、TLS 或多租户目录设计。路径前缀也不是天然租户边界:每个目标节点和父目录权限都须按操作核查。
认证与授权是两段链:addAuthInfo 提交 scheme 与凭据,服务端对应 AuthenticationProvider 验证后把身份加入连接的 authInfo;请求预处理再以该身份集合、目标 ACL 和操作权限执行 checkACL。digest provider 根据 user:password 形成可比对身份,但不会替链路加密;SASL 把身份生命周期交给 Kerberos/JAAS,仍要另配 TLS 保护链路并设计 znode ACL。固定机制来源:ProviderRegistry / AuthenticationProvider 与 PrepRequestProcessor。
方案选择不能只看“能否读到”:隔离教学环境可用 digest 获得低配置成本,但要承担共享口令轮换、分发和审计责任;企业身份可选 SASL/Kerberos,并以 TLS 和受控 keytab 降低凭据暴露,代价是 KDC、时钟、主体映射和故障恢复复杂度。多租户目录还要比较独立根路径逐层 ACL 与独立 ensemble:前者资源成本低但误配爆炸半径共享,后者隔离强却增加容量、升级和灾备责任。验收至少包括匿名、错身份、正确身份、父节点 CREATE/DELETE、目标节点 READ/WRITE、凭据轮换前后和恢复后的重新认证;本票只实跑前三类 digest 读取结果,其余不得标成已验证。
znode版本、创建身份与保证范围
znode组成层级命名空间,保存数据、ACL及Stat元数据。version用于数据CAS,cversion反映子节点变更,czxid/mzxid标识创建与修改事务;不同字段不能混用成同一版本。持久节点需显式删除,临时节点绑定创建session,session失效后删除;临时顺序节点还附带父目录下的递增序列。节点不适合放大对象,数据和watch数量都影响内存、网络及恢复。
同一客户端更新按发送顺序处理,更新具有原子性,成功更新在满足服务保证的故障恢复后不应回滚;但不同客户端同一时刻不保证见到同一快照。本地读可能落后最新写,因此不能把所有读写概括成线性一致。需要跨客户端的新鲜观察时应设计明确同步流程,并区分sync与后续并发写;不能说sync后读就锁住了整个系统。来源:3.9.2 Programmer's Guide。
zxid编码事务顺序,与znode数据version不同。version在一个节点生命周期内递增,节点删掉重建后版本可重新开始;需要防ABA时还要核对创建身份或业务epoch。将version保存到外部后仅用数值相等判断“还是原节点”可能出错。ACL保护访问身份,命名空间路径本身不是租户安全边界,开放world:anyone权限不应成为默认生产配置。
写入从入口到quorum的源码链
leader写路径串联PrepRequestProcessor.pRequest2Txn → ProposalRequestProcessor.processRequest → Leader.propose → SyncRequestProcessor,proposal经follower复制并持久化后ACK;Leader.processAck/tryToCommit判断quorum及提交顺序,之后CommitProcessor → FinalRequestProcessor → DataTree应用并回复。follower接到更新会向leader转发,读通常走本地已提交树。来源:PrepRequestProcessor、ProposalRequestProcessor、Leader。
以下为tryToCommit核心决策的教学改写,不替代重配置全部分支:
if previous proposal is still outstanding: wait
if proposal does not satisfy its quorum verifiers: wait
remove from outstanding; add to applied pipeline
send COMMIT / inform observers
commitProcessor.commit(request)“已到leader内存”“leader落盘”“quorum已确认”“客户端收到结果”仍是不同阶段。动态成员变更时proposal可能需满足对应配置的quorum verifier,不能永久用“任意两个ACK”表达所有情形。三投票节点稳定配置至少两节点构成多数;五节点至少三。增加observer可扩展读,不增加投票多数,不会把两台投票节点分区时变成可写。
PrepRequestProcessor.checkAndIncVersion校验数据版本,两个客户端读到version0后都set version0,只能一个成功;另一个得到BadVersion再读取和重新决策。它是服务端原子条件更新,不是客户端读完再比较。multi可将多个znode操作放在同一原子事务,但不能包含任意SQL或HTTP,也不让整个外部系统变成分布式事务。
原码:前驱与quorum都要满足
原码连续摘录(仅统一公共缩进,截取方法局部):Leader.java,release-3.9.2,L971–L995。
if (outstandingProposals.containsKey(zxid - 1)) {
return false;
}
// in order to be committed, a proposal must be accepted by a quorum.
//
// getting a quorum from all necessary configurations.
if (!p.hasAllQuorums()) {
return false;
}
// commit proposals in order
if (zxid != lastCommitted + 1) {
LOG.warn(
"Commiting zxid 0x{} from {} not first!",
Long.toHexString(zxid),
followerAddr);
LOG.warn("First is 0x{}", Long.toHexString(lastCommitted + 1));
}
outstandingProposals.remove(zxid);
if (p.request != null) {
toBeApplied.add(p);
}processAck收到ACK后调用tryToCommit。第一支检查前一提案仍在outstandingProposals,存在便返回;第二支检查提案记录的所有必要quorum,未满足也返回。这两个条件同时满足才会移除待提交项,并将有请求的提案加入toBeApplied。删去前驱判断会让后续满足自己quorum的提案越过尚未完成的前驱;删去quorum判断则会把未获足够认可的写当提交。
特别注意L983的zxid != lastCommitted + 1只记录LOG.warn,没有return,不能把它误讲为拒绝乱序的保护。摘录后还有重配置及普通提案两条路径:前者commitAndActivate/informAndActivate并可能取消旧leader后续提交资格;普通提案commit(zxid)/inform(p),随后交zk.commitProcessor.commit(p.request)。这些方法的角色分别是传播决定、通知observer和推进本地应用,不是“把对象放入列表便已完成所有客户端响应”。
本段覆盖前驱、quorum与待应用状态,不重写完整Zab恢复证明。p.hasAllQuorums()依据提案涉及的配置核查,动态重配置不能简化为永久固定两个ACK;客户端断线后外部效果是否继续也不由这支判断。
session不是TCP连接:断连与过期的不同恢复
先明确恢复资格,再决定是否重建身份:
| 观察 | 服务端状态可能是什么 | 客户端可做什么 |
|---|---|---|
| Disconnected | 原session可能仍活着,临时节点仍存在 | 暂停依赖授权的新工作,尝试恢复原session,不能直接当作锁已丢失或新建第二候选 |
| 同session重新连接 | 原资格仍有效,断连期间状态可能变化 | 重读业务状态并恢复监听;连接回来不等于缓存仍新鲜 |
| Expired | 当前客户端对象已不可继续;服务端 session 可能已过期,也可能尚未提交关闭 | 新建session与业务身份,重新竞争;不得拿旧凭据继续原资格,也不得据此断言临时节点已删 |
| 旧进程恢复 | 进程仍可运行本地代码或已发网络请求 | 资源端检查授权/业务状态,不能靠临时节点删除强停进程 |
客户端持有sessionId、凭据和协商后的timeout,可以在连接断开后换服务器继续同一session。Disconnected说明当前连接断开,不立即删除临时节点;客户端观察到 Expired 时,该客户端对象与原资格不可继续,需新建session并重建业务身份。服务端会话关闭会清理原临时节点,但客户端事件本身不证明该事务已经提交;不同版本还可能由客户端先在本地判定过期。
3.9.2 与 3.9.6:客户端 Expired 仍不是删除提交证据
3.9.3 起的 ZOOKEEPER-4508 增加客户端本地过期路径:3.9.6 ClientCnxn在长时间未收到服务端数据时,可按协商超时派生的本地界限关闭客户端并发布 Expired;3.9.3 的相关重连回归又由 ZOOKEEPER-4921 在 3.9.4 修复。因此“客户端通常要重连后才知道过期”不能无条件套到所有 3.9.x。
但这项变化没有把客户端事件与服务端删除做成同一原子事实。服务端仍要在自己的过期调度中关闭 session,形成 closeSession 事务并提交,随后 DataTree.killSession 才删除临时节点并触发 watch。故:
- 客户端收到
Expired后必须停止复用旧对象并重建身份; - 不能仅凭收到事件就断言临时节点删除事务已在 quorum 提交;
- B 必须重新读取候选并确认自己最小后才取得协调资格;
- 外部数据库仍以 fencing/业务条件拒绝 A 的迟到写。
这也是为什么本页保留 3.9.2 固定源码作为机制证据,同时用 3.9.6 实验记录当前分支的客户端行为;来源见3.9.6发布说明、ZOOKEEPER-4508与ZOOKEEPER-4921。
源码中关闭会话事务进入DataTree.processTxn的closeSession分支,调用killSession删除对应ephemerals并触发watch。客户端ClientCnxn处理连接、session状态和事件线程,Expired不是普通业务重试异常。来源:DataTree、ClientCnxn。
会话删除是协调状态变化,不代表旧进程已经终止。A持有临时节点后长暂停,session过期、B接管,A恢复仍可能执行受保护业务。断连时立即停止新工作减少风险,最终安全仍依赖fencing/条件状态机。将“临时节点不存在”当“原owner一定死了”会错误处理网络分区。
创建请求响应丢失也有UNKNOWN:节点可能存在。普通确定路径create可重读核对身份;临时顺序create应把GUID写进节点名或数据,重连列目录找原节点。无条件重建可能生成多个候选,从而占住锁或产生重复领导者身份。重试库简化调用不改变这个协议事实。
watch是失效通知:重读与注册必须一起考虑
普通watch触发一次后移除,收到通知应重新读取并注册;持久watch需显式建立与管理,但仍不是每次更新可离线重放的事件日志。通知可以让客户端知道“快照可能过期”,不应被当作完整状态本身。断开期间多次变化可以让应用只观察到最终状态,因此计数、流水与审计不能只依赖watch次数。来源:3.9.2 watches。
正确模式是带watch读取状态、判断业务条件,收到事件后再次读;监听不存在节点用exists。若先读、稍后独立注册监听,中间变化可能丢失。锁配方只watch前驱,前驱若已消失就重新列目录判断;不能在exists返回不存在后继续等删除事件。所有等待者watch父目录会惊群,每次子节点变更唤醒所有竞争者。
安全等待循环的教学协议如下;exists(path, watch)原子地观察不存在与注册未来变化,不能分两条应用步骤拼接:
循环:读取当前状态并同时注册watch
条件已满足:按当前身份尝试条件操作
条件不满足且watch已建立:等待状态失效信号,再循环
前驱不存在:立刻重新列候选,不等待一个已经过去的删除
重连后:重新核对当前状态与监听资格,不按累计通知次数补业务流水对同一个watch,v1→v2触发一次通知,紧接着v2→v3发生在重注册前;稍后重读可能直接看到v3。跳过v2是快照刷新模型允许的行为,依赖每个中间版本执行业务便选错了通知机制。持久watch减少反复注册成本,仍不提供断线期间每次变化的持久回放。
ClientCnxn.EventThread处理watch与连接事件,回调中长时间数据库操作会阻塞后续事件,造成应用误以为通知“延迟丢失”。回调应快速记录失效信号,交给有界任务重新同步;多次通知可合并为一次快照刷新,但要确保刷新期间再来的变更会触发下一轮,不在清标记时丢唤醒。
选主、同步与多数派丢失
leader故障后,ensemble需要选举并让新leader与follower同步已提交历史,再恢复广播。未提交提议不能被随意当作客户端成功;恢复可能通过日志差异同步、截断或快照加日志处理。运维上要观察节点角色、zxid、同步耗时、事务日志与快照,不应看到端口打开就认为节点已可承担写入。
三节点分成2+1,多数侧可推进,少数侧不能独立提交写;连接少数侧的客户端即使进程仍在运行也可能失去服务。失去多数时应暂停依赖强协调的授权,不能人工把两边都改成单节点继续写再合并。会话清理与回调传播还受恢复时序影响,不保证租约精确在某毫秒删除。
恢复集群备份时必须考虑外部已接受的epoch/fence。如果ZK恢复旧快照、外部资源max_fence已经更大,直接从旧值重新授权可能被拒绝或破坏设计。灾备方案应保持令牌单调、引入新集群epoch或执行受控资源切换,不能只有“复制dataDir”的说明。来源:3.9.2管理员指南。
验证:有限模型与真实集群分别证明什么
先运行完整实验脚本,仅需Python标准库:
python3 labs/middleware-senior/reliability_lab.py session --json输入A连接并拥有临时节点。断连→原session重连→再断连时owner均为A;服务端过期事件后owner为空,B新会话创建后owner为B;A恢复用旧session的请求被拒,B不受影响。两次更新2、3仅触发一次普通watch,重读得到3;创建已成功但响应丢失时,盲目create得到2个候选,GUID查找复用只留1个。输出model=finite-session-watch-not-zookeeper-cluster,每个trace事件可纸面重放;它没有模拟真实超时毫秒、quorum、磁盘或回调线程。
历史 T22 公开CLI及断言已运行,结果符合以上预期;模型中的服务端expire是显式输入,不是实际启动ZK并等待过期。以下 zkCli 手工路径仍是未运行的补充步骤,完整模型入口见实验说明;U39 新增的自动化真实集群证据见下一节。
准备隔离3.9.2节点,执行发行包bin/zkCli.sh -server localhost:2181,会话A输入:
create /study-mw root
create /study-mw/value v0
get -s /study-mw/value
set /study-mw/value v1 0
set /study-mw/value stale 0
create -e /study-mw/owner A
get -w /study-mw/value预期第二个set因旧version失败,get返回已提交v1。会话B两次set不同值,观察A普通watch只触发一次,再get -w重新注册;不能用一次watch证明每个中间值都被应用处理。A正常quit会关闭session并删除owner;要观察断连与过期差异,应仅中断A网络/暂停进程,保留session配置,B持续观察owner,记录何时断连、何时服务端删除、何时A得知Expired。
单节点只验证API、版本与watch,不验证quorum。手工将三节点分成2+1、从少数侧发写并观测超时仍未运行;它与下一节“停一节点后多数派可写”不是同一故障注入,不能互相替代。
纸面锁输入候选n1、n2、n3:n1获锁,n2看n1,n3看n2;删除n1仅应促使n2重新检查。若n2未成功注册watch前n1已删,exists返回不存在后立即重读。这个流程能解释惊群、丢唤醒和锁交接,而非只背“临时顺序节点”。
真实三节点实验:API、会话与多数派
U39 新增公开入口使用 ZooKeeper 3.9.6、JDK 17 和 Curator Test 5.7.0 启动三个真实投票节点;Curator 只负责测试生命周期,所有行为调用均走原生 ZooKeeper API:
python3 labs/zookeeper/run.py实验说明、公开入口和源码下载见ZooKeeper 实验 README。
实际断言分四组:
- digest ACL:匿名与错误口令读取返回
NOAUTH,正确身份可读; - 普通 data watch:注册后连续写
v1、v2,只收到一次通知,重读得到v2; - session:停掉当前连接节点后,同一 session 重连到其余节点且临时 owner 仍在;随后停掉全部服务端,3.9.6 客户端先在本地报告
Expired。此时服务端不可达,实验明确不能证明删除已经提交;ensemble 重启后,新客户端再单独观察临时节点最终不可见; - quorum:三投票节点停一台后仍能提交并读取写入;节点恢复后,客户端定向连接该恢复节点读取故障期间的值,再经
sync读取恢复后的新值。
运行器核对固定依赖、五项 Surefire 测试(四组行为加一项越界清理拒绝)和观察行,并把环境、命令、退出状态、日志哈希、实验文件哈希及清理状态写入 u39-2026-10-30.json。它在实际验收时使用 Maven 离线模式,避免静默换版本;TestingCluster.close()和 runner 进程组清理只回收本次随机端口、临时目录与 JVM。
这是真实三节点同进程实验,不是有限模型;但三个节点共享一台主机和一个测试 JVM,停节点不代表跨主机网络分区。它不证明2+1网络隔离的少数侧行为、磁盘损坏、跨地域延迟、生产容量或 SLA,也没有验证 SASL/Kerberos、TLS。客户端本地 Expired 发生在所有服务端停止期间,临时节点删除只在重启后最终观察;实验没有观测 closeSession zxid,不把两者冒充同一提交时点。
架构师变化条件
服务实例从1万变100万、频繁上下线时,应估算znode数、watch数、全量列表响应和session恢复风暴。协调系统应保存小而必要的元数据,数据流放消息日志或数据库。observer扩读仍需考虑leader写与watch推送负载;增加投票节点会改变quorum成本,不是无成本扩容。
跨地域高RTT部署要比较多数派位置、写延迟、失联可用性和灾备责任;需要本地低延迟目录读取不代表必须用同一套强协调承载全部健康状态。若从ZK迁到Nacos,要按数据类型与客户端缓存验证,不把znode事件一比一映射成Nacos实例事件就宣称等价。验收证据包括会话状态、节点版本、quorum可达、watch重建、fence拒绝旧写及恢复时间。
闭卷验收
本页 10 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。