引言
在分布式系统中,多节点之间达成共识是核心难题。无论是分布式数据库的选主、配置管理的一致性,还是微服务协调服务,都依赖共识算法来保证多副本之间的状态一致性。Raft 算法由 Diego Ongaro 和 John Ousterhout 于 2014 年提出,以"可理解性"为设计目标,相比 Paxos 更易学习和工程实现,已成为 etcd、Consul、TiKV 等系统的核心共识引擎。本文从理论到工程实现,全方位剖析 Raft 算法。
一、分布式共识的问题定义
共识问题(Consensus Problem)的核心是:如何让一组独立的节点就某个值达成一致,即使部分节点可能发生故障。一个正确的共识协议必须满足以下三个属性:
- 终止性(Termination):所有正常节点最终都会做出决定
- 协定性(Agreement):所有正常节点决定的是同一个值
- 有效性(Validity):决定的值必须是由某个节点提出的
Raft 通过"分解问题"的策略,将共识拆分为三个相对独立的子问题:选主(Leader Election)、日志复制(Log Replication)、安全性(Safety),逐一解决。
二、Raft 核心概念
2.1 节点状态机
Raft 中每个节点处于三种状态之一:
- Leader(领导者):处理所有客户端请求,定期发送心跳维持领导权
- Follower(跟随者):被动接收 Leader 的请求,超时后转为 Candidate
- Candidate(候选人):发起选举,获得多数票后成为 Leader
状态转换规则简洁明了:Follower 在 ElectionTimeout 内未收到 Leader 心跳 → Candidate;Candidate 获得多数票 → Leader;发现更高 Term 的 Leader → Follower。
2.2 任期(Term)机制
Raft 将时间划分为不定长的 Term,每个 Term 从一次选举开始。Term 是一个全局递增的逻辑时钟,用于检测过期信息:
- 每个节点持久化存储当前 Term 编号
- 节点通信时携带 Term,遇到更高 Term 时更新自身
- Candidate 或 Leader 发现自己的 Term 过时时,立即降级为 Follower
- 拒绝来自过期 Term 的请求
2.3 日志条目(LogEntry)结构
每个日志条目包含三个关键字段:
- Term:条目被创建时的任期号
- Index:条目在日志中的位置(从 1 开始)
- Command:状态机要执行的具体命令
日志是"仅追加"结构,一旦写入不可修改。这是 Raft 安全性的基石。
三、Leader 选举机制
3.1 选举触发与流程
Follower 维护一个ElectionTimeout定时器(通常150-300ms随机值)。当超时未收到 Leader 心跳或 Candidate 的 AppendEntries 时,该节点:
- Term 加 1
- 状态转为 Candidate
- 投票给自己
- 重置选举定时器
- 并行向所有节点发送 RequestVote RPC
3.2 RequestVote 投票规则
节点收到 RequestVote 请求时,遵循"日志至少一样新"原则决定是否投票:
- 候选人的 Term 必须 ≥ 当前 Term
- 如果 Term 更大,节点会更新自己的 Term 并转为 Follower
- 每个 Term 每个节点最多投一票(投过票则拒绝)
- 候选人的日志必须"至少一样新":比较最后日志条目的 Term 和 Index
3.3 选举结果
Candidate 在三种情况下结束选举:
- 获胜:收到多数节点的选票(包括自己),成为 Leader,立即发送空的 AppendEntries 确立领导权
- 失败:发现更高 Term 的 Leader 或 Candidate,降级为 Follower
- 分裂:多节点同时 Candidate,无人获得多数票。Raft 通过随机 ElectionTimeout 大幅降低分裂概率
四、日志复制机制
4.1 正常流程
Leader 当选后开始服务客户端请求:
- 客户端向 Leader 提交命令
- Leader 将命令追加为新的日志条目
- Leader 并行向所有 Follower 发送 AppendEntries RPC
- 当多数 Follower 确认接收后,Leader 将该条目标记为 Committed
- Leader 通知 Follower 提交并应用到各自状态机
- Leader 返回结果给客户端
4.2 日志一致性保证
Raft 保证两条核心性质:
- 如果两个日志条目有相同的 Index 和 Term,它们存储相同的命令
- 如果两个日志条目有相同的 Index 和 Term,它们之前的所有条目都相同
第二条性质由 AppendEntries 的一致性检查保证:Leader 在 AppendEntries RPC 中携带前一条日志的 Index 和 Term,Follower 检查匹配后才接受新条目。
4.3 日志冲突修复
当 Follower 日志与 Leader 不一致时(如 Leader 崩溃后新 Leader 上任),AppendEntries 一致性检查会失败。Leader 通过"后退重试"策略解决:
- Leader 维护每个 Follower 的 nextIndex(下一个要发送的日志位置)
- 当 AppendEntries 被拒绝时,Leader 将 nextIndex 减 1 重试
- 逐步后退直到找到双方一致的位置
- 从该位置开始覆盖 Follower 的后续日志
优化:Follower 在拒绝响应中携带冲突信息,加速回退。
五、安全性保证
5.1 Election Safety
一个 Term 内最多只能有一个 Leader。因为每个节点每个 Term 只能投一票,不可能有两个 Candidate 同时获得多数票。
5.2 Leader Append-Only
Leader 永远不会覆盖或删除自己日志中的条目,只追加新条目。这保证了如果某个日志条目在某个 Term 被提交,它一定会出现在所有更高 Term 的 Leader 日志中。
5.3 Log Matching Property
如果两个日志包含一个具有相同 Index 和 Term 的条目,则该位置之前的所有日志完全一致。由 AppendEntries 一致性检查递归保证。
5.4 Leader Completeness Property
如果一个日志条目在某个 Term 被提交,它一定存在于所有更高 Term 的 Leader 日志中。这是 Raft 最关键的安全性保证。
此前的 Raft 实现中,新 Leader 当选后需要先提交一个 no-op 空条目才能开始服务,否则可能出现"已提交但未应用"的漏洞。这一细节在工程实现中至关重要。
5.5 State Machine Safety
如果某个节点已将某个 Index 的日志应用到状态机,则其他节点不会在同一 Index 应用不同的日志。这直接由 Leader Completeness 性质推导而来。
六、工程实现关键问题
6.1 持久化状态
崩溃后重启的节点必须恢复到崩溃前的状态。Raft 要求持久化以下三个状态:
- currentTerm:当前 Term 编号
- votedFor:当前 Term 投票给谁的 CandidateId(null 表示未投)
- log[]:所有日志条目
必须在响应 RPC 之前持久化,否则可能出现投两次票或丢失已提交日志的严重错误。
6.2 日志压缩与快照
随着系统运行,日志无限增长会消耗大量内存和恢复时间。Raft 通过快照(Snapshot)解决这个问题:
- 每个节点独立创建快照,仅覆盖已提交的日志
- 快照包含:最后包含的 Index 和 Term、状态机的当前状态
- Leader 通过 InstallSnapshot RPC 向落后太多的 Follower 发送快照
6.3 集群成员变更
生产环境中不可避免需要增减节点。直接切换配置可能导致"脑裂"。Raft 的解决方案是联合共识(Joint Consensus):
- Leader 写入一个同时包含新旧配置的日志条目 C-old,new
- 此阶段决策需要新旧配置各自的多数同意(交集保证安全性)
- 联合共识提交后,写入仅包含新配置的日志 C-new
- C-new 提交后,旧配置相关的节点可以安全关闭
6.4 线性一致性读
Raft 的日志复制只保证了写操作的共识,但读操作也需保证线性一致性。两种方案:
- ReadIndex:Leader 记录当前的 commitIndex,等待状态机至少应用到该 Index 后再返回读结果。心跳确认领导权。
- LeaseRead:基于租约的优化,Leader 在租约有效期内可直接读,无需每次心跳确认。
七、Raft 与 Paxos 对比
| 维度 | Raft | Paxos |
|---|---|---|
| 设计目标 | 可理解性,工程友好 | 理论完备性 |
| 问题分解 | 选主/日志复制/三阶段 | Basic Paxos/Multi-Paxos |
| Leader | 必须存在 Leader | 可无 Leader(柔性 Paxos) |
| 学习难度 | 低,论文可读性高 | 高,久负"难懂"之名 |
| 工程实现 | etcd/TiKV/Consul | Chubby/Spanner/ZooKeeper |
| 性能 | 相近,实际差异取决于实现优化 | 相近,实际差异取决于实现优化 |
八、etcd 中的 Raft 实现解析
etcd 是 CoreOS 开发的分布式 KV store,使用 Raft 实现数据一致性,是 Kubernetes 的后端存储。etcd 的 Raft 实现有这些特色:
- 预投票(PreVote):Candidate 先进行一轮预投票(不递增 Term),避免因网络隔离的节点不断递增 Term 干扰集群
- Leader 转移(Leadership Transfer):主动将 Leader 转移到指定节点,用于滚动升级和均衡
- 批量化与管道化:AppendEntries RPC 批量发送,网络管道化提升吞吐量
- WAL(Write-Ahead Log):基于预写日志的持久化保证崩溃恢复一致性
九、实战:从零实现一个 Mini Raft
以下是 Raft 核心状态机的伪代码结构:
struct RaftNode {
persistent: (currentTerm, votedFor, log[]),
volatile: (commitIndex, lastApplied),
leaderState: (nextIndex[], matchIndex[]),
}
impl RaftNode {
// 选举定时器
fn election_timeout(&mut self) {
self.currentTerm += 1;
self.state = Candidate;
self.votedFor = self.id;
send_request_vote_to_all();
}
// AppendEntries 处理
fn handle_append_entries(&mut self, args: AE_Args) -> AE_Reply {
if args.term < self.currentTerm { return reject(); }
self.state = Follower;
self.currentTerm = args.term;
reset_election_timer();
// 一致性检查
if !self.log.matches(args.prevLogIndex, args.prevLogTerm) {
return reject_with_conflict_info();
}
// 追加新条目,解决冲突
self.log.truncate_and_append(args.entries);
if args.leaderCommit > self.commitIndex {
self.commitIndex = min(args.leaderCommit, self.log.last_index());
}
return success();
}
// 新 Leader 初始化
fn become_leader(&mut self) {
self.state = Leader;
self.nextIndex = vec![self.log.last_index() + 1; peer_count];
self.matchIndex = vec![0; peer_count];
// 提交 no-op 条目确保 Leader Completeness
self.append_entry(noop_command());
// 立即发送心跳
send_append_entries_to_all();
}
}
十、性能优化与生产实践
- 批处理(Batching):将多个客户端请求打包为一个日志条目,减少 RPC 次数
- 管道化(Pipelining):不等前一次 AppendEntries 返回就发送下一条,充分利用带宽
- Learner 节点:只接收日志但不参与投票的节点,用于读扩展和新节点追赶
- CheckQuorum:Leader 定期确认与多数节点的连接,若失去多数则主动降级
- 集群规模:Raft 集群建议 3、5、7 节点。偶数节点不提供更强的容错能力,徒增延迟
总结
Raft 通过"分解问题"和"日志仅追加"两大设计原则,将复杂的共识问题拆解为选主、日志复制、安全性三个可独立理解和实现的模块。相比 Paxos,Raft 在保持同等容错能力和性能的同时,大幅降低了理解和实现的门槛。理解 Raft 不仅有助于掌握分布式系统核心知识,也是正确使用 etcd、Consul、TiKV 等分布式系统的基础。建议读者结合 Raft 论文 和 etcd raft 模块 源码深入学习。

发表评论 取消回复