在分布式系统中,如何让多台机器对”同一件事”达成一致,正是 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 | 调用方 | 核心参数 | 作用 |
|---|---|---|---|
| RequestVote | Candidate | term, lastLogTerm, lastLogIndex | 拉票、保证日志足够新 |
| AppendEntries | Leader | term, 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 的架构文档,就会觉得顺理成章。




