欢迎访问 内存溢出 —— 独立后端与系统工程技术博客

专注于分布式系统、Linux内核、数据库存储与后端工程实践

Raft 共识算法工程实践:Leader 选举、日志复制与脑裂防护

发表于:|最后更新:|分类:系统架构|阅读:3420 次

在分布式元数据服务(如 etcd、Consul)以及分片分布式数据库(如 TiKV、CockroachDB)中,Raft 已经成为复制状态机(Replicated State Machine)的事实标准。虽然 Raft 论文相比 Paxos 更容易理解,但在真实生产网络环境中实现一个工业级 Raft 库,仍然需要处理网络半分区、节点假死、跨任期日志提交以及高性能只读查询等棘手问题。

一、任期(Term)与状态机安全性边界

Raft 将时间切分为任意长度的 任期(Term),每个任期是一个单调递增的逻辑时钟。所有节点在任意时刻处于以下三种角色之一:Follower、Candidate 或 Leader。

为了保证 State Machine Safety(即没有任何两个节点会在同一个日志索引 logIndex 上应用不同的状态机命令),Raft 在选主和提交两个阶段施加了严格约束:

  1. 选举约束(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}) ]
  2. 仅提交当前任期日志(Commit Rule):新当选的 Leader 绝不能通过计算历史任期日志的副本数直接将其标记为 Committed,而必须先在当前 Term 写入一条新日志(通常是空操作 No-Op 日志),当该当前任期日志被多数派复制后,顺带将之前的历史日志一并提交。

二、为什么需要 Pre-Vote(预投票)机制

在生产机房中,有时会出现“非对称网络分区”:某个 Follower 节点与其他大多数节点网络断开,但仍在不断超时发起选举,每次超时都将自己的 currentTerm 加 1。

当网络恢复的一瞬间,该隔离节点的 Term 可能已经涨到了远高于集群正常 Leader 的数值。一旦它广播 RequestVote,现任 Leader 收到更高 Term 的消息会被迫退位为 Follower,触发整个集群不必要的重新选主和短暂写中断。

go
// 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 机制实现不写磁盘的线性一致读:

  1. Leader 收到客户端读请求时,记录当前的 commitIndex 作为 readIndex;
  2. Leader 向所有 Follower 发送一轮轻量级心跳(无需携带日志负载),确认自己在当前时刻依然是获得多数派承认的合法 Leader;
  3. 等待本地状态机的 appliedIndex >= readIndex;
  4. 读取本地状态机数据并返回给客户端。

进一步优化:在多个并发只读请求同时到达时,可以将同一个毫秒窗口内的读请求合并共享同一次心跳确认(ReadBatching),将网络 RTT 均摊到数千个并发读请求上。