分布式系统 11:Raft 任期、投票与选举安全
三台服务器 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 | |
本次本地运行输出如下:
1 | |
分票场景让 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 | |
这是故意违反存储契约的实现反例,不是 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 篇继续完成。
