三台服务器 A、B、C 同时竞选。A 得到自己和 C 的票,成为领导者;随后 C 重启,忘掉已经投给 A 的记录,又投给 B。B 加上自己的票,也达到了多数。每次计票都没有算错,集群却在同一轮选出了两个领导者。

问题出在投票承诺没有跨越重启。一次选举能否安全,不只取决于票数,还取决于票属于哪一轮、由谁投出,以及已经回复成功的投票是否还能被遗忘。

第 10 篇 将稳定领导者与多槽日志联系起来。本篇开始构建另一条可累计的实现:先完成 Raft 的选举状态转换,下一篇再加入日志复制与提交。本篇的 Go 核心会继续复用;当前尚不能存储客户端命令,也不提供完整 Raft 服务。

任期把局部观察分开

采用一个固定成员配置,成员身份在实验期间不变。进程可能崩溃恢复,消息可能延迟、丢失、重复或乱序;节点不伪造身份、不发送任意错误内容。多数指完整配置中超过一半的成员,三节点需要两票,五节点需要三票,不能因两台暂时不可达就缩小分母。

每个节点保存自己的 currentTerm,即已经知道的最高任期。任期是逻辑轮次,不是由统一时钟切出的时间段。A 已经进入 term 4,隔离中的 B 仍可能只知道 term 2。收到更高任期的请求或响应时,基础算法更新本地任期,并转为 follower;更低任期的请求不能获得该节点在旧任期的支持。Raft 扩展论文,Figure 2、§5.2

节点以 follower 启动。选举超时后,增加自己的任期,转为 candidate,投给自己,再向其他成员请求投票。候选者得到当前任期的多数票后成为 leader;如果又一次超时仍未获胜,就增加任期重新竞选。新一轮重新计票,上一轮收到几张票没有继承价值。

下面只画本篇实现的选举转换。leader 尚不发送心跳,计时器也由实验驱动显式触发;图中的角色和任期足以检查选举安全,却不足以维持一个正常运行的复制服务。

stateDiagram-v2
    state "Follower:跟随者" as F
    state "Candidate:当前轮候选者" as C
    state "Leader:获得本轮多数" as L
    [*] --> F: 从持久映像恢复
    F --> C: 超时,term 加一并记录自投票
    C --> C: 再次超时,新任期重新计票
    C --> L: 持久化完成且获当前任期多数
    C --> F: 收到更高 term 请求或响应
    L --> F: 收到更高 term 请求或响应
    F --> F: 更新更高 term 或处理投票请求

论文还有一条当前核心未实现的路径:候选者收到任期不低于自己的有效 AppendEntries 时退回 follower。这里没有把空消息改名为心跳来替代完整的 AppendEntries 规则;下一篇会在相同核心上补齐这个接口和日志匹配检查。

一张票怎样跨越重启

节点在一个任期内至多投给一个候选身份。votedFor 为空时可以授票,已经投给同一候选时可以重复回复,已经投给其他候选时必须拒绝。重复回复让丢消息后的重试能够继续,但候选者按投票者身份计数,同一节点回复十次仍然只有一票。

这个条件必须与 currentTerm 一起持久化。只保存任期、不保存投票,恰好允许开头 C 的反例;只保存投票、不保存任期,又无法可靠知道记录属于哪一轮。进入更高任期时可以清空旧投票,单纯在同一个任期转为 follower 则不能清空投票。MIT Raft 第二讲的 persistence 部分直接说明了重启与重复投票的关系。

收到 term 8 的 RequestVote,本地原先处于 term 7,需要先接受新任期,再检查候选日志是否足够新。即使日志落后而拒绝授票,也要保留已经获知的 term 8;不能只在成功授票时更新任期。回复中的任期使对方也能发现自己已经落后。

对外可见的顺序比函数名字更重要。节点修改内存字段以后,先让存储层可靠保存新状态,再释放依赖它的回复。竞选请求与自投票计数同样受这个顺序约束。存储写入仅仅开始,或者任务已经排入后台队列,都不等于保存完成。

sequenceDiagram
    participant A as 候选者 A
    participant C as 投票者 C
    participant S as 存储适配器
    A->>C: RequestVote,term=1
    C->>C: 更新 term 与 votedFor=A
    C->>S: 请求保存 HardState
    Note over C: 完成前不发送肯定回复
    S-->>C: 保存完成
    C-->>A: VoteResponse,granted=true
    Note over A: 按 C 的身份计入一票
    C->>C: 崩溃并销毁易失状态
    S-->>C: 恢复 term=1,votedFor=A
    Note over C: 同任期不能再投给 B

图里的存储是一个协议边界。本篇适配器使用内存中的 durable image 表示“允许跨模拟重启保留的值”,不是文件、WAL 或实际 fsync。崩溃动作创建新 Node,只传入这个映像;旧 Node 的角色、票数和未释放消息全部丢弃。第 13 篇才会实现文件保存、错误处理和快照,并验证真实恢复路径。

多数交集证明了什么

设固定配置有 N 个节点,候选者获胜需要的票数为 floor(N/2)+1。假设 term t 中 A、B 都获胜,各自的投票集合为 QA、QB。两集合的大小之和大于 N,所以至少有一个共同成员 C。

A 获胜要求 C 在 term t 投给 A,B 获胜要求 C 在同一 term 投给 B。如果 A、B 是不同节点,这与“一任期至多支持一个候选身份”矛盾。因此,同一任期至多有一个获多数的候选者。持久化投票使这条前提在 C 崩溃恢复后仍成立;按身份去重使计数确实对应成员集合。

这个论证没有要求每轮一定有领导者。五节点中 A、B 各有自己的票,C 投 A、D 投 B、E 未授票,就只有两个两票候选者。安全性保持,进展暂时停止。实验允许这种状态,不能用“每轮必须出现一个 leader”作为安全断言。

证明也没有说任何时刻只能有一个节点自称 leader。假设 A 在 term 1 得到 A/B 的票,随后与另外两台失去联系。B 超时进入 term 2,得到 B/C 的票。A 没有收到新消息,仍保留 term 1 的 leader 角色;B 已成为 term 2 的 leader。同 term 唯一性并未被破坏。MIT Raft 讲义的 election 部分区分了旧领导者仍存活与新选举成立。

sequenceDiagram
    participant A as A
    participant B as B
    participant C as C
    A->>B: term 1 请求投票
    B-->>A: term 1 授票
    Note over A: A/B 多数,成为 leader 1
    Note over A,C: 隔离 A,不再向 A 投递消息
    B->>B: 超时进入 term 2,自投票
    B->>C: term 2 请求投票
    C-->>B: term 2 授票
    Note over B: B/C 多数,成为 leader 2
    Note over A: 仍自认为 leader 1
    B->>A: 恢复投递 term 2 请求
    A->>A: 更新并保存 term 2,退为 follower

该图对应本篇投票调度。它验证不同任期角色可以并存,以及消息恢复后旧节点获知新任期;没有客户端写入和提交过程,因此没有验证旧 leader 能否错误确认写入。要分析确认安全,必须接着检查下一篇的复制与提交规则。

日志新旧不能只比长度

选出唯一领导者还不够。如果获胜者缺少已经提交的历史,随后复制自己的日志仍可能破坏状态机。Raft 在授票条件中增加日志限制:候选日志至少与投票者自己的日志一样新。扩展论文 §5.4.1使用最后条目的任期和索引判断,先比较任期,相同时再比较索引。

这里有两种 term:RequestVote 携带的竞选任期,与候选最后一条日志的任期。提高竞选任期不能把旧日志变新。假设两个节点都在讨论竞选 term 8,下表比较的仍是各自最后条目的元数据。

候选最后条目 (term,index) 投票者最后条目 日志条件 原因
(3,2) (2,9) 满足 最后条目任期更大,长度不是优先项
(2,9) (3,2) 不满足 更长的日志仍可能更旧
(3,4) (3,2) 满足 最后任期相同,再比较索引
(3,2) (3,4) 不满足 相同最后任期下,候选更短
(3,2) (3,2) 满足 允许一样新,并不要求严格更新

空日志在本篇索引约定中用 (0,0) 表示。配套核心目前只读取最后条目的这两个字段;比较用例合成元数据,没有构造完整日志,也不证明每一对元数据都来自合法执行历史。

尤其不能从“最后任期相同”直接推出任意两段数组有相同前缀。Raft 中的前缀性质还依赖 Log Matching:相同索引、相同任期的条目,应对应相同命令并具有相同前缀;这些性质由合法的日志产生与复制路径维护,不能靠数组长度推出来。

Election Safety 与 Leader Completeness 因而要分开。前者排除同任期两个多数领导者;后者要求新领导者包含之前已提交的条目。论文 §5.4.2–5.4.3 的完整论证还使用提交规则和日志匹配,特别是不能把旧任期条目仅因达到多数就直接判为提交。本篇的投票检查只完成这一证明链的一部分。

迟到回复属于原来的竞选

候选者 A 在 term 1 发出请求,随后超时进入 term 2。此时才到达的 term 1 肯定票,不能加到 term 2 的计数器里。否则 A 可能把不同任期的投票拼成一个根本不存在的多数。

核心的 VoteResponse 带有回复方当前 Term,并保留原请求的 CampaignTerm。后者表达请求关联信息,可以由消息字段携带,也可以保存在 RPC 回调上下文中;它不是另一套任期机制。收到回复时先处理更高 Term,然后要求本地仍是 candidate、回复与原请求都属于当前竞选,才按发送者身份记录肯定票。

先处理更高任期这个顺序不能反。一个旧请求得到 term 3 的拒绝,虽然不能贡献选票,却仍然告诉 term 2 的候选者自己已经过期。若代码先因“不是本轮成功响应”就返回,会丢掉这条任期信息。

节点身份也属于计票输入。核心仅接受固定配置内、发往自己的消息,并拒绝从自己发来的外部投票消息;自投票由本地竞选路径产生。网络适配器将来还需要负责身份真实性,当前进程内驱动只建立逻辑成员检查,不提供网络认证。

把持久化完成作为显式接口

累计代码新增 examples/distributed-systems/raft/election.go,实验驱动位于 raft11/main.go。Node 由单个串行事件循环消费,调用者不能并发操作它。Step 处理超时或投票消息,返回需要保存的状态、可发送消息及观察事件。

只要 HardState 改变,Step 当次就只返回保存请求,把依赖该状态的竞选消息、授票回复和观察事件扣留在 Node 内。适配器保存完成后调用 AdvancePersisted,核心核对完成值与待保存值一致,再释放效果。错误的完成值被拒绝,不会解除等待。

等待保存时,新的 Step 返回 ErrPersistPending。驱动必须保留输入,待完成后重新提交;不能把这个错误当作消息已经消费。这个简化选择牺牲存储与处理重叠,换来容易检查的顺序边界。它也说明存储永久不完成时节点会停止进展,而不是继续对外作出无法恢复的投票承诺。

第 12 篇将在这个 Node 中加入 AppendEntries,第 13 篇替换存储适配器,第 15 篇再接真实回环通信与 KV 状态机。当前没有预先实现这些功能,也没有随机选举计时器、心跳、日志追加、读取接口、Pre-Vote 或 CheckQuorum。

有限调度中实际检查哪些执行

在博客仓库根目录执行以下命令。GOCACHE 设到可写临时目录,是因为本次环境限制了默认构建缓存路径,与协议行为无关。

1
2
cd examples/distributed-systems
GOCACHE=/private/tmp/ds-go-cache go run -race ./raft11

本次本地运行输出如下:

1
2
3
4
5
6
7
PASS split schedules=27 one-leader=14 no-leader=13
PASS restart same-term second vote refused
PASS stale old-round=ignored duplicate=deduplicated higher-term=3 role=follower
PASS partition leaders=1@term1,2@term2 heal=old-leader-follower
PASS log-order cases=6 higher-term-denial=persisted
PASS persistence pending-effects=withheld wrong-ack=rejected crash-cuts=2
PASS mutation detected=double-vote,two-leaders

分票场景让 A、B 在五节点配置中各自竞选 term 1,C/D/E 各取投 A、投 B、暂不投之一,共 27 种决定。有 14 种得到一个 leader,13 种没有 leader。这里只枚举这一有限空间,没有穷举消息交错、重启次数或全部任期。

重启场景复现开头的三节点调度,正确适配器恢复 C 的投票后拒绝 B。迟到场景包含来自原请求的真实排队响应、同一响应重复投递和更高任期的拒绝;候选者的当前票数不能被旧消息增加。分区场景不使用网络设备,而是暂不交付 A 的消息。

持久化场景实际检查两个崩溃切点:durable image 尚未更新,以及映像已经更新但保存完成尚未通知 Node、消息尚未释放。两个切点都没有产生对外授票事件,后者恢复时却必须保留投票。已回复后重启的情况由前面的重启场景覆盖;没有执行四种真实磁盘断电实验。

独立历史检查器保留每次授票、已经交付的回复和当选证书,跨模拟重启继续检查。它重新构造投票集合,核对成员身份、重复身份、多数大小,以及一任期是否出现两个不同当选者;不复用 Node 的计票判断。只看最终角色会漏掉先后当选的历史,因而不能替代这个检查。

变异放在实验适配器中:它保存 C 的任期,却故意丢掉 votedFor,然后仍向核心谎报保存成功。C 重启后再次授票,检查器同时找到重复投票和同任期两个当选者。正常全场景运行要求成功抓到此变异;也可以单独运行坏适配器,观察非零退出:

1
GOCACHE=/private/tmp/ds-go-cache go run -race ./raft11 -scenario restart -mutate-drop-vote -trace

这是故意违反存储契约的实现反例,不是 Raft 论文的漏洞。-trace 输出确定性事件顺序;记录中的时间顺序来自调度器,不是跨机器墙上时钟。所有故障都在单进程教学模型中发生,没有发送网络包或杀掉真实服务进程。

本次还执行了 go vet ./raft ./raft11、帮助和非法场景检查。race 检查没有报告数据竞争,但核心本来就是串行消费,不能据此证明未来网络适配器的并发安全。命令、原始输出及环境保存在 验证记录

活性与工程扩展的条件

多数节点还活着不自动意味着选举及时完成。消息可以不断错过当前竞选,候选者也可能反复同时超时。随机化选举超时降低重复分票的概率,但在任意异步调度下不能给出确定的完成期限。Ongaro 博士论文的 Leader election 章节明确讨论其概率与时间条件。本实验用固定事件,不测超时分布,也不报告平均选举时间。

工程实现还要处理隔离节点不断增加任期后重新加入的问题。博士论文 §9.6 的 Pre-Vote先询问是否有机会取得多数,成功后才开始实际竞选,减少旧隔离节点对健康集群的干扰。预投票不应直接当作已授出的正式选票。

固定版本 etcd-io/raft v3.6.0 的 raft.go提供 PreVote 和 CheckQuorum。后者可在一段 electionTimeout 内未观测到活跃多数时使 leader 降级。该实现还对预投票和已知 leader 的租期条件设置了任期处理分支,所以基础算法的“更高 term 就降级”不能不加条件地描述它的所有消息类型。

这些扩展没有在本篇实现或运行。CheckQuorum 也不能单独证明本地读线性一致;读屏障、租约假设和状态机应用位置留到后续篇章。这里核对的是固定标签下的局部源码,并未审计整个 etcd 的 WAL 与服务集成。

两道练习

找出第一次破坏承诺的位置

A、B 在 term 1 自投票,C 给 A 的票已经回复。C 重启后保留 term 1,但 votedFor 为空;随后 C 给 B 授票。哪一个事件第一次使“一任期至多投一人”不再成立?为什么只在 B 当选时检查 leader 数量还不够?

推导:第二次授给不同候选 B 的票,已经直接违反投票不变量,不必等待 B 当选。只看此刻角色可能看不到 A 曾经当选:A 可以已经停止,或者随后转成 follower。历史中的同任期获胜事实不能因角色改变被删除。修复需要保存任期与投票,且在完成保存后才释放肯定回复。

日志较新是否已经证明提交安全

五节点配置中,一个候选者通过了多数成员的日志新旧比较,成功当选。仅凭这个事件,是否已经证明它今后不会覆盖已提交条目?若只能增加一个检查来完成证明,应增加“日志最长”还是“多等一次超时”?

推导:两者都不足够。选举限制需要与合法日志演化、Log Matching 和提交规则一起使用。最后条目任期较大可以使较短日志通过比较;多等一次超时不能改变已提交历史的内容。下一篇会用旧任期条目达到多数但仍不能直接提交的时间线,补上这条证明链。

参考资料与修订边界

主要依据是 Ongaro 与 Ousterhout 的 2014-05-20 扩展论文、Ongaro 的 2014 年博士论文 Consensus: Bridging Theory and Practice,以及 MIT 两份 Raft 讲义。Stanford 博士论文 PDF 地址本次读取失败,改读作者公开的相关 LaTeX 章节;没有声称通读整本 PDF,也不把滚动 master 当作不可变出版物。

作者仓库的 Updates and Errata 包括 lastApplied 与状态机持久性应匹配,以及单节点成员变更方案的修订。后者涉及跨配置多数交集,不能当作本篇固定配置证明的反例。源码扩展、论文修订和故意破坏存储的实验变异,分别按其范围归因。

资料访问日期为 2026-09-19。逐项标题、版本、定位、交叉核对和未验证边界保存在 论断证据记录。本篇交付的是可运行选举核心与有限反例检查,复制日志的安全性将在第 12 篇继续完成。