分布式系统 19:ZooKeeper 一致性、锁配方与外部 fencing
一个进程的临时节点已经被删除,它保存在内存中的“持有锁”状态却可能还在。另一个进程获得了新资格,并不能自动取消旧进程发往数据库的请求。协调服务判断资格,客户端保留对资格的认知,外部资源决定是否接受写入;这三处状态并不会同时变化。
第 18 篇 解释了更新顺序和恢复前缀。本篇进一步区分普通读取、顺序锁和外部 fencing 的保证,使用 Apache ZooKeeper 3.9.6 官方服务与客户端验证三个本机场景。课程结构仍参考 MIT ZooKeeper 讲义及 Stanford CS244B 日程,没有照搬课程作业实现。
更新有统一顺序,普通读仍可能陈旧
ZooKeeper 的更新进入复制提交链,普通读取可以由连接到的副本本地完成。客户端 FIFO 和同一 session 的进度约束,不等于任意两个 session 之间的实时顺序。
A 的更新完成以后,通过外部通道通知 B,B 再向落后副本读取,仍可能看到旧值。外部通知建立了应用层的先后关系,却没有让 B 所连接的副本自动推进到 A 的更新位置。如果把这段历史当成完整读写寄存器,它不满足线性一致性的实时顺序要求。Hunt 等,USENIX ATC 2010,§2.3–2.4
sequenceDiagram
participant A as 客户端 A
participant Q as 复制更新链
participant B as 客户端 B
participant F as 落后副本
A->>Q: 写 x=1
Q-->>A: 更新成功
A-->>B: 外部消息:写入完成
B->>F: 普通 getData(x)
F-->>B: 仍可能返回 x=0
Note over B,F: 外部消息不会自动刷新这个副本
固定 3.9.6 的 FinalRequestProcessor 从本地 ZKDatabase 获取数据;follower 上的普通 getData 不会像更新那样转发给 leader。这里的产品行为与 第 14 篇 中按新读轮次确认领导权的 ReadIndex 模型不能混用。FinalRequestProcessor.java、FollowerRequestProcessor.java
sync 的边界不能省略
官方 Guide 建议通过 sync 推进读取位置。在当前 leader 及更新顺序正常的条件下,这种安排有用途;但不能把“sync 后读”直接写成任意故障下的新多数确认。
ZOOKEEPER-1675 给出的反例窗口可用五节点集群说明:旧 leader 和一个旧 follower 仍互相连通,另外三个节点形成新多数,已经选出新主并完成更新,旧侧尚未处理失位。固定 3.9.6 Leader.processSync 在没有 outstanding proposals 时可以直接发送 SYNC;有待处理提议时则挂到相应提议后面。该路径没有为每次 sync 发起新的 quorum 轮次。Leader.java
flowchart TD
subgraph OLD["五节点中的旧侧两节点:尚未处理失位"]
F["旧 follower"] -->|"sync 请求"| L["旧 leader"]
L -->|"无待处理提议时直接 SYNC"| F
end
subgraph NEW["其余三节点:新 leader 加两个投票副本"]
N["新 leader"] --> Q["新更新 x=1 已提交"]
Q --> R["新侧成功响应"]
end
R -."旧侧尚未包含此更新".-> F
这张图是公开反例与源码路径的说明,不是本篇真实集群实验。1675 在核验时仍为 Open/Unresolved、没有修复版本;其旧 affected version 标签也不能单独证明整个 3.9.6 版本的全部行为。结论来自问题记录和固定源码的交叉核对,不能用单机调用 sync 成功来消除这个边界。
确需借写顺序推进时,可以在同一 session 成功完成一次真实更新,再读取。更新必须进入复制提交链;普通 exists、失败的条件检查和心跳都不能替代它。它至少为后续读取提供一个经过成功更新的进度下界,同时增加写开销和结果不确定性的处理成本。
如果讨论这样的复合读取,其调用区间必须包含前置更新与后续读取。不能只截取内部 getData 的区间,声称它单独已成为线性化读:另一个写可能在屏障完成后、getData 调用前完成,而当前副本尚未收到它。一次成功更新也不能让该 session 以后的所有读永久获得新鲜性保证,更不等于跨路径原子快照。
临时顺序锁怎样判断资格
每次逻辑获取生成一个唯一 GUID,在固定父路径下创建 EPHEMERAL_SEQUENTIAL 候选。比较时取顺序后缀;完整名字中含有随机 GUID,整体字典序与创建顺序没有对应关系。
客户端取得孩子列表,找到自己的位置。自己最小时获得这次获取的资格,否则只监听紧邻前驱。事件到达后仍要重新列出和判断,不能把任何 Watch 回调直接当作获取成功。若自己的节点已消失,就已经没有当前候选资格。3.9.6 官方配方源文档,Locks
flowchart TD
A["A:序号 0<br/>当前最小候选"] -->|"B 只 watch A"| B["B:序号 1"]
B -->|"C 只 watch B"| C["C:序号 2"]
A --> D["A 删除后,B 收到通知"]
D --> E["B 重新列出并判断资格"]
C --> W["C 仍等待 B<br/>不能因 A 删除就获资格"]
只 watch 前驱减少了每次正常释放时被唤醒的等待者数量。若所有候选都 watch 父节点孩子变化,一次删除会让许多等待者同时重读。前驱链也不保证系统永远恰好产生一次回调;故障、会话状态事件和重新注册仍可能产生额外处理。
列孩子与注册监听之间有竞态。B 列出 A 为前驱以后,A 可能已经删除;B 随后的 exists(A, watcher) 返回空,就应立即重新列出,不能继续等那个已经发生的删除事件。实验通过一个明确的调用钩子,在 list 后、exists 前删除前驱,实际走到空返回并重新判断,没有使用 sleep 猜测窗口。
普通读可能旧,为什么这里还能判断队列?成功 create 后的同 session 读取至少越过自己的创建。所有编号更小的候选创建已经在此前缀中;某个更小候选不在列表,意味着这个读取所见状态已包含它的删除。尚未看到的删除只会使客户端多等待,不会凭空隐藏仍在前面的合法候选。固定实现的 CommitProcessor 对写后读处理次序也与这一约束对应。原论文 §2.3–2.4、CommitProcessor.java
这段论证依赖同一 session、有效的自身候选、固定父节点、不重建和序号不回绕。它不证明客户端在随后执行外部写时仍有资格。类似队列也可用于选主,但排序最小只产生候选资格;业务还可能需要恢复状态并发布 ready,不能把排到第一直接等同于可服务。
create 回复丢失以后怎样找回节点
创建临时顺序节点时,完整路径由服务端生成。服务器成功创建了节点,客户端却可能因为连接中断拿不到返回路径。直接重试相同的创建前缀,会再生成一个顺序节点;顺序创建本身并不按 GUID 自动去重。
恢复要保留这次逻辑获取的 GUID,并在同 session 下列出孩子,按 GUID 与 ephemeralOwner 找回节点。本篇实现要求恰好找到一个属于当前 session 的匹配节点。没有匹配或出现多个匹配时明确失败,不把不确定状态偷偷改成新的获取,也不擅自删除其他候选。
flowchart TD
C["生成本次获取 GUID"] --> W["发送临时顺序 create"]
W --> S["服务端创建成功<br/>生成完整路径"]
S --> X["成功响应在 TCP 代理处丢失"]
X --> E["客户端收到 ConnectionLoss"]
E --> R["恢复同一 session"]
R --> L["getChildren 按 GUID 查找<br/>再校验 ephemeralOwner"]
L --> O{"恰好一个匹配?"}
O -- 是 --> K["恢复原候选路径"]
O -- 否 --> F["报告缺失或歧义<br/>不能假装恢复成功"]
这次实验比丢弃应用变量更接近实际网络故障。代理在转发目标 create 之前记录 xid,收到匹配 xid、err=0 的完整响应后,丢掉响应并关闭连接。客户端真实异步回调返回 ConnectionLoss。之后放行连接,确认 sessionId 没有改变,恢复函数才通过官方 API 查找节点。
代理使用官方 Jute 解析请求头、回复头和目标 CreateRequest;不读取 CreateResponse 的返回路径。因此恢复逻辑无法从故障注入器获取答案。每条连接的首帧单独按握手透传,不能把握手字段误认成普通 xid。该代理只支持实验所用的本机明文协议和有限帧大小,不是通用 TLS 或认证代理。
正常恢复实际找到一个节点。错误对照随后盲目再调用一次真实 create,孩子数变成两个,恢复函数拒绝歧义。GUID 在这里是逻辑请求的查询标识,不能替代持久客户端状态、任意重启恢复或无限期 exactly-once 承诺。session 过期以后,旧节点不再提供获取资格,新的获取要使用新的逻辑身份。
资源接受新 token 后拒绝旧写
A 创建临时节点并取得资格后,业务线程可能暂停。ZooKeeper 仍能因为心跳超时清理 A 的节点,让 B 排到第一;A 的业务线程随后恢复,仍可能携带旧资格向另一个系统写入。
fencing 把最后一道校验放到受保护资源。在同一锁域中,资源保存已接受 token 的高水位,原子地比较 token 并执行写。资源已经接受 B 的较大 token 后,A 的较小 token 必须被拒绝。Chubby 的 sequencer 设计说明了这类迟到请求为何需要接收方参与校验。Burrows,The Chubby lock service,OSDI 2006,§2.4、§2.6
sequenceDiagram
participant A as 旧持有者 A
participant Z as ZooKeeper
participant B as 新持有者 B
participant R as 外部资源
A->>Z: 获得候选 token 0
Note over A: 业务线程持旧 token 暂停
Z->>Z: A 自然过期,删除候选
Z-->>B: 前驱删除通知
B->>Z: 重查,获得 token 1 资格
Note over R: 尚未见 token 1 时<br/>高水位仍不能自动拒绝 token 0
B->>R: token 1,写 new-owner
R-->>B: 原子更新高水位与数据
Note over A: 放行业务线程
A->>R: token 0,写 stale-late
R-->>A: 拒绝;保留 new-owner
资源必须在提交点完成比较与写入。如果先在 ZooKeeper 检查“还持有锁”,再向数据库写入,两步之间仍有失位窗口。把 token 比较放在另一个未与数据写入原子化的步骤,也会重新留下竞态。
本篇 Resource.write 使用同一个 synchronized 临界区检查高水位和修改数据。无 fence 的对照资源接受迟到的旧写,最终值变成 stale-late;带 fence 的资源拒绝旧 token,保持 new-owner。这是实际执行过的独立内存资源模型,没有声称 ZooKeeper 替数据库完成了校验。
flowchart TD
I["收到同锁域 token 与写操作"] --> A["进入资源原子临界区"]
A --> C{"token 小于<br/>已接受高水位?"}
C -- 是 --> R["拒绝<br/>数据与高水位不变"]
C -- 否 --> W["更新高水位并执行写"]
W --> S["结束同一临界区"]
相等 token 可以允许当前持有者继续写,重复请求去重仍需另外处理。若资源要跨自身故障恢复,高水位必须与业务数据保持一致持久化;内存模型没有验证这一点。fencing 也不是认证机制,不能让任意调用者伪造一个更大的数字越权。
实验明确验证了一个容易被省略的时间窗口:A 已自然过期,B 的 token 尚未到达资源,此时资源仍接受 A 的旧 token。随后 B 提交较高 token,才释放旧线程并验证拒绝。保证从“资源接受新代”开始,不能扩大成“session 过期瞬间外部请求自动失效”。
顺序号只在明确的锁域内比较
实验把同一持续存在父节点下的顺序后缀作为 token。这依赖父节点不删除重建、计数不回绕、ensemble 不替换,且参与者遵守协议。第 17 篇已经核对底层计数是有符号 int;代码拒绝负序号或超出支持范围的名字,没有执行大规模回绕实验。
父路径重建以后,新编号可能从小值重新开始。资源若继续保存旧域的较大高水位,就可能拒绝所有新持有者;反过来随意清空高水位,又可能允许旧请求重新进入。生产方案需要约定域代数和恢复规则,不能仅凭“顺序号递增”省略它们。
czxid 也不能无条件充当任意节点的唯一代号:一个 multi 内多个创建可以共享事务 zxid,ensemble 的恢复与替换还涉及比较域。这里选择受限顺序号,目的是明确展示接收方拒绝旧代的机制,并未交付跨集群生命周期的 token 服务。
三个本机场景的证据
代码位于 examples/distributed-systems/zk19/Zk19.java,复用第 17 篇已有的客户端、事件等待、回调屏障和断连代理。ZooKeeperServer、NIOServerCnxnFactory 和 Java 客户端均来自官方 3.9.6;所有服务和代理绑定 127.0.0.1 随机端口。外部写由同 JVM 的独立 Resource 对象执行,没有伪称独立数据库进程。
前驱链场景实际检查 B/C 的等待对象、A 删除后 C 不误获资格,以及 list/exists 窗口内删除后的重扫。回复丢失场景留下目标 xid、成功错误码和断链证据;自然过期场景由 B 的删除通知与存在性读取确认服务端清理,未调用强制 expire 或主动关闭 A 来制造删除。A 的本地 Expired 通知和服务端清理仍作为不同观察对象。
2026-09-20 实际运行三个场景全部通过,进程退出 0。以下命令把 shell 和 JVM 临时目录显式限定在仓库构建目录;它与已有实际运行命令的差异、验证记录及未完成检查保存在附件中。
从 examples/distributed-systems 执行;官方包的获取、SHA512 校验和已有 helper 的编译见对应 README。
1 | |
事件有界等待、业务线程门闩和显式调用钩子控制必要时序;没有通过 sleep 碰运气。失败清理会尝试关闭已创建客户端、代理线程和服务,每次只创建自身运行目录,不清理其他实验数据。开放 ACL 仅用于独占本机实例,不能直接复制到共享环境。
完整的资料、版本与边界见 论断证据表;实际输出摘录、源码依赖及未验证事项见 验证记录。本次单实例没有复现跨副本陈旧读或 1675 的分区轨迹;内存 Resource 没有故障恢复持久性证明。
两道练习
练习一:获取结果未知后再次创建。 客户端创建 GUID-lock- 顺序节点时收到 ConnectionLoss。它再发相同前缀,拿到一个更大的序号,于是认为第一次创建失败。这个判断哪里有问题?重连后的恢复查询还必须检查什么?
答案要点:第一次可能已经创建成功,只是回复丢失。顺序 create 不按 GUID 自动去重,第二次可以再生成一个节点。恢复应保留同一逻辑 GUID,检查 session 和 ephemeralOwner,并处理零个或多个匹配的歧义;不能仅凭后一次成功就否定前一次。
练习二:过期即拒绝。 A 的临时节点已被清理,B 刚取得 token 1,但还没向资源发送请求。资源当前高水位是 A 的 token 0。它能否只凭自己的高水位拒绝 A 的迟到写?如果先查询 ZooKeeper 再写资源,是否已经解决了问题?
答案要点:高水位尚未推进,不能自动判定 token 0 已失效。查询资格与外部提交之间仍有竞态。资源接受新 token 后,在与数据写入相同的原子边界拒绝较小 token,才能提供这里定义的 fencing 保证;更强即时授权需要额外协议。
第 20 篇转向 etcd 的 Raft 与存储路径,分别追踪 WAL、提交、apply、MVCC 和 revision,避免把日志位置与 API 数据版本混为一谈。
