引言:为什么需要共识算法?
在分布式系统中,多个节点之间就某个值达成一致(Consensus),是整个领域最核心、最具挑战性的问题之一。无论是etcd的领导者选举、Redis Sentinel的故障转移,还是区块链的区块确认,底层都依赖共识算法来保证多副本之间的强一致性。
Paxos作为共识算法的理论基石,虽然被证明正确,但其"两个阶段+多轮消息交换"的设计理念过于复杂,工程实践中难以正确实现。正因如此,Stanford的Diego Ongaro和John Ousterhout在2013年提出了Raft算法——一个以"可理解性(Understandability)"为第一设计目标的共识算法,将复杂问题分解为领导者选举、日志复制、安全性三个相对独立的子问题,极大地降低了工程实现门槛。
如今,Raft已被etcd、Consul、TiKV、CockroachDB、NATS Streaming等众多工业级分布式系统广泛采用。本文将从原理到实践,带大家深入理解Raft的设计思想、核心机制,并给出完整的工程实现方案和性能优化策略。
一、Raft核心原理:分解与简化
1.1 节点状态模型
Raft将集群中的每个节点定义为三种状态之一:
状态转换关系:
┌──────────┐ 超时触发选举 ┌──────────┐ 获得多数票 ┌──────────┐
│ Follower │ ──────────────▶│ Candidate│ ──────────────▶│ Leader │
└──────────┘ └──────────┘ └──────────┘
▲ │ 发现更高任期号或新Leader │
│ ▼ │
│ ┌──────────┐ │
└────────────────────│ Follower │◀────────────────────────┘
└──────────┘ 心跳超时/更高任期号
Follower(跟随者):被动接收Leader的心跳和日志追加请求,不主动发起通信。如果选举超时(Election Timeout)内未收到Leader消息,转变为Candidate。
Candidate(候选人):增加自己的任期号(Term),发起选举请求(RequestVote),争取多数票。获得多数票后成为Leader;如果超时则开始新一轮选举。
Leader(领导者):所有请求的唯一处理节点,定期发送心跳(空AppendEntries)维持领导权,接收客户端请求并将其转化为日志条目广播出去。
1.2 任期机制(Term)
任期是Raft的核心时间抽象。每个任期以一个选举开始,延续到选出新的Leader为止。任期号是全局严格单调递增的,每个节点都记录着当前任期currentTerm。任期号在以下场景被使用:
- 节点通信时携带任期号,用于识别过期的信息
- 较小任期的节点在收到较大任期的消息时,立即更新自己的当前任期,并退为Follower
- 如果Candidate/Leader收到更大任期的消息,说明自己已过期,同样退为Follower
1.3 一致性检查(Election Restriction)
Raft的安全性保证要求:Leader必须拥有旧任期内的所有已提交日志条目。因此在RequestVote请求中,候选人的日志必须至少与投票者的日志一样"新"——通过比较最后一个日志条目的Term和Index来判断。这避免了在选举中丢失已提交的数据。
二、领导者选举:多数票的博弈
2.1 选举流程
当Follower在electionTimeout(通常150-300ms之间的随机值)内未收到Leader心跳时,触发选举:
// 选举流程伪代码
void startElection() {
currentTerm++; // 任期号+1
votedFor = selfId; // 投票给自己
state = CANDIDATE;
lastElectionTime = now();
// 向所有其他节点发送RequestVote RPC
for each peer != self {
send RequestVote {
term: currentTerm,
candidateId: selfId,
lastLogIndex: log.lastIndex(),
lastLogTerm: log.lastTerm()
};
}
}
// 节点收到VoteRequest后的投票逻辑
bool handleVoteRequest(req) {
if (req.term < currentTerm) return false; // 任期过老,拒绝
if (votedFor != null && votedFor != req.candidateId) // 已投过其他人
return false;
if (req.lastLogTerm < myLastLogTerm) return false; // 日志没我新
if (req.lastLogTerm == myLastLogTerm && req.lastLogIndex < myLastLogIndex) return false;
votedFor = req.candidateId;
resetElectionTimer();
return true;
}
2.2 随机化超时与选票分裂
Raft的一个关键设计是:在旧版本的Raft中,如果多个Follower同时超时导致选票分裂(Split Vote),选举会陷入无休止的重试。Raft通过让每个节点的electionTimeout在150-300ms范围内随机选取,极大降低了选票分裂的概率。即使发生分裂,随机超时也能让不同节点在不同的时间重试选举,快速打破僵局。
etcd对此进行了优化,增加了Pre-Vote阶段:正式发起选举前,先询问其他节点是否愿意投票给自己,如果不获得多数支持就不真正发起选举,避免了因网络分区节点反复Term膨胀的问题。
三、日志复制:状态机的同步引擎
3.1 日志结构
Raft中每个节点维护一个日志(Log),每个条目包含:
LogEntry {
term: uint64 // 条目被创建时的任期号
index: uint64 // 条目的位置索引(从1开始,单调递增)
command: bytes // 客户端提交的状态机指令
}
3.2 日志复制流程
// 客户端写请求处理流程
void handleClientRequest(command) {
// 只有Leader能接受写请求
if (state != LEADER) redirectToLeader();
// 追加日志条目
LogEntry entry = { term: currentTerm, command: command };
log.append(entry);
matchIndex[self] = entry.index;
// 向所有Follower发送AppendEntries RPC
for each peer != self {
sendAppendEntries(peer);
}
}
// Leader发送AppendEntries
void sendAppendEntries(peer) {
uint64 prevLogIndex = nextIndex[peer] - 1;
uint64 prevLogTerm = log[prevLogIndex].term;
send AppendEntries {
term: currentTerm,
leaderId: selfId,
prevLogIndex: prevLogIndex, // 上一个已匹配的日志索引
prevLogTerm: prevLogTerm, // 上一个匹配日志的任期
entries: log[nextIndex[peer]:], // 待复制的新条目
leaderCommit: commitIndex // Leader当前的commitIndex
};
}
3.3 一致性检查与冲突解决
Follower收到AppendEntries后,执行一致性检查(Consistency Check):
// Follower处理AppendEntries
void handleAppendEntries(req) {
if (req.term < currentTerm) { reject(); return; }
// 有效的Leader消息
resetElectionTimer();
updateTermAndState(req.term);
// 一致性检查:leader的prevLog是否匹配?
if (log[req.prevLogIndex].term != req.prevLogTerm) {
// 冲突!跳过该索引及之后的所有日志(快速回退)
log.truncate(req.prevLogIndex);
reject();
return;
}
// 追加新条目,删除冲突的旧条目
log.appendAfter(req.prevLogIndex, req.entries);
// 更新commitIndex
if (req.leaderCommit > commitIndex) {
commitIndex = min(req.leaderCommit, req.prevLogIndex + entries.length);
applyToStateMachine();
}
success();
}
当Follower拒绝时,Leader通过nextIndex[peer]递减的方式寻找Follower日志与Leader一致的位置,然后从该位置开始快速重传。etcd对此做了优化:拒绝响应中携带冲突Term的FirstIndex,让Leader可以直接跳过整个Term的冲突区间。
3.4 提交与Apply
规则:Leader只能提交当前Term的日志条目。不能直接提交旧任期的条目(避免"已提交日志被覆盖"问题——Figure 8问题)。这是Raft最关键的安全性约束,很多实现疏忽了这一点。
// Leader提交检查
void checkCommit() {
// 找到多数节点已复制的最新index
for (uint64 n = log.lastIndex(); n > commitIndex; n--) {
if (log[n].term != currentTerm) continue; // 只提交当前Term的条目
int replicatedCount = countReplica(n);
if (replicatedCount > clusterSize() / 2) {
commitIndex = n;
applyToStateMachine();
break;
}
}
}
四、安全性:Raft的不变式保证
4.1 选举限制
Leader的日志必须是"最完整"的:所有Candidate在选举时必须保证自己的日志至少与其他多数节点中的最完整日志一样新,否则无法获得多数投票。
4.2 Leader不变式
Leader不会删除或覆盖自己的日志条目,只会追加。一旦Leader成功复制了一条日志条目到多数节点,它就是已提交(Commited)状态。如果后续Leader崩溃,新选出的Leader必然包含该条目(因为获得多数票的节点日志至少和提交后的节点一样新)。
4.3 日志匹配属性
如果两个日志条目具有相同的Index和Term,则这两个日志存储相同的Command,且之前的所有条目完全相同。这个性质保证了一致性检查(prevLogIndex + prevLogTerm)的可靠性。
五、完整Java实现
5.1 节点状态与核心数据结构
public class RaftNode {
// 持久化状态(需要写到磁盘)
private long currentTerm; // 当前任期
private Integer votedFor; // 当前任期投票给谁(null表示未投票)
private List<LogEntry> log; // 日志条目
// 易失性状态
private NodeState state; // Follower/Candidate/Leader
private int leaderId; // 当前已知的Leader
private long commitIndex; // 已知最高的被提交日志索引(初始0)
private long lastApplied; // 最高已应用到状态机的索引(初始0)
// Leader选举超时
private final Random random = new Random();
private long electionDeadline;
private final int MIN_ELECTION_TIMEOUT = 150; // ms
private final int MAX_ELECTION_TIMEOUT = 300; // ms
private final int HEARTBEAT_INTERVAL = 50; // ms
public RaftNode() {
this.state = NodeState.FOLLOWER;
this.currentTerm = 0;
this.votedFor = null;
this.log = new ArrayList<>();
this.log.add(new LogEntry(0, null)); // 哨兵条目,index=0
this.commitIndex = 0;
this.lastApplied = 0;
resetElectionTimer();
}
private void resetElectionTimer() {
int timeout = MIN_ELECTION_TIMEOUT +
random.nextInt(MAX_ELECTION_TIMEOUT - MIN_ELECTION_TIMEOUT);
this.electionDeadline = System.currentTimeMillis() + timeout;
}
}
5.2 选举线程
// 主循环线程
public void mainLoop() {
while (!shutdown) {
long now = System.currentTimeMillis();
if (state == NodeState.LEADER) {
// Leader:发送心跳
if (now - lastHeartbeatSent >= HEARTBEAT_INTERVAL) {
broadcastHeartbeat();
lastHeartbeatSent = now;
}
} else if (now >= electionDeadline) {
// 超时,开始选举
startElection();
}
// 应用已提交的日志
applyCommittedEntries();
Thread.sleep(5); // 避免CPU空转
}
}
private void startElection() {
state = NodeState.CANDIDATE;
currentTerm++;
votedFor = nodeId;
resetElectionTimer();
int votes = 1; // 投了自己
LogEntry lastLog = log.get(log.size() - 1);
for (RaftPeer peer : peers) {
VoteRequest req = new VoteRequest(
currentTerm, nodeId, lastLog.index, lastLog.term
);
VoteResponse resp = peer.sendVoteRequest(req);
if (resp == null) continue;
if (resp.term > currentTerm) {
// 步进降级
stepDown(resp.term);
return;
}
if (resp.voteGranted) {
votes++;
if (votes > (clusterSize() + 1) / 2) {
becomeLeader();
return;
}
}
}
// 未获多数,等下一轮超时
}
5.3 投票处理
public VoteResponse handleVoteRequest(VoteRequest req) {
if (req.term > currentTerm) {
stepDown(req.term); // 更新任期,退为Follower
}
boolean grant = false;
if (req.term == currentTerm) {
if (votedFor == null || votedFor == req.candidateId) {
// 日志新旧比较:候选人的lastLog至少与我一样新
LogEntry myLastLog = log.get(log.size() - 1);
boolean logIsUpToDate =
(req.lastLogTerm > myLastLog.term) ||
(req.lastLogTerm == myLastLog.term &&
req.lastLogIndex >= myLastLog.index);
if (logIsUpToDate) {
votedFor = req.candidateId;
grant = true;
resetElectionTimer(); // 有投票活动,重置超时
}
}
}
return new VoteResponse(currentTerm, grant);
}
5.4 AppendEntries处理
public AppendResponse handleAppendEntries(AppendRequest req) {
if (req.term < currentTerm) {
return new AppendResponse(currentTerm, false);
}
// 有效的Leader消息
if (req.term > currentTerm) {
stepDown(req.term);
}
leaderId = req.leaderId;
state = NodeState.FOLLOWER;
resetElectionTimer();
// 日志一致性检查
if (req.prevLogIndex >= log.size()) {
return new AppendResponse(currentTerm, false, log.size(), -1);
}
LogEntry prevEntry = log.get((int) req.prevLogIndex);
if (prevEntry.term != req.prevLogTerm) {
// 冲突优化:返回冲突Term的FirstIndex
int conflictFirstIndex = (int) req.prevLogIndex;
while (conflictFirstIndex > 1 &&
log.get(conflictFirstIndex - 1).term == prevEntry.term) {
conflictFirstIndex--;
}
return new AppendResponse(currentTerm, false,
log.size(), conflictFirstIndex);
}
// 追加新条目
int insertIndex = (int) req.prevLogIndex + 1;
for (int i = 0; i < req.entries.size(); i++) {
LogEntry entry = req.entries.get(i);
if (insertIndex + i < log.size()) {
if (log.get(insertIndex + i).term != entry.term) {
log.subList(insertIndex + i, log.size()).clear();
log.add(entry);
}
// 已存在且Term相同,跳过
} else {
log.add(entry);
}
}
// 更新commitIndex
if (req.leaderCommit > commitIndex) {
commitIndex = Math.min(req.leaderCommit, log.size() - 1);
}
return new AppendResponse(currentTerm, true, log.size(), -1);
}
5.5 批量提交优化
// Leader日志追加后触发复制
public void appendAndReplicate(byte[] command) {
state = NodeState.LEADER;
long index = log.size();
LogEntry entry = new LogEntry(currentTerm, command, index);
log.add(entry); // 追加到自己的日志
// 异步发送给所有节点
for (RaftPeer peer : peers) {
replicateToPeer(peer);
}
// 等待提交(简化为同步等待,实际使用CountDownLatch或回调)
waitForCommit(index);
}
// 收到多数成功的响应后提交
public synchronized void onPeerReplicated(int peerId, long index) {
if (state != NodeState.LEADER) return;
matchIndex[peerId] = index;
// 从后往前找多数复制的index(只能提交当前Term的)
for (long n = log.size() - 1; n > commitIndex; n--) {
if (log.get((int) n).term != currentTerm) continue;
int replicated = 1; // 自己
for (RaftPeer peer : peers) {
if (matchIndex[peer.id] >= n) replicated++;
}
if (replicated > (clusterSize() + 1) / 2) {
commitIndex = n;
notifyAll(); // 唤醒等待的线程
applyToStateMachine();
break;
}
}
}
六、变种与优化
6.1 Pre-Vote(etcd优化)
网络分区中的节点因为无法连接到Leader而不断自增Term。当分区恢复后,高Term会迫使当前Leader下台,造成服务中断。Pre-Vote让节点在正式Term+1之前先做一次"预选举",确认自己能获得多数票再真正发起选举。
6.2 CheckQuorum(etcd优化)
Leader在每次心跳时检查多数节点心跳应答。如果在electionTimeout内未收到多数Leader的应答,则主动退位自增Term,避免分区恢复期间的高Term效应。
6.3 ReadIndex与LeaseRead
针对线性一致性读的优化方案:
- ReadIndex:Leader在响应读请求前,向多数节点发送一次心跳确认自己的Leader身份,等待commitIndex被应用到状态机后再响应。保障了线性一致性但增加了RTT。
- LeaseRead:Leader与节点之间维护一个"Leader Lease"(基于时间的安全租约,通常一个electionTimeout),在lease期间直接读取无需确认。延迟更低但依赖时钟。
6.4 Joint Consensus(安全成员变更)
成员变更期间最容易出现"脑裂"——旧配置和新配置各自形成多数派。Raft采用Joint Consensus(联合共识)策略:先过渡到旧+新配置的联合状态,期间所有决策需要同时获得旧配置和新配置的多数同意。随后切换到纯新配置,彻底消除脑裂风险。
6.5 Log Compaction与Snapshot
当日志过大时,Raft支持快照压缩:Leader发送InstallRPC给落后太多的Follower一个snapshot,包含状态机在某index时刻的完整状态,以及该index对应的term和index用于后续一致性检查。
七、工业级实现对比
| 项目 | 语言 | 特色 | 适用场景 |
|---|---|---|---|
| etcd/raft | Go | 性能优异,被etcd广泛验证 | 协调服务、配置中心 |
| HashiCorp Raft | Go | 成熟稳定,支持快照和成员变更 | Consul、Nomad |
| SOFAJRaft | Java | 蚂蚁金服出品,支持统计和限流 | Java生态分布式锁、Leader选举 |
| TiKV/Raft | Rust | 性能极致,基于Multi-Raft | TiKV分布式KV存储 |
| Apache Ratis | Java | Apache顶级项目,强一致复制 | Kafka元数据、HBase WAL |
| braft | C++ | 百度出品,BFTRaft实现 | 搜索、存储、分布式事务 |
八、常见问题与陷阱
Q1: 为什么不能直接提交旧Term的条目?
假设Leader A提交了Term 2的条目index=4后,崩溃了。B获得多数票成为Term 3的Leader,但此时还有节点C的日志只到index=3。如果B直接提交了Term 2的条目(以为已提交),然后B也崩溃了,C如果得到D、E的投票可能在Term=3时成为Leader,其不包含index=4的条目,就会覆盖已提交的数据。
Q2: 为什么要用随机超时?
同步超时下,所有Follower会同时变为Candidate同时发起选举,选票分裂导致永远选不出Leader。随机化让每个节点在不同时间触发选举,第一个超时的节点通常能获得多数票。
Q3: Raft的性能瓶颈在哪里?
Raft的吞吐量受限于网络RTT:Leader必须等待多数节点的AppendEntries响应才能提交。在跨地域部署下,一次RPC延迟可能到数十ms,吞吐量会严重下降。批量提交(Batching)和流水线复制(Pipeline)是常用优化手段。
九、总结
Raft以其优雅的设计和清晰的工程化路径,成为工业界最广泛采用的共识算法。其核心思想——通过状态约束(Leader完整性、日志匹配、提交限制)来保证安全性——在Paxos之上提供了更高的可理解性,使得更多工程师能正确实现强一致协议。
理解Raft不仅是分布式系统知识体系的重要篇章,更是构建可靠基础设施的必备能力。无论是自研分布式存储、微服务协调,还是使用etcd/Consul等组件做Leader选举,Raft的设计思想都会让你对"一致性"有更深的认知。
建议读者结合本文代码,基于OpenRaft或hashicorp/raft等开源实现,搭建一个3节点的Demo并模拟Leader崩溃、分区恢复、成员变更等场景,在实践中深入理解Raft的各个环节。

发表评论 取消回复