三成员 etcd 只剩一个成员时,一次写请求等待约三秒后超时。恢复第二个成员,完成新的写屏障,再读取先前请求的键,它已经存在。客户端没有收到成功回复,与系统没有执行该写,是两件不同的事。

分布式系统 21:etcd 的读、事务、Watch 与 Lease

前篇已把读、事务、Watch 与 Lease 的保证拆开。本篇将这些 API 放回协议与实现的完整关系中,对照 Paxos、Zab、Raft、ZooKeeper 和 etcd,并用真实三成员进程故障验证多数派丢失与恢复。协议比较依据原论文;产品固定 ZooKeeper 3.9.6、etcd 3.7.1。三成员实验只运行 etcd,不能把文档对照表当作两个产品都实测过。

协议正确为何仍不足以保证业务正确

Paxos、Zab、Raft 解决复制状态之间如何达成安全顺序。ZooKeeper、etcd 则还包含存储、客户端连接、读写 API、变化通知和运维接口。把五个名字直接放进“谁支持 Watch”的表格,会混淆算法与服务。

协议安全依赖实现保存承诺、任期和日志;产品 API 又决定客户端如何观察结果;业务配方最后还要处理重试、检查点和外部资源。某一层通过证明或实验,不能直接替下一层完成验收。

flowchart TD
    P[协议:哪些值与顺序可确定] --> D[存储适配:承诺与日志跨重启保留]
    D --> A[服务 API:读写与通知的具体保证]
    A --> C[客户端:重试、缓存、完整消费游标]
    C --> B[业务:外部资源校验与副作用]

例如,Raft 日志一致不代表本地 leader 身份足以支持线性化读;Watch 按序发送不代表消费者已原子保存缓存与游标;临时节点删除也不会自动阻止一个暂停后恢复的线程写入外部数据库。这些缺口不是共识算法“不够强”,而是不同层需要维护不同不变量。

多数交点保存了什么

固定三个投票者需要两票。任意两个两票集合有交点,这提供了旧决定传给新轮次的机会。若交点进程遗忘已经作出的承诺,或新轮次忽略它保存的证据,集合相交本身仍不能保证安全。

flowchart TD
    Q1[旧 quorum:A 与 B] --> B[B 同时属于两个 quorum]
    Q2[新 quorum:B 与 C] --> B
    B --> E{跨轮证据仍在吗}
    E -->|在,且新轮遵守恢复规则| S[保留既有决定或必要前缀]
    E -->|遗忘或错误使用| X[多数交集不足以排除冲突]

Paxos 接受者要持久保存 promise 和 accepted;Raft 要保存 term、vote、日志并执行选举限制;Zab 的新 primary 需要恢复必要历史,再开始广播。第 16 篇遗漏持久投票的反例与第 18 篇错误保存同步历史的反例,分别展示了跨重启证据丢失会如何破坏上层安全。

这些协议讨论非拜占庭节点,假设持久介质履行约定。消息可以延迟,节点可以崩溃;安全性要求不能因为怀疑故障就产生冲突决定。进展则需要可通信多数、足够稳定的协调者以及相应调度条件。纯异步环境下,超时无法证明对方永久死亡。原始 Zab 模型还包含 FIFO 连接及迭代隔离假设,不能直接把它替换成任意消息乱序模型。Paxos Made SimpleRaft 扩展论文Zab 原论文

三种恢复约束

协议 必须保持的事实 新协调者不能省略的步骤
Paxos / Multi-Paxos 已 chosen 值在后续 ballot 中不被替换 回收接受状态,逐 slot 按规则选择值,恢复连续可应用前缀
Zab 新 primary 广播前具有必须继承的历史,交付遵守 primary 顺序 发现、同步,再进入广播;暂选 leader 不是恢复完成
Raft Log Matching 与 Leader Completeness 候选日志满足选举限制;新 leader 修复后缀并按本任期提交规则推进

Multi-Paxos 不要求每个进程在每一瞬间只认同一个 proposer。安全性来自跨 ballot 的承诺与选值约束。多 slot 又引入空洞:后面的值 chosen,不意味着前面尚未确定的位置可以直接跳过执行。no-op 也必须经共识确定,不能由状态机自行填造。

Zab 面向 primary-backup 的状态增量广播。新 primary 开始产生新增量之前,要先继承必要前缀。旧 primary 尚未通知 COMMIT 的事务也可能已经被多数接受;恢复不能简单把“客户端没见成功”或“本地未交付”翻译成必须删除。发现与同步决定哪个历史可以延续,论文的 primary order 也不是任意应用因果关系的通用证明。

Raft 用候选日志新旧限制保护已提交历史,leader 通过匹配前缀修复 follower 的冲突尾部。只数旧任期条目有多少份,不足以按 Raft 规则直接推进提交;本任期条目提交后,其之前的连续前缀才能随之确定。commit 与 apply 仍分开,读必须等待相应应用进度。

flowchart TD
    F[更换协调者] --> P[Paxos:回收 promise 与 accepted]
    P --> PS[逐 slot 恢复安全值并补空洞]
    F --> Z[Zab:发现合格历史]
    Z --> ZS[同步 quorum 后进入广播]
    F --> R[Raft:选举限制保护已提交前缀]
    R --> RS[匹配日志并建立本任期提交进展]
    PS --> A[按连续确定前缀应用]
    ZS --> A
    RS --> A

这张图比较的是恢复责任,不是宣称三种协议的报文或证明可以互换。Paxos 安全不自动产生 KV API;Zab 的恢复阶段不等于数据库两阶段提交;Raft 易于讲解也不表示其产品实现不再需要持久化审计。

产品的读路径与状态表示

ZooKeeper 3.9.6 提供树状 znode、数据 version、zxid、session 和顺序节点。etcd 3.7.1 提供键区间、MVCC revision、键 version、Txn 和 Lease。zxid、顺序后缀、Raft index、KV revision 各有范围与用途,不能只因它们都增长就互换。

API 维度 ZooKeeper 3.9.6 etcd 3.7.1
更新 线性化更新、version CAS、multi Txn 比较与 Success/Failure;Failure 也可写
普通读 结合客户端顺序的本地读,可旧 默认线性化;serializable 显式允许本地旧状态
变化观察 一次性或显式 persistent watch,需重新检查状态 revision 历史流,受 compaction 窗口限制
生命周期 session 所属 ephemeral 节点 lease 关联键,成功 KeepAlive 与撤销分开
外部资源 资格检查之后仍需外部 fencing 同样需要;lease ID 不是单调代际

ZooKeeper 的 sync 不能无条件当作一次新的 quorum 读确认。固定版本的 processSync 存在没有待定提议时直接发送响应的路径;需要结合客户端顺序和具体写屏障分析,而不能凭方法名推出线性化读。固定 InternalsLeader.processSync

flowchart TD
    C[客户端读取] --> Z[ZooKeeper 普通读]
    Z --> ZL[本地状态与客户端顺序约束]
    C --> E[etcd 默认 Range]
    E --> EB[线性化读确认与应用等待]
    C --> S[etcd serializable Range]
    S --> SL[成员本地一致状态,可能较旧]

本地读可能旧,不意味着默认失去多数后 ZooKeeper 仍会继续提供读取。其 Read Only Mode 默认关闭;需要服务端显式启用、客户端支持相应模式才讨论少数只读。etcd serializable 的本地路径也不是任意故障下都必能响应的承诺,进程、网络和存储本身仍可能不可用。ZooKeeper Read Only Mode

通知、历史与消费进度

ZooKeeper 默认 Watch 是一次性通知;persistent watch 改变持续注册方式,不把离线期间所有状态变化变成可重放日志。通知后重读状态、重新检查资格,才是配方的一部分。断连期间创建又删除的节点可能没有留下一份能供重连读到的状态。

etcd Watch 提供按 revision 恢复的历史流,但窗口受 compaction 限制。消费者必须完整处理一个修订的事件,再推进游标;压缩后则需要固定 R 的分页 List,整体替换旧缓存,再从 R+1 Watch。注册成功的响应不是“历史全部消费完”的证明。

flowchart TD
    Z[ZooKeeper Watch 通知] --> ZR[重读并重新判断条件]
    ZR --> ZW[按所用模式重新注册或继续观察]
    E[etcd Watch 历史事件] --> B[完整修订处理与消费检查点]
    B --> R[历史仍在:按游标续传]
    B --> C[历史已压缩]
    C --> L[固定 R 完整 List,替换缓存]
    L --> W[Watch 从 R+1]

两者适合的客户端算法因此不同。前者不能直接当作业务事件存档,后者也不自动保证外部副作用 exactly-once。通知次数、事件数量与业务执行次数要分开检查,产品之间的差别不能压缩成“一个弱一致、一个强一致”这样的单标签。

生命周期失效与外部写权限

ZooKeeper session 过期清理临时节点,etcd lease 到期触发关联键撤销,均能帮助服务内部更新资格。客户端何时知道失效、服务端何时完成删除、旧线程何时恢复执行,是不同事件。

sequenceDiagram
    participant A as 旧持有者 A
    participant C as 协调服务
    participant B as 新持有者 B
    participant R as 外部资源
    Note over A: 暂停或失联
    C->>C: session 或 lease 失效并完成清理
    B->>C: 获取新资格与有效代际
    B->>R: 带更高代际写入
    R->>R: 原子保存代际并更新资源
    A->>R: 恢复后携带旧代际写入
    R-->>A: 拒绝旧代际

外部资源只有在已经接受更高代际之后,才能按该记录拒绝旧代际。不能把协调服务的删除时刻当成所有外部系统自动撤权的时刻。第 19 篇的真实 ZooKeeper 配方与外部资源模型已验证这条边界;本篇不新增外部 fencing 实验,也不把 etcd lease ID 直接拿作排序令牌。

真实三成员:3→2→1→2→3

实验使用官方 etcd 3.7.1 的同一个已校验可执行文件,启动三个进程,各自使用独立 data、日志和临时目录。六个 client/peer 端口全部绑定回环。先构造完整集群映射并启动全部成员,再统一等待就绪;如果启动第一成员就等待线性化 Range,会因尚无多数而阻塞。

flowchart TD
    A[3 成员:逐个读回 baseline] --> B[停止实际 leader]
    B --> C[2 成员:新 leader 与新写成功]
    C --> D[再停一个 follower]
    D --> E[1 成员:默认读写未获成功,本地读观察]
    E --> F[同目录恢复 follower]
    F --> G[2 成员:barrier 成功,查询不确定写]
    G --> H[同目录恢复原 leader]
    H --> I[3 成员:身份保持,已确认值追平]

源码为 examples/distributed-systems/etcd22/check.py。2026-09-20 实际运行退出 0。leader 从 Status 识别,而不是假设编号最小者当选;恢复始终复用原数据目录和成员身份,没有 remove/re-add、删除日志或 force-new-cluster

阶段 本轮动作与观察 能得出的结论
初始三成员 baseline 成功,三个成员各自默认读回;初始 leader=node3 基线已确认且三个真实成员可服务
剩余两成员 SIGTERM 停 node3,node2 成为新 leader;新写成功 本调度下失去原 leader 后多数继续推进
剩余一成员 再停 node1;向仍运行的 node2 发唯一 Put、默认 Range 两次约3.001秒 socket 等待超时,无成功回复
单成员本地读 node2 的 serializable Range 返回 baseline 本轮本地路径可响应,不证明普遍可用或现场陈旧
恢复两成员 恢复 node1,barrier 写成功,未知写键存在 没有成功响应不等于没有执行
恢复三成员 恢复 node3,三个 member ID 保持,分别读回全部确认值 原成员从自身目录恢复并追平这些已确认状态

唯一不确定写只发送了一次。就绪循环会重试只读 Status 与 Range,并保存每次结果;业务 Put 没有隐式重试。客户端 socket timeout 限制一次等待,不是严格端到端墙钟截止保证,表里的耗时属于本轮实测。

这不是对停止成员发请求得到 ConnectionRefused 的演示。少数阶段的目标 endpoint 是仍运行的 node2,随后 serializable 请求也确实成功;默认路径和本地路径是在同一个存活成员上比较。另一方面,没有另一个多数派在隔离区域继续写入,所以实验并没有观察到“旧读落后于另一侧新写”的网络分区历史。

超时后的值为何可能出现

少数阶段发出的 Put 可能已经进入服务端处理或本地日志,却暂时无法提交。客户端停止等待,不必然撤销已经发生的协议动作;恢复多数之后,请求可能继续产生效果。本轮恢复后查询确实看到了该键,但公开 API 观察没有定位它在每一个时刻的 WAL 或提交状态,不能补写一个未观察到的内部执行时间线。

flowchart TD
    R[请求已发送] --> S[收到成功响应]
    R --> E[收到具体 API 错误]
    R --> T[客户端超时或连接丢失]
    S --> K[按成功 API 保证解释]
    E --> EC[按错误代码与发生阶段分析]
    T --> U[操作结果未知]
    U --> Q[恢复后新屏障与查询]
    Q --> P[存在:本观察点已有对应效果]
    Q --> A[不存在:仅本观察点未见]

若另一轮实验查询结果不存在,也不能立刻宣布请求永远不会迟到执行。单次后验查询只描述它的线性化点;缺少服务端终结或额外顺序约束时,旧请求仍可能在之后产生效果。业务若需要跨重试的确定结果,应使用明确请求身份、去重状态或能查询的操作记录,而不是将网络错误映射成“安全重试任何副作用”。

协议层关于多数派的结论也不能由一次超时证明。固定三票配置在只有一票时无法新形成多数,是模型与算法约束;实验只观察这一实际版本和调度下两次请求没有获得成功回复。安全性、有限轨迹观察与活性条件必须分别陈述。

运维与验证范围

三个进程仍运行在同一台机器,无法代表独立故障域、跨可用区延迟、介质掉电或真实网络隔离。SIGTERM 是主动进程终止,本篇不宣称已经覆盖优雅关闭每个内部步骤或某个精确 fsync 窗口。最终清理向本程序持有的三个进程句柄发送信号,三者退出码均为 -15,cleanup_errors=[];不使用按名称批量杀进程。

启动前、少数阶段、恢复期间的 API 失败均保存请求深拷贝、起止时间、类别及响应正文。完整原始材料见 三成员观测 JSON;命令、退出及检查范围见 验证记录

examples/distributed-systems/ 运行:

1
2
3
4
mkdir -p .build/etcd22/tmp
export TMPDIR="$PWD/.build/etcd22/tmp"
export TMP="$TMPDIR" TEMP="$TMPDIR" PYTHONDONTWRITEBYTECODE=1
python3 -B etcd22/check.py

所有手动生成资料位于仓库 .build/etcd22/,每个子进程的 TMPDIR/TMP/TEMP 也指向自身仓库目录。包获取与校验复用第 20 篇 README;实验没有编译新二进制,也不会删除旧运行目录。

原进程带原数据重新加入,与多数成员永久丢盘后的灾难恢复不同。后者可能重建集群或改变 revision 历史域,旧 Watch 游标与外部 fencing 代际都需重新评估。协议论文不能替代备份恢复演练;历史缺陷复盘也不能直接推定新版本仍有同一漏洞。

两道练习

练习一:三成员 A、B、C 中,A/B 形成旧决定,B/C 开始新一轮。是否仅凭交点 B 就能证明不会产生冲突?如果 B 重启后丢失已投票或已接受记录,各协议还缺少什么?

解析:交点只保证存在传递证据的成员,不保证证据真的保留或被正确使用。Paxos 需要持久承诺和跨轮选值;Raft 需要持久投票/日志与选举限制;Zab 需要正确保存同步历史及 epoch 状态。多数集合的组合性质不能替代这些状态转移规则。

练习二:一次 Put 超时,恢复多数后执行 barrier,再 Range 得到不存在。能否把第一次 Put 判为永久失败?如果重试会触发外部扣款,应怎样改变接口?

解析:该查询仅说明观察点未见目标键;没有进一步的服务端完成顺序证据,不能排除迟到执行。重试接口需要稳定请求身份和可查询结果,并在真正执行扣款的资源处去重或原子记录效果。etcd 内部提交一条资格记录,不会自动把外部扣款纳入同一事务。

后续第 23 篇将转向 Dynamo 风格的无主复制,讨论 quorum、Gossip、反熵与冲突版本,并解释它与固定多数共识的区别:仅有 R+W>N 仍不足以自动获得线性一致性。

参考资料