Raft 一致性协议详解 ⭐⭐⭐
🎯 面试题:什么是 Raft?它是如何工作的?
Raft 是用于管理日志复制的一致性算法,核心目标是易于理解。通过领导者(Leader)、追随者(Follower)、候选者(Candidate)三种角色实现分布式一致性。相比 Paxos,Raft 更易于工程实现,是 etcd、Kubernetes 等主流系统的共识基础。
一、Raft 三种角色
┌──────────────────────────────────────────────────────────┐
│ Raft 集群角色状态机 │
│ │
│ Follower ←───── election timeout ──────→ Candidate │
│ ↑ ↘ │
│ │ ↘ 赢得多数票 │
│ │ ↘ │
│ ←─────── 心跳或新 Leader ────────── Leader │
│ │
│ Follower :被动接收请求,只响应 Leader 和 Candidate │
│ Candidate :发起选举时角色,可升级为 Leader │
│ Leader :处理所有客户端请求,向 Follower 同步日志 │
└──────────────────────────────────────────────────────────┘
角色职责
| 角色 | 职责 | 关键词 |
|---|---|---|
| Leader | 接收客户端请求,日志复制 | 唯一处理写请求 |
| Follower | 被动响应,超时发起选举 | 投票、接收日志 |
| Candidate | 选举中的临时角色 | 争取多数票 |
二、领导人选举(Leader Election)
触发条件
Follower 在 election timeout(150~300ms 随机)内
没收到 Leader 的心跳 → 认为自己 leader 不存在
→ 发起选举,转为 Candidate
选举流程
1. Candidate term + 1(term 任期号递增)
2. 给自己投票
3. 向所有节点发送 RequestVote RPC
4. 等待响应:
情况 A:收到多数票
→ 成为新的 Leader
→ 立即向所有节点发送心跳(AppendEntries 空日志)
→ 阻止新选举
情况 B:收到任期更新的 Leader 的心跳
→ 发现对方 term >= 自己 term
→ 承认对方为 Leader,自己退回 Follower
情况 C:选举超时无人胜出(split vote 平票)
→ term + 1,重新发起选举
→ 随机退避避免再次平票
投票规则(Vote)
能投票给 Candidate 的条件(必须同时满足):
① 没投过票(每个 term 最多投一票)
② Candidate 的 lastLogTerm > 自己的 lastLogTerm
或 Candidate 的 lastLogTerm == 自己的 lastLogTerm
且 Candidate 的 lastLogIndex >= 自己的 lastLogIndex
这两条规则保证了:选举出的 Leader 一定包含所有已提交的日志。
选举超时(Election Timeout)
随机范围:150ms ~ 300ms
随机目的:避免多个 Follower 同时发起选举导致 split vote
心跳间隔(heartbeat interval):
通常 = election timeout 的 1/10 ~ 1/5
Leader 定期发送心跳维持领导地位
Java 实现简版
public class RaftNode {
private final int nodeId;
private volatile RaftRole role = RaftRole.FOLLOWER;
private volatile long currentTerm = 0;
private volatile Integer votedFor = null; // 投给了谁
private volatile long lastHeartbeat = 0; // 最后心跳时间
// 选举超时时间(随机 150~300ms)
private final Random random = new Random();
private long electionTimeoutMs;
@Scheduled(fixedRate = 10)
public void tick() {
if (role == RaftRole.LEADER) {
// Leader 发送心跳
sendHeartbeat();
} else {
// 检查选举超时
if (System.currentTimeMillis() - lastHeartbeat > electionTimeoutMs) {
startElection();
}
}
}
private void startElection() {
currentTerm++;
role = RaftRole.CANDIDATE;
votedFor = nodeId; // 给自己投一票
int votes = 1; // 自己的票
// 并行向所有节点发 RequestVote
for (int peerId : peers) {
CompletableFuture<Boolean> future = sendRequestVote(peerId);
future.thenAccept(granted -> {
if (granted) votes++;
if (votes > peers.size() / 2) {
// 赢得选举
becomeLeader();
}
});
}
}
private void becomeLeader() {
role = RaftRole.LEADER;
System.out.println("Node " + nodeId + " became Leader, term=" + currentTerm);
// 立即发心跳,阻止其他节点发起新选举
sendHeartbeat();
}
// 处理 RequestVote RPC
public boolean handleRequestVote(int term, int candidateId,
long lastLogTerm, long lastLogIndex) {
if (term > currentTerm) {
currentTerm = term;
role = RaftRole.FOLLOWER;
votedFor = null;
}
boolean canVote = (votedFor == null || votedFor == candidateId)
&& (lastLogTerm > getLastLogTerm()
|| (lastLogTerm == getLastLogTerm() && lastLogIndex >= getLastLogIndex()));
if (canVote) {
votedFor = candidateId;
return true;
}
return false;
}
}
三、日志复制(Log Replication)
日志结构
每个节点维护一个日志数组:
Index: 1 2 3 4 5 6 7 8
Term: 1 1 1 2 2 3 3 3
Cmd: cmdA cmdB cmdC cmdD cmdE cmdF cmdG cmdH
↑ ↑
已复制到多数节点 已提交(committed)
但未提交 ✅
已提交 = 被多数节点复制过的日志,Leader 可以安全执行
AppendEntries RPC
客户端请求 → Leader 追加本地日志
→ 并行发 AppendEntries RPC 给所有 Follower
→ 等待多数节点响应成功
→ Leader 本地提交(applied)
→ 通知 Follower 提交
Leader 同时维护 nextIndex[] 和 matchIndex[]:
nextIndex[i]:下一个要发送给 Follower i 的日志索引
matchIndex[i]:Follower i 已复制的最高日志索引
日志一致性与修复
问题:Follower 日志可能出现"空洞"或"冲突"
┌─────────────────────────────────────────┐
│ Leader 日志: [1,1] [2,1] [3,2] [4,2] │
│ Follower: [1,1] [2,1] [4,2] [5,2] │ ← 缺少 [3,2],多了 [5,2]
│ 冲突:位置3对应不同命令
└─────────────────────────────────────────┘
解决:Leader 通过递减 nextIndex[i] 找到冲突点,
然后从该点起覆盖 Follower 的日志
具体过程:
1. Leader 发 AppendEntries(prevIndex=3, prevTerm=2, entries=[4,2])
2. Follower 发现位置 3 的 term 不匹配,拒绝
3. Leader nextIndex[Follower]-- → nextIndex=3
4. 重试,直到成功
// Leader 向 Follower 同步日志
public void replicateToFollower(int followerId) {
long nextIdx = nextIndex.get(followerId);
long prevLogIndex = nextIdx - 1;
long prevLogTerm = getLogTerm(prevLogIndex);
List<LogEntry> entries = logs.subList((int)nextIdx, logs.size());
AppendEntriesRpc rpc = AppendEntriesRpc.builder()
.term(currentTerm)
.leaderId(nodeId)
.prevLogIndex(prevLogIndex)
.prevLogTerm(prevLogTerm)
.entries(entries)
.leaderCommit(commitIndex)
.build();
boolean success = sendAppendEntries(followerId, rpc);
if (success) {
// 同步成功,推进 matchIndex
nextIndex.put(followerId, nextIdx + entries.size());
matchIndex.put(followerId, nextIdx + entries.size() - 1);
} else {
// 日志不一致,退回重试
nextIndex.put(followerId, nextIndex.get(followerId) - 1);
}
}
// Follower 处理 AppendEntries RPC
public boolean handleAppendEntries(AppendEntriesRpc rpc) {
// 1. 任期检查
if (rpc.getTerm() < currentTerm) return false;
if (rpc.getTerm() > currentTerm) {
currentTerm = rpc.getTerm();
becomeFollower();
}
// 2. 日志匹配检查
if (rpc.getPrevLogIndex() > 0) {
LogEntry entry = getLog(rpc.getPrevLogIndex());
if (entry == null || entry.term != rpc.getPrevLogTerm()) {
return false; // 一致性检查失败
}
}
// 3. 覆盖冲突日志
for (LogEntry entry : rpc.getEntries()) {
long idx = rpc.getPrevLogIndex() + 1 + rpc.getEntries().indexOf(entry);
if (idx < logs.size() && logs.get((int)idx).term != entry.term) {
logs.subList((int)idx, logs.size()).clear(); // 删除冲突日志
}
if (idx >= logs.size()) {
logs.add(entry);
}
}
// 4. 更新 commitIndex
if (rpc.getLeaderCommit() > commitIndex) {
commitIndex = Math.min(rpc.getLeaderCommit(), logs.size() - 1);
}
return true;
}
提交规则
已提交日志必须满足:
① 被多数节点写入本地日志
② 所在 term 的 Leader 已提交
⚠️ 注意:
Leader 不能仅通过"多数节点确认"就提交上一任期的日志!
必须等待当前任期的日志被复制到多数节点后才能提交。
证明:如果 Leader 只看了多数节点就提交上一 term 日志,
可能因网络分区导致被覆盖,违反一致性。
四、安全性保证
Raft 的 5 条安全性规则
规则 1:选举约束(Election Restriction)
Follower 只投票给比自己日志新的 Candidate
→ 保证新 Leader 包含所有已提交日志
规则 2:Leader 只追加日志(Append-Only)
Leader 永远不会覆盖或删除自己的日志
→ 只能追加
规则 3:日志匹配(Log Matching)
如果两个节点的日志在某个索引相同 → 之前所有条目也相同
→ 通过 AppendEntries 一致性检查保证
规则 4:Leader 完整性(Leader Completeness)
如果一条日志在某个 term 被提交 → 所有后续 Leader 都包含该日志
→ 由规则 1 和规则 3 共同保证
规则 5:状态机安全(State Machine Safety)
如果节点已应用了某个索引的日志 → 其他节点不会在同一索引应用不同日志
→ 保证了所有节点执行相同序列的状态机命令
任期(Term)的意义
Term = 逻辑时钟,递增整数
作用:
① 判断谁是最新 Leader(term 更大优先)
② 识别过期请求
③ 区分不同选举轮次
通信时附上 term:
- 如果节点 term < 对方 term → 立即更新自己的 term
- 如果 Leader/Candidate term < Follower term → 退回 Follower
五、成员变更(Joint Consensus)
问题:直接切换配置有风险
旧配置:3 节点 {A, B, C}
新配置:3 节点 {A, B, D}
如果出现 {A, B} 和 {C} 两个多数派同时存在?
→ 可能选出两个 Leader,脑裂
┌──────────────────────────────────────────┐
│ 时间线: │
│ t1: 旧配置 {A,B,C} │
│ t2: C 被添加到配置 {A,B,C,D} │
│ 但 D 还没同步配置 │
│ 如果此时 C 认为 A,B,D 是多数派? │
│ 就可能选出第二个 Leader ❌ │
└──────────────────────────────────────────┘
解决方案:两阶段成员变更(Joint Consensus)
阶段一:Cold ∪ Cnew(过渡配置)
所有节点使用 Cold ∪ Cnew 配置
新节点以 LogEntry 形式写入日志,异步同步配置
多数派 = Cold∩多数 ∪ Cnew∩多数(重叠保证)
阶段二:Cnew(最终配置)
新配置生效
旧节点可以下线
配置变更流程:
Leader 收到成员变更请求
↓
追加 Cold∪Cnew 日志,广播给所有节点
↓
收到 Cold∪Cnew 多数确认 → 追加 Cnew 日志
↓
收到 Cnew 多数确认 → 新配置生效
期间如果 Leader 挂了,新 Leader 可能是 Cold 或 Cnew 节点,
最终都会收敛到 Cnew。
六、日志压缩与快照(Log Compaction)
问题
日志无限增长 → 内存耗尽、重启太慢
解决方案:快照(Snapshot)
将已提交的状态机数据写入快照文件
删除快照之前的日志
快照内容
快照文件 = 状态机数据 + 最后日志 term + 最后日志 index
快照后的状态:
已持久化:快照文件
已应用:0 ~ snapshotIndex
未应用:snapshotIndex ~ commitIndex
未复制:commitIndex ~ lastIndex
快照同步
新节点加入时,Leader 通过 InstallSnapshot RPC 发送快照
Follower 收到快照后:
1. 如果快照覆盖自己的日志 → 清空本地日志
2. 加载快照数据到状态机
3. 更新 commitIndex 和 lastApplied
七、与 Paxos 对比
| 维度 | Raft | Paxos |
|---|---|---|
| 易理解性 | ✅ 强项,拆分清晰 | ❌ 难以理解 |
| 角色划分 | Leader/Follower/Candidate 明确 | 无固定角色 |
| 日志复制 | 强 Leader,线性复制 | 多 Paxos 实例 |
| 成员变更 | 两阶段 Joint Consensus | 理论完善但难实现 |
| 工程落地 | ✅ etcd、Kubernetes、RocketMQ | Chubby、ZooKeeper |
| 效率 | 与 Raft 相当 | 相当 |
| 正确性证明 | 论文有完整证明 | 原始论文过于简洁 |
八、高频面试题
Q1: Raft 如何保证选出的 Leader 一定包含所有已提交日志?
通过选举限制(Election Restriction):Follower 只投票给比自己日志新的 Candidate。”更新”的标准是 lastLogTerm 更大,或 lastLogTerm 相同但 lastLogIndex 更长。如果 Candidate 缺少已提交日志,它的 lastLog 就不够新,无法获得多数票,因此不可能当选。
Q2: 什么是脑裂?如何避免?
脑裂是网络分区导致多个节点同时认为自己是 Leader。Raft 通过任期号(Term)解决:Term 小的 Leader 自动降级为 Follower。分区中 Term 较小的 Leader 因无法获得多数票确认,无法提交任何日志,不会产生数据冲突。当网络恢复后,Term 较大的新 Leader 上台,旧的 Leader 检测到 term 更低,自动退位。
Q3: Raft 的日志提交和普通日志复制有什么区别?
普通日志复制只是把日志复制到 Follower(AppendEntries 成功),此时日志是”已复制但未提交”。只有当 Leader 确认某条日志被多数节点写入后,这条日志才是”已提交”(committed),Leader 才能执行并通知 Follower 应用。更关键的是,Leader 不能通过多数确认来提交前任期的日志,必须等当前任期的日志被复制后才能提交。
Q4: Leader 挂了,新 Leader 是怎么选出来的?
Follower 超时 → 发起选举(term+1),向所有节点发 RequestVote。节点收到请求后按投票规则决定是否投票。Candidate 获得多数票 → 成为新 Leader,立即发心跳阻止其他节点发起新选举。如果没人拿到多数票,选举超时后 term 再递增,重新发起选举。
Q5: 日志不一致时 Raft 如何修复?
Leader 通过递减 nextIndex 重试 AppendEntries。Follower 收到 AppendEntries 后做一致性检查(prevLogIndex 和 prevLogTerm 是否匹配),不匹配就拒绝。Leader 收到拒绝后 nextIndex–,重发直到成功。成功后就从冲突点开始覆盖 Follower 的日志,保证两者最终一致。
Q6: 为什么 Raft 不用 Paxos?
Paxos 的论文晦涩难懂,工程实现极难(Chubby 作者曾说”实现 Paxos 很难”)。Raft 将问题拆解为三个独立子问题(领导选举、日志复制、安全性),每一步都有明确的操作指引,更容易正确实现。etcd、Kubernetes 等项目都选择 Raft 而非 Paxos。
Q7: Raft 能做到线性一致(Linearizable)吗?
可以。Raft 本身保证了线性一致的日志复制,但客户端交互需要额外保证:① 客户端每次请求带上唯一序列号,防止 Leader 响应延迟导致重复执行;② Leader 记录已处理的序列号,重复请求直接返回之前的结果而不重新执行;③ 客户端在发现 Leader 变更后,要重新查询当前 Leader。
Q8: 日志压缩(快照)的触发时机是什么?
两种触发方式:① 按日志长度:当日志大小超过阈值(如 64MB 或 10 万条)触发快照;② 按时间:定期触发(如每小时)。快照生成时写入磁盘,之后删除快照点之前的日志,重启时加载快照 + 重放之后的日志即可恢复状态。
参考链接: