Raft 共识算法实战:从选主到日志复制与安全性

在分布式系统中,如何让多台机器对”同一件事”达成一致,正是 Raft 共识算法要解决的问题。无论是 etcd、TiDB 还是 Consul,底层都依赖共识算法保证数据不丢、不乱。相比晦涩的 Paxos,Raft 把共识拆成领导者选举、日志复制和安全性三个独立子问题,理解与实现都更直观。

一、为什么需要共识算法

单机数据库靠事务保证一致性,但一旦机器宕机、磁盘损坏,服务就不可用。常见的做法是”一主多从 + 复制”,可主节点挂掉后谁来接管?多个从节点如何就”哪条日志是合法的”达成一致?如果各节点各写各的,就会出现脑裂与数据错乱。共识算法(Consensus)就是让一组不可靠节点,在部分节点失效时仍能对外提供唯一、连续的决策序列。它和 分布式锁、接口幂等 一样,都是构建高可用系统的基石。

Raft 的设计目标是”可理解性”。它把问题拆开,使得每个子问题都能独立推理与测试,这也是它比 Paxos 更流行的原因。

举个最常见的例子:一个分布式 KV 存储要对外提供读写,客户端可能连到任意节点。如果没有共识,节点 A 写入 x=1,节点 B 因为网络延迟还停留在 x=0,客户端就会读到过期甚至互相矛盾的数据。Raft 通过”同一时刻只有一个 Leader 能决定写入顺序、且写入必须被多数派确认”来消灭这种不确定性,这正是 etcd 能当作可靠配置中心、TiDB 能当作强一致数据库的根基。

二、三个角色与任期(Term)

Raft 集群中每个节点在任何时刻都处于三种角色之一:

角色职责数量
Follower(跟随者)被动响应 Leader 与 Candidate 的 RPC,超时未收心跳则发起选举多数
Candidate(候选者)选举超时后自荐,向其他节点拉票选举期间临时出现
Leader(领导者)唯一处理写请求,通过日志复制把命令同步给 Follower同一任期至多 1 个

时间被划分为一个个任期(Term),用连续整数编号。每个 Term 最多有一位 Leader;若选举分裂则 Term 直接递增、本轮无 Leader,进入下一轮。Term 是 Raft 的”逻辑时钟”,用来检测过期信息。正因为任期单调递增,任何携带旧 Term 的消息都会被直接忽略——这从机制上避免了”已经下台的 Leader”继续向集群发号施令,是 Raft 抵御脑裂的关键设计之一。

三、领导者选举:requestVote RPC

Follower 维护一个随机的”选举超时”(通常 150–300ms)。只要在超时内没收到 Leader 的心跳(AppendEntries),就自增 Term、转变为 Candidate,并投自己一票后向全体节点发送 RequestVote。规则很简单:

  • 每个节点在每个 Term 只能投一票,先到先得;
  • 候选人日志必须”至少和自己一样新”(任期更大,或同任期且日志更长),否则拒绝;
  • 拿到多数派(N/2+1)选票即成为 Leader,立刻发心跳阻止他人选举。
// 简化版 RequestVote 处理逻辑
func (n *Node) onRequestVote(args RequestVoteArgs) RequestVoteReply {
    if args.Term < n.currentTerm {
        return RequestVoteReply{Term: n.currentTerm, VoteGranted: false}
    }
    if args.Term > n.currentTerm {
        n.currentTerm = args.Term
        n.state = Follower
        n.votedFor = ""
    }
    // 日志新旧比较:任期优先,其次长度
    upToDate := args.LastLogTerm > n.lastLogTerm ||
        (args.LastLogTerm == n.lastLogTerm && args.LastLogIndex >= n.lastLogIndex)
    if n.votedFor == "" && upToDate {
        n.votedFor = args.CandidateId
        return RequestVoteReply{Term: n.currentTerm, VoteGranted: true}
    }
    return RequestVoteReply{Term: n.currentTerm, VoteGranted: false}
}

四、日志复制:appendEntries 与提交规则

Leader 收到客户端写请求后,先把命令作为日志项(Log Entry)追加到本地,再通过 AppendEntries RPC 并行复制到 Follower。每条日志记为 (Term, Index, Command)。复制过程遵循两条铁律:

  • 日志匹配(Log Matching):若两个节点在某 Index 的 Term 相同,则它们之前的所有日志完全一致;
  • 仅提交当前任期:Leader 只有当”当前 Term 的日志”被多数派复制后,才能提交它及之前的所有日志(不能靠旧任期日志的多数派来提交旧日志)。
// 日志匹配检查:PrevLogIndex 处任期必须一致
func (n *Node) onAppendEntries(args AppendEntriesArgs) AppendEntriesReply {
    if args.Term < n.currentTerm {
        return AppendEntriesReply{Term: n.currentTerm, Success: false}
    }
    n.currentTerm = args.Term
    n.leaderId = args.LeaderId
    if len(n.log) <= args.PrevLogIndex ||
        n.log[args.PrevLogIndex].Term != args.PrevLogTerm {
        return AppendEntriesReply{Term: n.currentTerm, Success: false} // 不一致,拒绝
    }
    // 冲突项截断后追加新日志
    n.log = n.log[:args.PrevLogIndex+1]
    n.log = append(n.log, args.Entries...)
    if args.LeaderCommit > n.commitIndex {
        n.commitIndex = min(args.LeaderCommit, len(n.log)-1)
    }
    return AppendEntriesReply{Term: n.currentTerm, Success: true}
}
RPC调用方核心参数作用
RequestVoteCandidateterm, lastLogTerm, lastLogIndex拉票、保证日志足够新
AppendEntriesLeaderterm, prevLogIndex/Term, entries, leaderCommit心跳 + 日志复制 + 提交推进

五、安全性:两条不可逾越的红线

Raft 靠两条规则保证”选出来的 Leader 一定拥有全部已提交日志”:

  • 选举限制:只有日志足够新的节点才能当选(见上文 RequestVote 的 upToDate 判断),从源头杜绝选出”落后 Leader”;
  • 提交限制:Leader 不能直接提交旧任期的日志,必须先用当前任期的新日志被多数派确认,连带把旧日志一起提交。否则可能把一条已被覆盖的旧日志误当成已提交。

正是这两条规则,让 Raft 在任意网络分区、任意节点崩溃的情况下,都能保证状态机安全(State Machine Safety):一旦某日志项被提交,所有正常节点在该 Index 上都不会是不同命令。

可以设想一个反例:假设旧任期的某条日志只被旧 Leader 复制到少数节点就宕机,新 Leader 上任后若直接把这条”多数派尚未确认”的日志提交,后续分区恢复时旧节点带回更长的日志就会把它覆盖,已提交的数据便凭空消失。提交限制正是为了堵住这个洞——它要求 Leader 先用一条”当前任期的新日志”拿到多数派确认,借此把之前所有日志一并安全提交。理解了这点,就不难看懂为什么很多 Raft Bug 都出在”把旧任期日志当成已提交”上。

六、实战:用 etcd/raft 跑一个最小状态机

工业界几乎不会手写 Raft,而是基于成熟库。etcd 内置的 go.etcd.io/raft/v3 就是最经典的实现。下面是一段”骨架”代码,展示一个节点如何把外部命令包成日志、再应用(Apply)到状态机:

package main

import (
    "go.etcd.io/raft/v3"
    "go.etcd.io/raft/v3/raftpb"
)

// 1) 提议一条命令:交给 Raft 模块,由它负责复制
func propose(n raft.Node, cmd []byte) {
    n.Propose(context.TODO(), cmd)
}

// 2) 从 Ready 中取出已提交(committed)的日志,应用到状态机
func applyCommitted(rd raft.Ready, sm *StateMachine) {
    for _, entry := range rd.CommittedEntries {
        if entry.Type == raftpb.EntryNormal && len(entry.Data) > 0 {
            sm.Apply(entry.Data) // 只有这里才是"真正生效"
        }
    }
}

注意:Raft 库只负责”让所有节点日志一致”,至于”如何把日志变成业务状态”(如 KV 写入),由你的状态机自己实现。这也是 RocketMQ、OceanBase 等系统复用 Raft 的方式——共识层与存储层解耦。

七、与 Paxos / Multi-Paxos 的取舍

Paxos 理论更老、证明更早,但角色与阶段交错,工程落地极易出错;Multi-Paxos 虽贴近生产,却缺乏统一标准实现。Raft 用”强 Leader + 任期 + 明确选举/复制/安全”换来了可读性,代价是写吞吐略集中于 Leader。实践中 etcd、Consul、TiKV、Kubernetes 背后的 etcd 都选了 Raft。

八、落地常见坑

现象根因对策
频繁重新选举、任期飙升心跳超时设太短 / 网络抖动 / 磁盘 IO 慢导致处理 AppendEntries 超时调大 election timeout,保证磁盘与网络稳定
读到了旧数据从 Follower 直接读,未走 Leader 线性一致读读前先与 Leader 确认任期(ReadIndex / LeaseRead)
脑裂时双 Leader网络分区 + 任期判断错误严格依赖多数派与任期,切勿自造”降级写”

一句话总结:Raft 把”分布式一致性”这件难事,拆成”选谁当老大、怎么把日志对齐、怎么保证安全”三件事,再用任期这一逻辑时钟串联起来。理解了这三块,你再看 etcd、TiDB 的架构文档,就会觉得顺理成章。

上一篇 TiDB 实战:分布式数据库架构与弹性扩展
下一篇 TypeScript 装饰器实战:从类装饰器到依赖注入
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]