三个副本 A、B、C 已经能够复制日志。扩容时,把配置直接改成 A、B、C、D、E,看起来只是多加两台机器。但旧配置的多数 A、B 与新配置的多数 C、D、E 没有交集。若两组分别依据各自配置选出 leader,原先依赖多数交集的安全性论证便失去了前提。

读取也有类似的边界。一个副本的本地角色仍是 leader,并不表示它此刻还能代表集群。它可能被隔离,而另一侧已经选出新 leader、完成新的写入。成员变更决定哪些确认构成有效多数,线性化读则要使用这些确认,把一次读连接到实际时间顺序。

第 13 篇 已经接通持久化、快照与恢复。本篇继续扩展同一份 Go 核心,采用论文的 joint consensus 时序,并明确区分论文规则、etcd 的固定版本实现与教学驱动。第 14 篇的固定目录 race 二进制已通过九类本地场景;累计旧篇运行回归仍有缺口,具体范围见实验记录。

多数必须分别计算

稳定配置 C 的多数满足 |Q∩C| > |C|/2。两个 C 的多数必然相交;换成另一个集合 D 后,这个推理不能直接沿用。

joint 配置同时保存旧集合和新集合,要求同一批确认分别满足两边的多数。假设旧集合为 A、B、C,新集合为 C、D、E:A、B、C 三票满足旧侧,却只包含新侧的 C;A、B、C、D 四票才同时满足两边。交叉成员 C 可以在各自集合中各计一次,同一节点的重复消息不能在同一侧重复计票。Raft 扩展论文,2014-05-20,§6、Figure 10–11

flowchart TB
  ACK["同一批 ACK:A、B、C"] --> OLD["旧集合 A B C:3/3"]
  ACK --> NEW["新集合 C D E:1/3"]
  OLD --> O["旧多数成立"]
  NEW --> N["新多数不成立"]
  O --> AND["两侧都成立才有效"]
  N --> AND
  ADD["再收到 D 的 ACK"] --> BOTH["旧侧 3/3,新侧 2/3"]
  BOTH --> YES["联合多数成立"]

把并集 A、B、C、D、E 当成一个五节点配置并取三票,会接受 A、B、C,也会接受 C、D、E。它们各自缺少另一侧多数。相同错误若只修在日志提交函数中,选举或读请求仍可能使用错误的计票规则。因此教学核心让选举、提交、读确认共用双多数谓词,驱动另写计数 oracle 交叉检查。

安全性直觉来自过渡阶段的交集:joint 多数与任何旧多数在旧侧相交,与任何新多数在新侧相交。这个集合性质需要与 Raft 的任期、投票和日志新旧检查配合;它本身不能代替完整协议证明。可用性也更严格:joint 阶段必须同时取得两侧多数,某一侧不足时即使并集仍有很多节点,也不能继续提交。

配置进入日志的时点

论文采用两步转换。leader 先追加 joint(Cold,Cnew),立即按联合配置行动;等 joint 提交后,再追加 final(Cnew),立即按新配置行动。final 提交后,变更才完成。其他节点在把相应配置存入日志时切换,并不等待该配置被状态机应用。作者博士论文,membership/arbitrary.tex,读取于 2026-09-20

flowchart TB
  S["稳定配置 Cold"] --> J["存入 joint:立即双多数"]
  J --> JC["joint 按双多数提交"]
  JC --> F["存入 final:立即新多数"]
  F --> FC["final 按新多数提交"]
  FC --> N["稳定配置 Cnew"]
  J -->|"未提交 joint 被合法截断"| S

存入日志即启用,意味着 active configuration 不能成为一个独立、只增不回退的变量。它必须由快照边界和当前日志后缀推导:追加配置时改变,覆盖未提交配置时回退,安装快照或重启时重新计算。配置中存在 joint 与 final 两个不同阶段,不能只记录一个成员并集。

一个可达的回退过程是:A 在任期 1 提交 no-op@1,随后仅在自己保存 joint@2,尚未取得联合多数便被隔离。B、C 仍按旧配置行动,B 在任期 2 经 B、C 当选并提交 no-op@2。B 的日志同步覆盖 A 的未提交 joint@2 后,A 必须恢复旧配置。这里没有删除已提交配置;若把 joint 提交过再允许同样覆盖,便违反此前已建立的日志安全条件。

接收端也不能简单要求“本地已经知道 joint 提交,才允许收到 final”。一个落后的 follower 可能在一次合法 AppendEntries 中同时收到 joint 与 final。接收端应验证合并后日志的配置链;发出 final 的 leader 才承担先确认 joint 提交的责任。合法请求的前缀不匹配,仍按正常 Raft 规则更新任期和角色,再返回失败。同索引同任期却有不同载荷等结构无效输入,才在本教学接口的状态变更前拒绝。

论文与产品接口的切换时点需要单独阅读。etcd raft v3.6.0 要求使用者在处理已提交配置条目时调用 ApplyConfChange。这与本篇“日志存入即启用”的实现不同;不能把一个方案的提交规则和另一个方案的应用时点拼接使用。raft v3.6.0 doc.go,Usage、Implementation notes

谁能发送消息,谁能计票

已知网络身份与当前投票成员承担不同职责。新节点可以先作为复制目标追赶数据,还没有成为 voter。一个刚从新配置中移除的 leader,也需要继续向新成员复制 final。若消息入口直接拒绝所有“不在本地 active voters 中”的发送者,合法的过渡过程可能被中断。

教学核心固定一个已知身份集合,用它检查消息来源和复制目标;投票资格由日志导出的 active configuration 决定。候选者必须属于自己的 active voters,当选时按自己的配置统计有效票。接收者也不能一律拒绝旧配置之外的候选者。新增成员 D 可能已收到 joint,而旧成员 B 尚未收到。只要 D 日志足够新、任期和授票条件满足,B 仍可授票,提供联合选举需要的旧侧确认。

已知但不在当前投票集合中的节点,在这个有限模型中可以持久化授票并回复;候选者统计 quorum 时过滤无效成员,因此这些回复不能凭空增加有效票。生产实现还需要身份认证、移除节点的生命周期和防任期扰动措施,本地固定身份表没有实现这些功能。

被移除的 leader 在追加 final 后,立即不能把自己算进新多数;但它仍可发送复制请求,直到 final 按新多数提交后退位。不能先退位再期望它完成同一批 final 复制,也不能为了方便让它额外获得一张新配置中的票。Raft 扩展论文,§6

新增成员追赶不足,会使 joint 阶段失去进展能力。因此实际系统常先做不投票的追赶阶段,等新增节点接近现有日志再改变投票集合。本篇允许向已知非投票节点复制,但没有实现自动追赶调度、变更请求排队或自动恢复管理。

快照保留边界配置

快照覆盖到 k 时,配置必须是 k 处的配置。假设 joint@2 已提交,快照压缩到 2,而后缀还有未提交 final@3:快照必须保留 joint 的两侧,恢复时再由 final@3 导出当前新配置。若快照直接保存“现在的新配置”,以后 final@3 被合法覆盖时,就无法恢复正确的 joint 阶段。

flowchart TB
  SNAP["快照边界 k=2:joint ABC / CDE"] --> SUFFIX["后缀 index 3:final CDE,尚未提交"]
  SUFFIX --> ACTIVE["当前 active:稳定 CDE"]
  SUFFIX --> TRUNC["合法日志修复截断 index 3"]
  TRUNC --> BACK["重新导出 active:joint ABC / CDE"]

Snapshot.Configuration 保存完整 old/new 两侧;旧的 Members 字段只作为兼容性元数据,并要求等于两侧并集。配置模式下缺少 Configuration 必须拒绝,不能把仅有的并集猜成稳定配置。第 13 篇固定成员模式仍支持原来的快照格式。

安装快照时,若本地还保留相同 (index,term) 的边界且要采用该快照,配置应与该边界的配置一致,随后保留匹配后缀并重新导出 active。若本地没有可比较的边界,接收端不能从任意序列化数据独立证明远端历史;这里继续依赖非拜占庭节点生成合法快照的模型前提。应用数据也必须与快照索引对应。作者博士论文,membership 与 log compaction 章节

线性化读需要一次新的确认

考虑实际时间顺序:B 成为新 leader,写入 x=27 并返回成功;客户端随后才向隔离的旧 leader A 发起读。A 的本地状态仍为 x=9、角色仍为 leader。如果它直接返回 9,已经违反线性一致性。两个操作不重叠,不能把这次读重新排序到写入之前。

sequenceDiagram
  participant C as 客户端
  participant A as 旧 leader A
  participant B as 新 leader B
  C->>B: 写 x=27
  B-->>C: 已提交并应用,写成功
  C->>A: 随后发起读
  Note over A: 本地 Role=Leader,x=9
  A->>B: 当前读轮次的探测
  B-->>A: 更高任期
  Note over A: 退位并取消读,不能回复 x=9

不把只读请求写入日志时,论文提供了一组屏障。leader 先在本任期提交至少一条日志,以获得当前提交前缀的可靠知识;对新的读请求记录 readIndex=commitIndex,然后发送新一轮 quorum 探测。确认完成后,还要等本地状态机执行到至少 readIndex,才能读取当前状态并返回。作者博士论文,clients/clients.tex,Read-only queries

flowchart TB
  INV["读请求到达"] --> TERM{"本任期已有提交?"}
  TERM -->|"否"| WAIT["提交本任期 no-op 后重试"]
  TERM -->|"是"| INDEX["记录 readIndex;生成独立 round"]
  INDEX --> PROBE["发探测,按当前配置收集新 ACK"]
  PROBE --> READY["ReadReady:只得到协议屏障"]
  READY --> APPLIED{"applied ≥ readIndex?"}
  APPLIED -->|"否"| LAG["等待应用,不返回值"]
  LAG --> APPLIED
  APPLIED -->|"是"| RETURN["读取此时状态并响应"]

这轮 quorum 确认支持的是探测发送时的领导权论证,不能写成“收到所有回复时,它必然仍是唯一 leader”。更高任期的选举可能与网络中的回复交错。读证书通过调用后的确认点与已提交前缀连接实际时间顺序;应用等待则确保所读状态真正包含这个前缀。

某个 follower 返回探测 ACK,也不表示它已经复制到 readIndex,更不表示它已经应用到那个位置。本篇探测只验证任期和轮次。数据由 leader 本地应用状态提供,应用游标另行检查。提交与应用是两个不同的进度,不能用 Status.Commit 替代后者。

迟到 ACK、重复上下文与配置改变

应用层可能用相同字符串重试一次读。如果网络确认也直接用这个字符串关联,旧请求的迟到 ACK 就可能与新请求混淆。本篇把应用 token 与内部 (term,sequence) 分开,每个读重新分配 sequence,每个节点在同一轮只计一次。不复用旧 AppendEntries 成功,也不通过一个重复 context 批量释放其他请求。

sequenceDiagram
  participant L as leader
  participant F as follower
  L->>F: token=q,round=(7,1)
  F-->>L: ACK (7,1),原读完成
  L->>F: token=q,round=(7,2)
  F-->>L: 网络重复 ACK (7,1)
  Note over L: 不计入 round (7,2)
  F-->>L: 新 ACK (7,2)
  Note over L: 只推进新轮次

失去领导权或 active configuration 改变时,核心取消未完成轮次,并发出独立的 reads-cancelled 事件。适配器还要取消已收到 ReadReady、但尚在等待应用的请求。只检查“现在配置是否等于开始时配置”会漏掉中间切换又回退的过程,因此取消是事件,不只是最终字符串比较。

这种取消策略比必要条件更保守。已经形成的读证书并非一失位就自动在理论上作废;本篇选择让尚未响应的读重试,以缩小跨配置和应用等待的论证范围。重启后也丢弃旧读请求,不把临时读轮次写进恢复状态。

etcd raft v3.6.0 的旧只读队列以调用者 context 关联请求。官方 issue #392 给出了重复 context 与迟到确认组合的反例。后续 #397 改用内部索引生成确认上下文,固定 v3.7.0 源码已经采用新结构;release-3.6 的 #398 是文档限制,不能把它说成同样的算法修复。本篇独立轮次设计只覆盖这里的教学路径,不声称复现或修复了产品缺陷。raft #392修订 #397v3.7.0 read_only.go

版本与工程边界

etcd v3.6.0 默认 Range 走线性读路径,服务端在取得读索引后另等应用游标;serializable 选项允许陈旧读。API 契约说明调用者可要求什么,不能证明某个版本在所有执行中均无缺陷。源码、契约与故障报告需要分别核对。v3.6.0 v3_server.gov3.6 API guarantees

CheckQuorum 定期检查活跃节点,不能自动代替某次读调用之后的新确认。leader lease 可以降低读延迟,但需要明确时钟速率、暂停和到期计算等假设;旧进程长时间暂停后恢复,本地“尚未到期”的判断未必能排除另一侧已经选举和写入。本篇没有实现租约,也不把 etcd KV 的 TTL Lease API 等同于领导权租约。

作者勘误中的 2015 年单节点成员变更反例,针对的是另一种变更方案。单次只增删一个节点,并不能自动排除跨任期遗留的未提交配置。作者提出新 leader 先提交本任期条目再进行变更;原邮件也说明当时尚未完成正式证明。它应作为实现边界的提醒,不能改写成“joint consensus 已被这个反例推翻”。作者 errata2015-07-10 原始邮件

反向核验也包含 etcd 的进程暂停陈旧读报告 #20418。关联 PR #21375 虽已合并,作者随后明确更正它没有修复该陈旧读问题。因此文章不以 issue 关闭或 PR 合并作为所有相关版本已经安全的证据。etcd #20418#21375 讨论

本地驱动与验证范围

代码在 examples/distributed-systems/raft14/,继续调用 11–13 的 raft.Node,没有另写一个 Raft。持久状态由驱动保存为内存映像,并在完整保存确认后调用 AdvancePersisted;这能表达保存前后的协议屏障,但不增加第 13 篇已有文件系统实验的证据,也不属于真实进程重启或断电验证。

Entry 仍是可比较的值类型,配置用集中解析的规范字符串表示成员集合,避免累积检查器中的值比较被切片别名破坏。初始 voters 与 known 身份分开保存;动态配置快照要求完整两侧信息。该表示只面向有限教学驱动,没有设计生产序列化格式或配置管理 API。

场景 已执行场景检查的断言
联合多数与完整变更 穷举五节点 ACK 子集;旧侧多数不能提交 joint;移除中的 leader 不能自计票
回滚与新成员竞选 合法新 leader 覆盖未提交 joint 后回到旧配置;尚持旧配置的节点可以给新增候选者授票
读请求 本任期屏障、独立轮次、应用等待与取消分别生效;旧 leader 不能在已完成新写后返回旧值
快照与批量复制 joint 边界加 final 后缀恢复正确;正常 leader 的批量 joint/final 被接受;安装后恢复应用
输入边界 非法初始集合、缺少快照配置、匹配边界配置不一致,在改变任期前拒绝

在仓库内固定构建目录编译后,复用同一个文件运行:

1
2
3
4
5
6
7
cd examples/distributed-systems
mkdir -p .build/tmp
GOTMPDIR="$PWD/.build/tmp" GOCACHE=/private/tmp/ds-go-cache go build -race -o .build/raft14 ./raft14
./.build/raft14
go vet ./raft ./raft11 ./raft12 ./raft13 ./raft14
./.build/raft14 -help
./.build/raft14 -scenario unknown

2026-09-20,固定目录 .build/raft14 的 race 构建通过,九类场景全部通过且退出码为 0;复用该文件检查 -help 得到 0,未知场景与多余位置参数均得到 2。累计五包的编译与 go vet 也通过。

旧篇回归仍有缺口:串行执行从 .build/raft11 开始时,该程序无输出并以 137 退出,原因未独立确认。后续 raft12、raft13 因前一步失败没有执行;遵照用户要求已停止重试,不能将本次结果写成全部累计回归通过。可查看 逐项验证记录论断—证据表。驱动只检查所列有限轨迹,未实现通用线性一致性历史枚举器,不构成全实现证明或 etcd 端到端观察。

两道练习

练习一:joint 的四张票。 旧配置 ABC,新配置 CDE。A、B、D、E 的四票是否足够?如果 C 暂时失联,变更一定无法推进吗?

四票足够:旧侧 A、B 两票,新侧 D、E 两票。joint 需要每侧多数,不要求两侧公共成员必须在线。联合确认分别在两侧建立多数交集,并不要求每次确认都包含公共成员 C。若只剩 A、B、D 三票,新侧只有 D,则不足。

练习二:已经 ReadReady,为什么仍没有值? leader 已记录 readIndex=100,并收到本轮有效多数,但 applied=98。此时收到另一条已提交写使 commit=101,应当返回哪个索引的状态?

至少先应用到 100,再读取实际状态。若读取时已经应用到 101,也可返回包含该写的状态,前提是与整个并发历史相容;readIndex 是最小应用屏障,并非要求精确冻结在 100 的快照。若等待期间按本实现策略收到失位或配置改变事件,则取消尚未响应的读,重新建立屏障。

下一篇将把客户端命令接到 Raft 状态机,补齐容错 KV 的请求去重、回复丢失与读写历史检查。