一、为什么需要共识算法

在分布式系统中,多个节点如何对某个值达成一致,是设计可靠系统的核心难题。Raft 算法由 Diego Ongaro 和 John Ousterhout 在 2014 年提出,以"易于理解"为设计目标,把共识问题分解为三个相对独立的子问题:节点定域、日志写入、故障恢复,允许开发者分别理解和实现每个部分。

与 Paxos 相比,Raft 的优势在于:有清晰的算法描述、磨合的共识心理论,以另一种方式表示同一个算法,便于在校教材中使用和学习。本文将从理论到实战,完整解析 Raft 的核心机制和工程落地细节。

二、节点状态机:三种角色的切换

Raft 中的每个节点在任意时刻处于三种状态之中的一种:

// 节点状态定义
enum NodeState {
    Follower // 追随者:被动响应,不主动发起请求
    Candidate // 候选人:正在写寻领导者职位
    Leader // 领导者:处理所有客户端请求,复制日志
}

状态切换规则:

  • Follower → Candidate:在超时时间内没有收到 Leader 的心跳,升级为 Candidate 并发起选举
  • Candidate → Leader:获得多数节点的票待,弹到领导者
  • Candidate → Follower:发现新的 Leader 或遇高任期号,退回 Follower
  • Leader → Follower:发现更高任期号,立即降级

三、节点观测:领导者选举

节点观测是 Raft 的核心逻辑。每个节点维护一个开发者状态,用于向其他节点请求投票:

struct RequestVoteArgs {
    term: u64,              // 候选人的当前任期号
    candidate_id: u64,      // 候选人 ID
    last_log_index: u64,    // 候选人日志最后条目索引
    last_log_term: u64,     // 候选人日志最后条目任期号
}

struct RequestVoteReply {
    term: u64,                      // 投票者的当前任期号
    vote_granted: bool,             // 是否投票给候选人
    voter_id: u64,                  // 本节点 ID(用于跟踪)
}

选举流程:

  1. 上升任期号,置自身为 Candidate
  2. 向所有其他节点广播 RequestVote
  3. 等待回复:获得多数票(#(n/2)+1)则弹到 Leader
  4. 超时则重新发起选举

安全性保证:每个节点在一个任期号内只能投一票("每个任期号一票" 原则),避免多个候选人同时当选。此外,候选人必须拥有所有已提交的日志条目(通过比较 last_log_term 和 last_log_index),确保新 Leader 不丢失已提交数据。

随机超时避免选票分裂:Raft 使用随机化的选举超时时间(通常 150ms~300ms),极大降低了选票分裂的概率。如果发生分裂没有候选人获得多数票,则超时后重新发起选举,随机性确保各节点不会持续同时发起。

// 随机超时设置(Rust 伪代码)
use rand::Rng;

fn election_timeout() -> Duration {
    let mut rng = rand::thread_rng();
    Duration::from_millis(150 + rng.gen_range(0..150))
}

// 节点主循环
async fn run_follower_loop(&mut self) {
    loop {
        tokio::select! {
            // 收到 Leader 心跳,重置定时器
            Some(append) = self.heartbeat_rx.recv() => {
                self.reset_election_timer();
            }
            // 超时则发起选举
            _ = tokio::time::sleep(self.election_timeout) => {
                self.start_election().await;
            }
        }
    }
}

四、日志写入:复制状态机

Leader 当选后,负责处理所有客户端的请求。其核心流程是将操作日志复制到所有 Follower 节点,当多数节点确认后,该日志被 "提交"(commit),然后应用到状态机。

// 日志条目结构
struct LogEntry {
    term: u64,           // 条目所在任期号
    index: u64,          // 条目在日志中的位置
    command: Vec<u8>,    // 要执行的命令数据
    committed: bool,     // 是否已提交
}

日志复制流程:

  1. 客户端发送命令给 Leader
  2. Leader 将新条目追加到本地日志
  3. Leader 并行向所有 Follower 发送 AppendEntries RPC
  4. Follower 确认接收(返回 success=true)
  5. Leader 等待多数节点确认后,标记该条目为 committed
  6. Leader 应用该条目到状态机,返回结果给客户端
  7. Leader 通知 Follower 提交(下次心跳携带 commit_index)
// AppendEntries RPC
struct AppendEntriesArgs {
    term: u64,              // Leader 任期号
    leader_id: u64,         // Leader ID
    prev_log_index: u64,    // 前一条日志索引
    prev_log_term: u64,     // 前一条日志任期号
    entries: Vec<LogEntry>,// 要追加的日志条目
    leader_commit: u64,     // Leader 的 commit_index
}

// Follower 处理 AppendEntries
fn handle_append_entries(&mut self, args: AppendEntriesArgs) -> AppendEntriesReply {
    // 1. 如果 term < currentTerm,拒绝
    if args.term < self.current_term {
        return AppendEntriesReply { term: self.current_term, success: false };
    }
    
    // 2. 重置选举定时器(确认 Leader 存活)
    self.reset_election_timer();
    
    // 3. 日志匹配检查:prev_log_index 处的 term 必须等于 prev_log_term
    if !self.log.matches(args.prev_log_index, args.prev_log_term) {
        return AppendEntriesReply { term: self.current_term, success: false };
    }
    
    // 4. 追加新条目,处理冲突
    self.log.append_after(args.prev_log_index, args.entries);
    
    // 5. 更新 commit_index
    if args.leader_commit > self.commit_index {
        self.commit_index = args.leader_commit.min(self.log.last_index());
    }
    
    AppendEntriesReply { term: self.current_term, success: true }
}

日志压缩与快照:随着日志持续增长,Raft 支持快照机制。Leader 可以向落后的 Follower 发送 InstallSnapshot RPC,将当前状态机快照传输过去,使 Follower 快速追上进度。

// InstallSnapshot RPC
struct InstallSnapshotArgs {
    term: u64,                      // Leader 任期号
    leader_id: u64,                 // Leader ID
    last_included_index: u64,       // 快照包含的最后条目索引
    last_included_term: u64,        // 快照包含的最后条目任期号
    offset: u64,                    // 数据块在快照中的偏移
    data: Vec<u8>,                  // 快照数据块
    done: bool,                     // 是否是最后一块
}

五、集群成员变更:联合共识

Raft 支持在线添加或移除节点(不中断服务)。为了保证安全性,Raft 使用 "联合共识"(Joint Consensus)策略:

  1. Leader 提交一个包含新旧两种配置的联合配置日志条目(Cold,new)
  2. 在联合配置期间,所有决策需要同时获得 Cold 和 Cold,new 的多数节点同意
  3. Leader 然后提交只包含新配置的日志条目(Cnew)
  4. 新配置生效,旧节点可以安全关闭

这种机制避免了过渡期间两个不相交的多数派同时存在的 "脑裂" 问题。HashiCorp Raft 等实现中对此做了大量工程优化。

六、工程实践:etcd 中的 Raft 实现

etcd 是 CNCF 孵化的分布式 KV 存储,其底层使用 Go 自研的 Raft 实现(raft package),是目前生产级 Raft 实现的代表。

关键优化点:

  • 批量化写入:Leader 不会为每条日志单独发 AppendEntries,而是在一个 BatchWindow 内聚合后批量发送,降低网络开销
  • 流水线复制:Leader 不等上一个 AppendEntries 返回就发送下一个,利用带宽延迟积(BDP)提升吞吐
  • PreVote 机制:正式发起选举前先做一次预投票,避免网络分区节点频繁发起选举导致任期号膨胀
  • CheckQuorum:Leader 定期检测多数派是否可达,如果不可达则主动降级,避免分区两侧各自为政
  • Learner 节点:新加入的节点先作为 Learner 只接收日志但不参与投票,追赶进度后再转为 Voter,避免慢节点拖慢整体
// etcd raft 的 Step 函数核心逻辑(简化)
func (r *raft) Step(m pb.Message) error {
    switch m.Type {
    case pb.MsgHup:
        // 超时,发起选举
        r.campaign(campaignElection)
        
    case pb.MsgBeat:
        // 心跳定时器触发,发送心跳
        r.bcastHeartbeat()
        
    case pb.MsgProp:
        // 客户端提案,追加到本地日志
        r.appendEntry(m.Entries...)
        
    case pb.MsgApp:
        // AppendEntries from leader
        r.tickHeartbeat = r.randomizedTimeout()
        r.leader = m.From
        r.handleAppendEntries(m)
        
    case pb.MsgAppResp:
        // AppendEntries 回复,检查是否多数确认
        if pr.Reject {
            // 日志不匹配,递减 nextIndex 重试
            r.tryDecrTo(pr.From, pr.Index, m.Index)
        } else {
            // 更新 matchIndex,检查 commitIndex 是否推进
            r.updateMatchIndex(pr.From, m.Index)
            r.maybeCommit()
        }
    }
}

七、故障场景推演

场景一:Leader 崩溃

集群从剩余节点中选出新 Leader(通常是最先超时的节点)。新 Leader 的日志至少和多数节点一样 "新",因此不会丢失已提交的条目。但可能有 Leader 已接收但未提交的 "幽灵日志",新 Leader 会用自己同任期的条目覆盖它们。

场景二:网络分区

如果 Leader 被隔离到少数派分区,它无法获得多数节点的 AppendEntries 确认,因此无法提交新日志。多数派分区会选出新的 Leader 继续服务。分区恢复后,旧 Leader 发现更高任期号,主动降级并同步缺失日志。

场景三:节点逐个重启

逐个滚动重启时,每次重启只影响一个节点。集群在多数节点在线的情况下持续服务。被重启的节点重新加入后,通过 InstallSnapshot 或 AppendEntries 同步日志即可恢复。但需要注意:并发重启超过半数节点会导致集群不可用。

八、性能调优参数

参数典型值说明
ElectionTimeout150~300ms随机超时区间,避免选票分裂
HeartbeatInterval10~50msLeader 心跳频率,通常 = ElectionTimeout / 10
MaxSizePerMsg~1MB单次 AppendEntries 最大数据量
SnapshotChunkSize~16MBInstallSnapshot 每次发送的数据块大小
BatchWindowSize10~100ms日志批量发送的聚合时间窗口
MaxInflightMsgs2048~4096流水线模式下最大在途消息数

九、总结

Raft 通过将共识问题解耦为领导者选举、日志复制和安全性三个模块,使得理解和实现都远优于原始的 Paxos 算法。其关键设计哲学包括:

  • 强 Leader 模型:简化逻辑,所有写请求都经过 Leader
  • 日志作为全序广播通道:每条日志有序且幂等
  • 多数派决策:(n/2)+1 的法定人数保证安全性
  • 任期号作为逻辑时钟:term 严格递增,高 term 击败低 term

在工程层面,etcd、TiKV、CockroachDB、 Consul 等系统都基于 Raft 构建,证明了其在生产环境中的可靠性。对于开发者而言,理解 Raft 不仅是掌握一个算法,更是建立分布式系统思维的重要一步。

推荐阅读:

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部