分布式系统 12:Raft 日志匹配与本任期提交
五台服务器里,三台都有同一条日志,是否就可以回复客户端“写入成功”?在 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 维护 nextIndex 和 matchIndex。前者表示下一次尝试从哪里复制,后者表示已经确认匹配到哪里。刚当选时,可以先把 nextIndex 设成本地日志末端加一,再通过拒绝逐步后退;这时 follower 可能实际一条日志都没有,所以 matchIndex 不能随之初始化为日志末端。
本篇采用最小的串行策略:每个 follower 只保留一个有效在途请求。驱动每次触发 Replicate 都生成新请求编号,并退役该成员的旧请求;计时、丢包与重试由驱动显式安排,没有后台定时器或自动广播。退役不会撤回已经发出的包,因此旧成功和旧失败仍可能随后到达。
每个请求记录发送时的任期、目标成员、前驱索引和末端。成功回复只能确认这个末端:
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.2、MIT 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 | |
独立观察器保存发送时的前缀快照。follower 释放成功回复时,观察器先检查对应镜像已经保存了该前缀;回复交付后,再记录这一成员实际确认的位置。它据此约束 matchIndex 的上界,而不调用核心的多数提交函数。所有持久镜像和已提交条目保留在历史中,重启不会清掉这些证据。
本地已运行的检查包括以下几组,完整输出保存在同名附件。
| 场景 | 实际观察 |
|---|---|
| Figure 8 两分支 | 旧 x 达多数仍未提交;后继 E 可替换它;加入本任期 z 后提交 x/z,E 只能得到两票 |
| 销毁与恢复 | 完整镜像恢复日志,提交游标从 0 开始;新领导者继续复制,旧任期 AE 被拒绝 |
| 短批次和冲突 | 匹配短批次、空心跳保留后缀;本地长度 4、确认末端 2 时只提交到 2;真实冲突后缀被修复 |
| 响应与持久化边界 | 迟到及重复回复不回退或虚增进度;回复只确认发送末端;同任期日志更新也等待保存 |
另一个命令运行故意错误的驱动提交策略:它在 term 3 根据实际匹配多数宣告旧 x 已提交,省去当前任期限制,正常核心代码保持原规则。
1 | |
该命令预期返回非零。当前实际结果检出了 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 博士论文作者仓库,访问日滚动源文本及勘误;未冒充固定提交的产品实现。共识章节源文、勘误
