五台服务器里,三台都有同一条日志,是否就可以回复客户端“写入成功”?在 Raft 中,还要看条目产生于哪个任期,以及当前领导者凭什么判断它已经提交。

设 A 在旧任期写入 x,后来重新当选,并把 x 补到了多数节点。另一个节点可能保留着更新任期产生的冲突条目 y,仍有资格在下一轮赢得选举。此时直接提交 x,会把一个允许被覆盖的条目当成不可撤销的结果。

第 11 篇 建立了任期、投票与保存完成后的消息释放边界。本篇在同一个 Go 核心上加入实际日志,解释 AppendEntries 怎样修复分歧,以及“本任期条目获得多数确认”为什么能保护整段前缀。

模型与三个不同的位置

采用固定成员配置、崩溃恢复故障模型和非拜占庭消息行为。消息可以延迟、丢失、重复和乱序,节点不会任意伪造协议内容。多数始终按完整配置计算;五节点需要三个成员。成功确认的持久状态在恢复后仍然存在,是协议安全性的一项前提。

日志条目包含任期和命令,索引从 1 开始。leader 只在自身日志末尾追加当前任期的条目;follower 可以删除与合法领导者冲突的未提交后缀。实验中的命令是字符串,尚未接入应用状态机。

三个位置承担不同职责,不能混用。

位置 含义 能否独自证明客户端操作完成
lastIndex 本地日志最后一个位置,可能含未提交条目 不能
commitIndex 当前节点知道已经提交的连续前缀末端 还需应用到状态机并按 API 返回
lastApplied 已经应用到状态机的位置 还需结合请求去重及回复语义

本篇实现前两项。恢复时日志保留,commitIndex 按基础模型重新从 0 开始;这不表示已经提交的事实被撤销,只表示新进程还没有恢复这份知识。实验的独立观察器跨节点销毁保留提交历史。第 13 篇再接入文件存储与应用恢复,不能拿本篇内存镜像中的 saved 动作当作 fsync 证据。

AppendEntries 先确认前缀,再接续后缀

leader 给每个 follower 发送 AppendEntries,带上自己的任期、前一条日志的索引和任期、待复制的条目,以及已知提交位置。前一条的坐标是本次追加的前置条件:follower 必须先确认它拥有这条日志,才能把后续条目接在相同前缀上。Raft 扩展论文,Figure 2、§5.3

接收方先拒绝过期任期。对于有效的当前任期请求,即使后面的日志前置条件检查失败,同任期的 candidate 也要退出竞选、成为 follower。请求来自更高任期时,更新任期并清除旧投票;这些持久字段变化必须经过保存边界才能回复。

下图回答一次 AppendEntries 何时能成功。实验先验证消息身份、字段和条目形状,避免处理非法高任期输入时先修改任期、随后返回错误而漏掉保存;图中从通过这层输入验证之后开始。

flowchart TD
    R[收到合法形状的 AE] --> T{请求任期过期?}
    T -->|是| X[日志不变<br/>任期若变化,保存后才回复失败]
    T -->|否| F[处理任期与角色]
    F --> P{prev 索引和任期匹配?}
    P -->|否| X
    P -->|是| C[逐条检查传入后缀]
    C --> D{出现同索引不同任期?}
    D -->|是| E[从首次冲突处截断并追加]
    D -->|否| A[追加缺少的条目,保留匹配后缀]
    E --> B[限制提交位置到本次确认末端]
    A --> B
    B --> S[若持久状态变化,先完成保存]
    S --> Y[释放成功回复与提交观察]

假设 follower 已有 [a,b,c,d],收到只覆盖 [a,b] 的迟到批次。a、b 都匹配时,c、d 不能因此被删除。只有传入条目与已有条目在相同索引上出现不同任期,才从首个冲突处截断。请求短不等于后缀冲突。

空的 AppendEntries 也一样。它不携带新条目,却仍然携带 prevLogIndex/prevLogTerm。如果 follower 连指定前缀都没有,就必须返回失败,不能因为“这是心跳”而直接回复成功。否则 leader 会把没有匹配的副本算进确认集合。MIT 教学说明,Incorrect RPC handlers

这种前缀检查支撑 Log Matching:在合法执行中,两份日志若有相同索引、相同任期的条目,那么该条目及之前的前缀相同。理由分两部分。同任期只有一个 leader,且它按索引依次生成条目、不重写自己的日志,因此同一坐标不会合法地产生两条不同命令;follower 接受条目之前又检查前驱坐标,逐步把一致性延伸到前缀。

这不是任意数组满足的数学性质。直接构造两份“同索引同任期、命令却不同”的数组,只能说明输入不符合上述生成规则。核心将这种输入视为模型外的损坏,不声称能容忍拜占庭节点。

已知匹配位置,不能用猜测代替

leader 为每个 follower 维护 nextIndexmatchIndex。前者表示下一次尝试从哪里复制,后者表示已经确认匹配到哪里。刚当选时,可以先把 nextIndex 设成本地日志末端加一,再通过拒绝逐步后退;这时 follower 可能实际一条日志都没有,所以 matchIndex 不能随之初始化为日志末端。

本篇采用最小的串行策略:每个 follower 只保留一个有效在途请求。驱动每次触发 Replicate 都生成新请求编号,并退役该成员的旧请求;计时、丢包与重试由驱动显式安排,没有后台定时器或自动广播。退役不会撤回已经发出的包,因此旧成功和旧失败仍可能随后到达。

每个请求记录发送时的任期、目标成员、前驱索引和末端。成功回复只能确认这个末端:

1
2
3
requestEnd = sentPrevIndex + sentEntryCount
matchIndex = max(matchIndex, requestEnd)
nextIndex >= matchIndex + 1

例如 leader 发送到索引 3 的批次,随后又在本地追加索引 4。旧回复到达时,不能使用此刻的 len(log)=4 更新匹配位置,否则尚未发送的条目也会被计入多数。重复成功来自同一个成员,同样只更新这个成员的位置,不能变成多张确认票。

旧失败也不能把已经确认到 4 的进度退回 2。这里用请求编号丢弃已退役回复;失败只调整当前有效请求的尝试位置,而且不会低于 matchIndex+1。收到回复时仍要先处理更高任期,再检查请求是否过时。请求过时不意味着其中携带的更高任期也可以忽略。

多数里的旧条目为什么仍可能被替换

Figure 8 展示了不能直接按多数提交旧任期条目的原因。下面是保留同一机制的四任期缩小实验,所有状态从五个空节点经真实核心事件产生。字母 x、y、z 表示实验命令,x@1 表示 x 产生于 term 1;这里的任期编号与原图不要求相同。

term 1,A 从 A/B/C 获票,随后只把 x 复制到 A/B,两份不足多数。E 通过一条投票请求获知 term 1,下一次超时进入 term 2。此时 C/D/E 都为空,可以选出 E;E 仅在自己追加 y@2。A 收到 E 的 term 2 请求后,虽然因日志比较而拒票,仍然更新任期并退位。

term 3,A 从 A/B/C 获票,随后把旧 x 补到 C。E 收到 A 的竞选请求时也会更新到 term 3,但因为自己的最后日志任期 2 更新而拒票。至此 A/B/C 都有 x@1,E 有 y@2,D 为空。

图中的上下方向表示时间推进;两条分支从同一个 term 3 状态出发,不能把两支发生的动作拼成同一条历史。

flowchart TD
    S[term 3:A/B/C 有 x@1<br/>D 为空,E 有 y@2] --> O[分支一:A 不追加本任期条目]
    S --> N[分支二:A 追加 z@3<br/>并在 A/B/C 完成确认]
    O --> Q[x 有三份,但仍未提交]
    Q --> E[term 4:E 获 C/D/E 选票]
    E --> R[E 将 C 的 x 替换成 y]
    N --> K[提交索引 2<br/>前缀 x 和 z 都提交]
    K --> V[term 4:A/B/C 拒绝 E]
    V --> W[E 只有 D/E 两票,无法当选]

第一支中,A 离线后,E 进入 term 4。C 虽然持有 x,但它最后条目的任期只有 1;E 的最后条目属于 term 2,符合日志新旧比较。因此 C/D/E 可以选出 E。E 随后向 C 复制 y,合法替换未提交的 x。原论文 Figure 8 与 §5.4.2MIT Raft2 教学讨论

第二支中,A 在 term 3 追加 z 到索引 2,并在 A/B/C 获得确认。z 属于当前任期,满足直接提交条件;连续前缀中的 x 随 z 一同提交。随后 E 在 term 4 竞选,A/B/C 的最后日志任期 3 都高于 E 的 2,因而拒票。即使 A 不可达,E 最多取得 D/E 两票,仍不足多数。

正常核心寻找的提交位置 N 因而需要同时满足三个条件:N 超过当前提交位置,多数成员的 matchIndex 至少为 N,且 log[N].term == currentTerm。旧任期条目可以随一个满足条件的后续条目间接提交,但不能删掉最后一个条件后直接按副本数量宣布成功。

后来的领导者为何一定包含已提交条目

Leader Completeness 指的是:某任期已经提交的条目,会存在于所有更高任期领导者的日志中。它把“过去达到多数”连接到“未来任何合法领导者”,比某一时刻检查几份数组相同更强。

先取 term T 的 leader 直接提交的本任期条目 e。假设存在缺少 e 的后来领导者,令 U 是其中最小任期。提交 e 的多数集合与选出 U 的多数集合必定有交点 v。v 接受 e 必须早于它投票给 U;若已经进入 U,v 就会拒绝 T 的旧请求。

从接受 e 到投票 U 之间,v 也不能经合法复制丢掉 e。按 U 的最小性,所有中间领导者都有 e;它们发送的相同前缀不会在 e 处产生冲突截断。只剩下 U 的候选者如何通过 v 的日志检查这个问题。

flowchart TD
    C[term T 提交 e 的多数] --> V[交点 v:先持久接受 e<br/>后来才可能投票 U]
    E[term U 的投票多数] --> V
    V --> L[最早缺 e 的候选者<br/>必须通过 v 的日志新旧检查]
    L --> S[最后任期相同<br/>候选日志至少一样长]
    L --> H[候选最后任期更高:k]
    S --> P[同任期唯一 leader 的顺序日志<br/>加 Log Matching,推出含 e]
    H --> I[T 小于 k 小于 U<br/>k 的 leader 按最小性已经含 e]
    I --> P
    P --> X[与候选者缺 e 的假设矛盾]

如果二者的最后日志任期相同,候选日志必须至少一样长。同任期只有一个 leader,按索引顺序追加。候选日志更长时,也包含 v 的末端条目;再用 Log Matching,推出候选者包含这一段前缀,也就包含 e。只说“最后任期相同,因此整份日志相同”会省掉必要条件。

如果候选者最后日志任期 k 更高,则 T < k < U。上界来自候选者尚未在 U 当选,不可能合法地产生 U 的日志条目。按最小性,k 的领导者已经包含 e,它生成后续条目的前缀也包含 e;候选者得到这些条目时,日志匹配再次迫使它保留 e。两种情况都与“缺少 e”矛盾。Raft 扩展论文,§5.4.3 Safety argument

旧条目的保护再通过前缀推出:如果本任期条目 z 已安全提交,以后的领导者包含 z,也必然包含 z 前面的旧条目 x。证明同时用了投票限制、前缀匹配、唯一领导者和本任期提交,不能只留下“两个多数必相交”。

保存日志与公布结果之间还有一道边界

第 11 篇只保存 Term/Vote。新增日志以后,判断是否需要保存,必须检查整个状态,而不能只比较任期和投票。一名 follower 在同任期追加新条目时,Term/Vote 完全可能不变,但成功回复仍然必须等待日志保存。

核心统一使用 State{HardState, Log},替换旧的独立日志元数据参数。New 从完整镜像恢复;Step 输出需要保存的镜像;适配器成功保存后才调用 AdvancePersisted。这三个入口围绕同一状态,不并列维护两套恢复接口。第 11 篇的运行命令与六组场景保留,日志比较场景改用具有同样末端坐标的合成完整日志,仍只检验比较规则。

下图回答新日志什么时候可以影响外部观察。存储适配器位于同一实验进程内,图中的“保存”是复制恢复镜像,尚未执行磁盘写入。

sequenceDiagram
    participant D as 调度驱动
    participant N as Raft 核心
    participant S as 存储适配器
    D->>N: Step:追加或接收日志
    N-->>D: Persist:完整 Term/Vote/Log
    Note over D,N: 此时不释放回复或提交观察
    D->>S: 保存完整镜像
    S-->>D: 保存成功
    D->>N: AdvancePersisted:匹配完整状态
    N-->>D: 消息与提交观察
    D->>D: 交付消息,记录历史

完成接口按值核对整个镜像,缺日志的部分确认会被拒绝,且不会清掉等待状态。但按值相等只能证明适配器确认了哪份数据,不能证明这些字节真的写到了稳定介质。适配器若谎报保存成功,核心无法通过这一函数识别设备故障。

State.Log、消息条目与观察日志都复制切片,防止调用者通过修改返回值绕过保存边界。本地 leader 新增日志后,也不能在等待保存时把自己计为该条目的持久副本。单节点实验尤其容易暴露这个问题:没有其他节点来推迟提交,提前自计一票会立即形成错误提交。

follower 在成功匹配后更新已知提交位置,还要受到本次确认范围限制。设 end=prevLogIndex+len(entries),新游标为 max(oldCommit, min(leaderCommit,end))。例如本地有四条日志,本次只确认到 2,即使消息携带 leaderCommit=4,也不能仅凭本地长度把未在本次请求中确认的后缀提交。反过来,迟到短请求也不能让已经知道的提交位置从 4 回退到 2。

调试用 Status 可以显示等待保存期间的内部游标;对外可见的实验提交事件,以保存完成后释放的 Observation 为准。应用不能把调试快照当作提交回调。MIT 教学说明的提交上限与响应关联细节

运行有限历史,检查提交事实

核心位于 examples/distributed-systems/raft/,新增驱动位于 raft12/。在 examples/distributed-systems 目录运行:

1
2
3
GOCACHE=/private/tmp/ds-go-cache go run -race ./raft12
GOCACHE=/private/tmp/ds-go-cache go run ./raft12 -scenario figure8 -trace
GOCACHE=/private/tmp/ds-go-cache go vet ./raft ./raft11 ./raft12

独立观察器保存发送时的前缀快照。follower 释放成功回复时,观察器先检查对应镜像已经保存了该前缀;回复交付后,再记录这一成员实际确认的位置。它据此约束 matchIndex 的上界,而不调用核心的多数提交函数。所有持久镜像和已提交条目保留在历史中,重启不会清掉这些证据。

本地已运行的检查包括以下几组,完整输出保存在同名附件。

场景 实际观察
Figure 8 两分支 旧 x 达多数仍未提交;后继 E 可替换它;加入本任期 z 后提交 x/z,E 只能得到两票
销毁与恢复 完整镜像恢复日志,提交游标从 0 开始;新领导者继续复制,旧任期 AE 被拒绝
短批次和冲突 匹配短批次、空心跳保留后缀;本地长度 4、确认末端 2 时只提交到 2;真实冲突后缀被修复
响应与持久化边界 迟到及重复回复不回退或虚增进度;回复只确认发送末端;同任期日志更新也等待保存

另一个命令运行故意错误的驱动提交策略:它在 term 3 根据实际匹配多数宣告旧 x 已提交,省去当前任期限制,正常核心代码保持原规则。

1
2
GOCACHE=/private/tmp/ds-go-cache go run -race ./raft12 \
-scenario figure8 -mutate-old-commit -trace

该命令预期返回非零。当前实际结果检出了 leader-missing-committed,随后检出了 committed-overwrite:E 当选时已经缺少历史宣告提交的 x,后续复制又在 C 上覆盖了它。默认正常运行也包含这项变异校验,并要求检查器确实发现问题。正常 Figure 8 分支则明确断言同一时刻核心的 commitIndex 仍为 0,避免只验证观察器、漏测提交条件本身。

这些是有限调度的本地验证,不是全状态空间证明,也没有提供真实 TCP 集群、掉电恢复、吞吐量或线性一致 API 的观测。稳定领导者要持续推进,还依赖多数可通信、存储能完成、消息和重试最终得到调度;本篇没有把任意异步网络下的终止写成保证。

作者勘误指出,lastApplied 的持久性需要跟随状态机的持久性。作者仓库还记录了成员变更问题和未合入的早期流水线实现问题;这些有各自适用范围,不能混称为基础固定成员复制规则失效。作者 Updates and Errata

两道推导题

题一:回复只确认发出的内容。 五节点中 A 是 term 6 的 leader。A 已持久保存到索引 9,B/C 的匹配位置是 7。A 给 B 发出到索引 8 的批次后,又在本地追加并保存索引 10。B 的成功回复到达时,B 能被计到哪里?假设索引 8 属于 term 6,此时能直接提交到 8 吗?

B 只能计到 8,因为响应绑定的发送末端是 8。A 和 B 共两份,C 只确认到 7,其余成员未提供确认,所以五节点还缺第三份。若用此刻日志长度 10 代替发送末端,不仅位置错误,还可能在其他回复交错时制造虚假多数。

题二:重启清掉了知识,是否也清掉了事实? follower B 已保存并应用到索引 12,之后进程崩溃。恢复映像含完整日志,但本篇核心把 commitIndex 初始化为 0。能否因此允许新领导者覆盖索引 12?能否直接重复应用前 12 条命令?

已经提交的事实不会因本地游标归零失效;合法后继领导者必须拥有这些条目,实验观察器也会拒绝覆盖历史提交前缀。是否重复应用则取决于状态机是否恢复了之前的结果,以及 lastApplied 与状态机采用什么一致保存方式。本篇没有实现这条应用恢复链,不能从日志仍在直接推出“重复执行也没关系”。这正是下一篇持久化、恢复和应用顺序需要解决的问题。

参考资料与核验记录

  • Diego Ongaro、John Ousterhout,In Search of an Understandable Consensus Algorithm (Extended Version),2014-05-20;本篇依据 Figure 2、Figure 8 和 §5.3–5.4。原论文
  • MIT 6.5840,2026 课程 Raft2 讲义,2026-09-19 读取的课程滚动文本,作为日志复制、持久化与提交讨论的教学结构依据。课程讲义
  • Jon Gjengset,Students’ Guide to Raft,2016-03-16;交叉核对空 AE、短批次、提交上限和回复关联,没有复制课程作业代码。教学说明
  • Ongaro 博士论文作者仓库,访问日滚动源文本及勘误;未冒充固定提交的产品实现。共识章节源文勘误

论断、资料与核验边界本地执行与写作检查记录