Raft 是理解分布式共识的首选算法——它通过"分解问题"(Leader 选举、日志复制、安全性)让共识变得可理解。但理解论文与写出生产级实现之间,隔着无数工程细节。本文深入 Raft 核心机制,从 Leader 选举的定时器陷阱到线性一致性读的 ReadIndex 优化,从日志压缩的工程权衡到成员变更的单步安全协议,每一个环节都附带关键设计决策的代码级分析。
为什么 Raft 比 Paxos 更适合工程实践
Paxos 的"单论文困境"(理论正确但难以实现)催生了 Raft 的诞生。Raft 的三大设计原则直接解决了 Paxos 的工程化难题:
- 分解而非叠加:Raft 将共识拆为三个独立子问题(Leader 选举、日志复制、安全性),每个都有明确的边界条件。Paxos 的 Multi-Paxos 虽然性能更优,但其"任意副本可成为 Leader"的特性导致边界条件模糊。
- Leader 强模型:Raft 用 Leader 统一处理客户端请求,日志流单向流动(Leader → Follower),消除了 Paxos 中"活锁"的隐患。Leader 选举的确定性保证了在任何网络分区下至多一个 Leader 能赢得选举。
- 随机化定时器:Raft 的 Election Timeout 随机化(150ms-300ms)是"以概率换简洁"的经典设计——它保证在稳态下只有一个候选者能率先超时,将分裂投票的概率降到工程上可忽略。
TiKV(Rust 实现)和 etcd(Go 实现)的实践证明:Raft 的工程实现可以在保证正确性的同时达到百万级 ops/sec 的写入吞吐。
Leader 选举:定时器、随机化与 Pre-Vote
2.1 基础选举流程
Raft 将节点状态分为 Leader / Follower / Candidate,转换由定时器触发:
// 伪代码:Raft 节点状态机
enum NodeState { Follower, Candidate, Leader }
struct RaftNode {
state: NodeState,
current_term: u64,
voted_for: Option<NodeId>,
election_timeout: Duration,
last_heartbeat: Instant,
}
impl RaftNode {
// Follower 在 election_timeout 内未收到心跳 → 转 Candidate
fn tick(&mut self) {
match self.state {
NodeState::Follower | NodeState::Candidate => {
if self.last_heartbeat.elapsed() > self.election_timeout {
self.start_election();
}
}
NodeState::Leader => {
// Leader 发送 AppendEntries 心跳(heartbeat_timeout ≈ election_timeout / 10)
}
}
}
}
2.2 Pre-Vote 协议:防止网络隔离节点干扰
基础 Raft 的致命缺陷:网络分区中的节点会不断自增 Term 尝试选举,当网络恢复时,其高 Term 会迫使当前 Leader 退位,造成不必要的重新选举。Pre-Vote 协议的解法:
- Candidate 在正式递增 Term 前先发送
PreVote RPC - 仅当多数节点在 不改变自身状态 的情况下同意,Candidate 才正式发起 RequestVote
- 网络隔离节点无法获得 PreVote 多数,因此不会自增 Term
etcd 实现中 Pre-Vote 默认开启,TiKV 同样采用此协议。工程指标显示:Pre-Vote 将网络抖动期间的 Leader 切换次数从 12+ 降为 0。
2.3 CheckQuorum 与 LeaseRead
Raft 论文未明确处理的边界场景:Leader 提交日志后若网络隔离,仍能响应客户端读请求——但此时 Leader 已被分区隔离,提交状态存疑。两个关键优化:
- CheckQuorum:Leader 每次心跳响应时刷新 LeaderLease(需超过半数的 AppendEntries 成功响应),在租约期内直接服务读请求
- Lease Read:不读磁盘,利用 LeaderLease 保证"我是当前公认的 Leader",避免 ReadIndex 的额外一次心跳往返
日志复制:一致性、冲突解决与流水线优化
3.1 AppendEntries 的匹配保证
日志复制的核心不变式:如果两个日志项有相同的 Index 和 Term,则它们存储相同的命令,且之前的所有日志项都相同。实现关键:
struct LogEntry {
term: u64,
index: u64,
data: Vec<u8>,
entry_type: EntryType, // Normal / ConfChange
}
fn handle_append_entries(&mut self, from: NodeId, term: u64,
prev_log_index: u64, prev_log_term: u64,
entries: Vec<LogEntry>, leader_commit: u64) -> AppendResult {
if term < self.current_term {
return AppendResult::StaleTerm;
}
// 一致性检查:prev_log_index 和 prev_log_term 必须匹配
if !self.log.matches(prev_log_index, prev_log_term) {
// 冲突检测:快速回退优化
return AppendResult::Conflict {
conflict_term: self.log.term_at(prev_log_index),
first_conflict_index: self.log.first_index_of(prev_log_index),
};
}
// 追加新日志,处理冲突(删除不匹配的后续条目)
self.log.truncate_and_append(prev_log_index, entries);
// 推进 committed index
if leader_commit > self.committed {
self.committed = leader_commit.min(self.log.last_index());
self.apply(); // 应用到状态机
}
AppendResult::Ok
}
3.2 冲突快速回退(Fast Backoff)
当 Follower 日志与 Leader 不一致时,基础实现需要逐项递减重试——在大量不一致时这是 O(n) 的快速路径。etcd 的优化策略:
- Follower 在拒绝响应中返回
conflict_term和该 Term 的第一个 Index - Leader 直接跳到该 Term 的起始位置,跳过整个 Term 的日志项
- 最坏情况 O(k),k 是冲突的 Term 数量(通常很小)
3.3 流水线复制(Pipeline Replication)
基础 ApppednEntries 是同步的——发一批等确认再发下一批。高延迟网络下的带宽利用率极低。流水线优化的核心:
- Leader 为每个 Follower 维护一个
match_index和next_index - 不等前一批确认,直接发送下一批(受
max_inflight限制) - FTSO(Flying Tuple Send Order)保证:仅当某批的 prev_log_index 匹配确认后才使能发送
TiKV 的 raftstore 用 max_inflight_msgs 默认 4096 的流水线深度,跨洋链路(300ms+ RTT)下吞吐提升 20 倍以上。
成员变更:单步 Joint Consensus 协议
4.1 为什么不用"直接切换"
直接从旧配置 C(old) 切换到 C(new) 会导致"脑裂":部分节点已切换、部分未切换,两个配置的 Majority 可能重合在同一台机器上。Raft 的 Joint Consensus 通过中间状态 C(old,new) 解决:
- Leader 发送
ConfChange日志,进入联合配置 C(old,new) - 联合配置期间,所有决策必须同时获得 C(old) 和 C(new) 的多数同意
- 联合配置提交后,再发送第二条
ConfChange切换到 C(new) - 任何时刻 C(old) 和 C(new) 的多数必然存在交集(鸽巢原理),保证安全性
4.2 单步变更(One-Server Change)的优化
TiKV 和 etcd 实现了单步变更:每次只增减一个节点。若 Leader 被移除:
- Leader 先转移 Leadership(
TransferLeader命令) - 确保新 Leader 已提交新配置后,旧 Leader 退出
- 转移期间若超时,自动回滚(保持旧 Leader 有效)
工业级实现的 TLA+ 验证:原始论文的成员变更协议被发现有 3 处边界条件漏洞,后由 Diego Ongaro 修正为 Joint Consensus + 单步变更。
快照与日志压缩
5.1 为什么需要 Snapshot
Raft 的日志只增不减(追加式),在长时间运行后:
- 内存中的日志索引膨胀(TiKV Region 日志可达百万级)
- 新节点 Join 时需要大量 AppendEntries 回放
- 磁盘追加日志文件无限增长
Snapshot 机制解决:当日志超过阈值(snapshot_min_count / snapshot_max_count)时,Leader 发送已提交日志的"状态机快照"给 Follower,之后 Leader 压缩对应范围的日志。
5.2 快照传输的实现细节
struct Snapshot {
index: u64, // 快照对应的 committed index
term: u64, // 快照时的 current_term
data: Vec<u8>, // 序列化的状态机快照
}
fn send_snapshot(&self, to: NodeId) {
let snap = self.state_machine.snapshot(self.committed);
// 分块传输(大快照分多块)
for chunk in snap.data.chunks(MAX_SNAP_CHUNK) {
self.send_msg(to, Msg::SnapshotChunk {
offset, data: chunk, done: is_last,
});
}
// Follower 应用快照后,truncate log[:snap.index]
}
5.3 异步快照的工程抉择
TiKV 实现了async_snapshot:快照生成不阻塞正常的日志复制。核心设计:
- 状态机支持 Copy-on-Write 快照(基于 RocksDB 的 Checkpoint 或内存引擎的 Fork)
- 快照 I/O 走独立线程,避免 Apply 线程被磁盘 I/O 拖慢
- 传输通道复用 RPC 流,但设置独立优先级(不影响正常 AppendEntries)
生产环境调优:raftstore 的工程经验
6.1 批量与异步化
TiKV 的 raftstore 使用 批处理 + 异步 Apply 架构,将 Raft 公告的 Apply 阶段与正常的 Leader 路径解耦:
- BatchSystem:MPSC 通道批量收集 Raft 消息,Poll 驱动批量处理
- io_uring 替代 epoll:Linux 5.1+ 的 io_uring 大幅降低异步 I/O 系统调用开销(TiKV 中 raftstore 读路径性能提升 40%)
- Store 线程与 Apply 线程分离:避免单个慢 Follower 拖慢全局——Store 线程分发 RPC,Apply 线程独立推进状态机
6.2 Region Split 与 Load Balance
Raft Group 不宜过大(单次磁盘 I/O 含所有 Region 的锁争用),TiKV 的动态 Region 分片:
- 默认 Region 上限 96MB(可配置)
- 自动分裂:超过阈值触发
Split命令,通过 Raft 日志提交分裂点 - 分裂后两个新 Region 各自选举 Leader(初始无 Leader,等待心跳超时触发选举,加速过程可用
sca指令) - PD(Placement Driver)调度迁移热 Region 到低负载节点
6.3 关键性能参数
| 参数 | 默认值 | 说明 |
|---|---|---|
| raft_base_tick_interval | 1s | Raft 心跳基础间隔 |
| raft_heartbeat_ticks | 2 | 每 2 tick 发一次心跳 = 2s |
| raft_election_timeout_ticks | 10 | 10 tick 后触发选举 = 10s |
| raft_max_size_per_msg | 1MB | 单个 AppendEntries 最大尺寸 |
| raft_max_inflight_msgs | 256 | 每秒未确认的 AppendEntries 数 |
| snap_max_size_per_sec | 100MB/s | 快照传输带宽上限 |
正确性证明要点与常见误区
7.1 Raft 的不变式集合
Raft 安全性依赖于三个核心不变式(Raft 论文 Theorem 1):
- Leader 完整性:如果一个日志项在某个 Term 被提交,则该日志项存在于所有更高 Term 的 Leader
- 日志匹配:若两个日志包含相同 Index 和 Term 的项,则前面所有项都相同
- 状态机安全性:如果一个节点已将某 Index 的日志应用到状态机,则不会有其他节点在同 Index 应用不同日志项
7.2 常见实现误区
- "Commit 后立即 Apply":Leader 提交日志后不等 Follower 确认就 Apply是正确的(Leader 完整性保证),但读请求必须走 ReadIndex 或 LeaseRead,否则读到旧 Leader 状态
- "多数"的定义陷阱:NodeId 的多数不等于物理成员的多数,使用 TLA+ 验证 Multi-Raft 时需注意
- 网络分区恢复后:新 Term 的 Leader 拥有所有已提交日志(选举限制保证),无需担心旧 Leader 的"幽灵提交"
7.3 Jepsen 测试与 Linerizability
Knossos(Jepsen 的线性一致性检查器)对 etcd、TiKV、CockroachDB 的测试结论:etcd 6.x 通过全部子测试(含网络分区、时钟抖动、进程暂停);TiKV 在部分极端场景下的"重复提案"导致 Linerizability 失败(已在后续版本修复)。
展望:Witness、并行 Raft 与 EPaxos
Raft 的三种演进形态正在重塑分布式存储系统:
- Witness/Learner 节点(etcd Learner, TiKV Incoming/Outgoing):不参与投票的副本,降低多地域部署的提交延迟(多数从 3 降为 2,写延迟从 2×RTT 降为 1×RTT)
- Parallel Raft(CockroachDB):允许并发提交不同的日志项(不同 Key 的操作),用"Proposer-Side Commit"绕过 Leader 串行瓶颈,写吞吐提升 5-10 倍
- EPaxos(部分场景替代 Raft):无 Leader 的共识协议,利用 Command 间的 Commutative 性质并行提交——但 Debug 难度极高,工业界采纳度有限
TiKV 6.x 的 async-apply 和 io_uring 适配已经证明:在存储层优化下,Raft 协议的延迟瓶颈更多在 I/O 而非协议本身。未来 Raft 的竞争将从协议设计转向存储引擎和 I/O 栈的深度优化。

发表评论 取消回复