在分布式元数据服务(如 etcd、Consul)以及分片分布式数据库(如 TiKV、CockroachDB)中,Raft 已经成为复制状态机(Replicated State Machine)的事实标准。虽然 Raft 论文相比 Paxos 更容易理解,但在真实生产网络环境中实现一个工业级 Raft 库,仍然需要处理网络半分区、节点假死、跨任期日志提交以及高性能只读查询等棘手问题。
一、任期(Term)与状态机安全性边界
Raft 将时间切分为任意长度的 任期(Term),每个任期是一个单调递增的逻辑时钟。所有节点在任意时刻处于以下三种角色之一:Follower、Candidate 或 Leader。
为了保证 State Machine Safety(即没有任何两个节点会在同一个日志索引 logIndex 上应用不同的状态机命令),Raft 在选主和提交两个阶段施加了严格约束:
- 选举约束(Election Restriction):Candidate 在发起
RequestVote时携带自己最后一条日志的(lastLogTerm, lastLogIndex)。投票节点只有当候选人的日志至少和自己一样新时才会投赞成票: [ (\text{cand.lastLogTerm} > \text{local.lastLogTerm}) \lor (\text{cand.lastLogTerm} == \text{local.lastLogTerm} \land \text{cand.lastLogIndex} \ge \text{local.lastLogIndex}) ] - 仅提交当前任期日志(Commit Rule):新当选的 Leader 绝不能通过计算历史任期日志的副本数直接将其标记为 Committed,而必须先在当前 Term 写入一条新日志(通常是空操作
No-Op日志),当该当前任期日志被多数派复制后,顺带将之前的历史日志一并提交。
二、为什么需要 Pre-Vote(预投票)机制
在生产机房中,有时会出现“非对称网络分区”:某个 Follower 节点与其他大多数节点网络断开,但仍在不断超时发起选举,每次超时都将自己的 currentTerm 加 1。
当网络恢复的一瞬间,该隔离节点的 Term 可能已经涨到了远高于集群正常 Leader 的数值。一旦它广播 RequestVote,现任 Leader 收到更高 Term 的消息会被迫退位为 Follower,触发整个集群不必要的重新选主和短暂写中断。
// Pre-Vote 阶段检查逻辑示意
func (r *RaftNode) handlePreVote(req PreVoteRequest) PreVoteResponse {
// 1. 如果当前节点仍在选举超时周期内收到有效 Leader 的心跳,直接拒绝 Pre-Vote
if time.Since(r.lastHeartbeatTime) < r.electionTimeout {
return PreVoteResponse{Term: r.currentTerm, VoteGranted: false}
}
// 2. 检查发起者的日志是否至少与本地日志一样新
if !r.isLogUpToDate(req.LastLogTerm, req.LastLogIndex) {
return PreVoteResponse{Term: r.currentTerm, VoteGranted: false}
}
return PreVoteResponse{Term: r.currentTerm, VoteGranted: true}
}开启 Pre-Vote 后,节点在真正自增 Term 之前,先发送不改变任期号的 PreVote 请求。只有拿到了多数派节点的预投票许可,才真正递增 Term 转为 Candidate。
三、线性一致读优化:从 Log Read 到 ReadIndex
如果每次只读请求都作为一条 Raft 日志走完磁盘持久化与多数派复制,读吞吐和延迟都会受到严重制约。但如果直接由 Leader 读取本地内存状态机返回,又可能由于网络分区导致旧 Leader 并不知道新 Leader 已经产生,从而返回脏数据(Stale Read)。
工业界通常采用 ReadIndex 机制实现不写磁盘的线性一致读:
- Leader 收到客户端读请求时,记录当前的
commitIndex作为readIndex; - Leader 向所有 Follower 发送一轮轻量级心跳(无需携带日志负载),确认自己在当前时刻依然是获得多数派承认的合法 Leader;
- 等待本地状态机的
appliedIndex >= readIndex; - 读取本地状态机数据并返回给客户端。
进一步优化:在多个并发只读请求同时到达时,可以将同一个毫秒窗口内的读请求合并共享同一次心跳确认(ReadBatching),将网络 RTT 均摊到数千个并发读请求上。