Raft 是 Diego Ongaro 于 2014 年提出的共识算法,以可理解性为首要设计目标,将共识问题分解为 Leader 选举、日志复制、安全性三个相对独立的子问题。本文深入伪代码级别,并重点分析 Leader 宕机、日志冲突、网络分区、脑裂恢复等异常场景。
相关文章:Paxos 算法 · 向量时钟与因果一致性 · Quorum 与 NWR 模型
目录
| 章节 | 说明 |
|---|---|
| 概念速查 | 阅读本文前先看这里 |
| 核心概念 | 任期、状态机、日志、三种节点状态 |
| Leader 选举 | 随机超时的关键性、RequestVote RPC |
| 日志复制 | AppendEntries、nextIndex/matchIndex、两阶段、客户端响应时机 |
| 异常场景分析 | Leader 宕机 / 日志冲突 / 网络分区 / 脑裂 / 选举失败 |
| 安全性保证 | 选举限制与提交限制 |
| 日志压缩(Snapshot) | 防止日志无限增长 |
| 成员变更 | 联合共识(Joint Consensus) |
| PreVote 扩展 | 防止分区节点 Term 膨胀扰乱集群 |
| 算法威胁模型 | Raft 能防什么、不能防什么 |
| 节点数量的选择 | 为什么是 3~5 节点,如何水平扩展 |
| 与 Paxos 的对比 | 关键差异 |
概念速查
阅读本文前,先了解这些贯穿全文的术语:
节点与角色
| 术语 | 含义 |
|---|---|
| Leader | 唯一主节点,处理所有写请求;读请求默认也走 Leader(保证线性一致性),Follower 可承接弱一致读(可能返回旧数据) |
| Follower | 被动接收者,响应 Leader 心跳和 Candidate 投票请求 |
| Candidate | 竞选中的节点,广播 RequestVote 争取投票 |
| Quorum(多数派) | 严格超过半数的节点集合(N 节点需 > N/2),两个 Quorum 必有交集 |
时间与版本
| 术语 | 含义 |
|---|---|
| Term(任期) | 单调递增的逻辑时钟,每次选举 +1;每条 RPC 都携带 term,用于识别过期消息 |
| Index(日志索引) | 日志条目在日志中的位置编号,从 1 开始单调递增;只由写操作产生,心跳不更新 index |
日志相关
| 术语 | 含义 |
|---|---|
| Log / LogEntry(日志条目) | {index, term, command} 三元组,command 是要应用到状态机的操作 |
| lastLogIndex | 节点日志中最后一条条目的 index(已写入,不一定已提交) |
| lastLogTerm | 节点日志中最后一条条目的 term(用于选举时比较日志新旧) |
| commitIndex | 已知已提交的最高 index(多数派已写入),可应用到状态机 |
| lastApplied | 已实际应用到状态机的最高 index(lastApplied ≤ commitIndex) |
| prevLogIndex / prevLogTerm | AppendEntries 中的一致性检查字段:新条目前一条的 index 和 term |
Leader 专属
| 术语 | 含义 |
|---|---|
| nextIndex[i] | Leader 记录的"下次要发给 Follower i 的日志 index"(乐观估计,失败则回退) |
| matchIndex[i] | Leader 记录的"已确认复制到 Follower i 的最高 index"(保守确认,用于推进 commitIndex) |
RPC
| 术语 | 含义 |
|---|---|
| AppendEntries | Leader → Follower 的核心 RPC,兼做心跳(entries 为空)和日志复制(entries 非空) |
| RequestVote | Candidate → 所有节点的投票请求 RPC |
其他
| 术语 | 含义 |
|---|---|
| 状态机(State Machine) | 实际执行日志命令的业务逻辑(如 KV 存储的 Map),所有节点的状态机应用相同日志序列后状态相同 |
| prevLog 检查 | Follower 收到 AppendEntries 时验证 prevLogIndex 处的 term 是否匹配,确保覆盖有安全起点 |
| Split Vote | 多个 Candidate 同时竞选,票数分散导致无人获得多数,需随机超时打破对称 |
核心概念
任期(Term)
Term 是 Raft 的逻辑时钟,单调递增的整数。
每次选举开始时 Term 加 1。
每个 RPC 都携带 Term,用于检测过期信息。
if 收到的 RPC.term > currentTerm:
currentTerm = RPC.term
转为 Follower(放弃 Leader/Candidate 身份)
节点状态机
| 状态 | 角色 | 触发条件 | 核心行为 |
|---|---|---|---|
| Follower | 被动接收者 | 初始状态;或收到更高 Term 的 RPC | 响应 Leader 心跳/复制请求;响应 Candidate 投票请求;选举超时后转为 Candidate |
| Candidate | 竞选者 | Follower 选举超时(150~300ms 随机) | 自增 Term,为自己投票,广播 RequestVote;赢得多数票 → Leader;收到更高 Term → Follower;超时再次选举 |
| Leader | 主节点 | 赢得多数派投票 | 接收客户端写请求;周期性广播心跳(防止 Follower 超时);复制日志给所有 Follower;决定 commitIndex 并应用到状态机 |
计时器重置规则(来自 Raft 论文 Figure 2):
- ✅ 收到合法 Leader 的 AppendEntries(心跳)→ 重置计时器
- ✅ 投出一票(grant vote,回复 VoteGranted=true)→ 重置计时器
- ❌ 仅收到 RequestVote 但拒绝投票 → 不重置计时器
原因:若"收到 RequestVote"就重置计时器,任何节点(或攻击者)都可以持续广播 RequestVote 来阻止所有 Follower 超时,集群将永远无法选出 Leader。
状态转换触发条件一览:
| 转换 | 触发条件 |
|---|---|
| Follower → Candidate | 选举超时内:既未收到合法 Leader 心跳,也未向任何 Candidate 投出一票 |
| Candidate → Leader | 收到严格多数票(> N/2) |
| Candidate → Follower | 收到更高 Term 的 RPC;或收到合法 Leader 心跳 |
| Candidate → Candidate | 选举超时仍无结果(随机退避后重新选举) |
| Leader → Follower | 收到更高 Term 的 RPC(意味着有更新的 Leader 存在) |
状态机(State Machine)
状态机就是你的业务数据本身——Raft 负责把命令排好序,状态机负责真正执行这些命令。
最简单的例子:KV 存储
状态机 = Map<String, String>,初始为 {}
日志(Raft 保证所有节点顺序一致):
index=1:set x=1
index=2:set y=2
index=3:del x
index=4:set x=5
状态机逐条应用后的当前状态:{y=2, x=5}
Raft 不关心命令内容,只管顺序;状态机不关心顺序怎么来的,只管执行。所有节点日志顺序相同 → 所有状态机执行结果相同,这正是"复制状态机"模型的核心。
| 系统 | 日志(Raft 管) | 状态机(业务管) |
|---|---|---|
| etcd | 操作序列 | 配置数据 Map |
| TiKV | 写入操作 | RocksDB 存储引擎 |
| CockroachDB | SQL 事务 | 数据库引擎 |
日志结构
每条日志条目包含:
LogEntry {
index: int // 在日志中的位置(从 1 开始)
term: int // 该条目创建时的 Leader 任期
command: bytes // 状态机命令
}
具体示例(5 节点集群,经历了两次 Leader 切换):
索引: 1 2 3 4 5 6
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
term: │ 1 │ │ 1 │ │ 1 │ │ 2 │ │ 2 │ │ 3 │
cmd: │x=1 │ │y=2 │ │x=3 │ │y=4 │ │z=5 │ │x=6 │
└──────┘ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘
↑
commitIndex=5(已提交)
index=6 的条目 term=3,尚未提交(Leader 正在等待多数派确认)
关键字段的作用:
| 字段 | 作用 |
|---|---|
index |
全局唯一序号,保证日志有序且无空洞(Raft 强制日志连续) |
term |
用于安全性判断:新 Leader 不直接提交旧 term 的条目(防止图 8 异常) |
command |
应用到状态机的操作,如 set x=1、数据库事务等 |
多节点日志对比(Leader 宕机恢复场景):
index: 1 2 3 4 5
Leader: [1,A][1,B][2,C][2,D][3,E] ← commitIndex=4,index=5 未提交
Follower1: [1,A][1,B][2,C][2,D] ← 落后 1 条,通过 AppendEntries 追上
Follower2: [1,A][1,B][2,C] ← 落后 2 条
Follower3: [1,A][1,B][2,C][2,D][3,E] ← 与 Leader 一致
日志格式:[term, command],index 从左到右递增
commitIndex=4 意味着 index 1~4 已提交,状态机已应用
Leader 选举
触发条件
Follower:
维护选举超时计时器(randomized,150ms ~ 300ms)
每次收到 Leader 心跳(AppendEntries)时重置计时器
on 计时器超时:
currentTerm += 1
转为 Candidate
开始选举
随机超时:算法能实际运行的关键
没有随机超时,Raft 无法收敛:
所有节点超时固定为 150ms 时:
T=150:S1、S2、S3、S4、S5 同时成为 Candidate
各自投自己 1 票 → 无人达到多数(3票)
T=300:所有节点再次同时超时,Term+1,重复
→ 无限 Split Vote 循环,永远选不出 Leader
加入随机超时(150~300ms)后:
S1 在 152ms 率先超时 → 广播 RequestVote
S2~S5 尚未超时,尚未投票 → 全部投给 S1
S1 赢得 4 票,2ms 内完成选举 ✅
这是 FLP 不可能定理在工程上的解法:纯确定性算法在异步网络中无法保证活性,随机性是打破对称的唯一手段。
超时范围的设置原则:broadcastTime ≪ electionTimeout ≪ MTBF
broadcastTime ≈ 1ms(局域网 RTT)
electionTimeout = 150~300ms(随机区间宽度 150ms ≫ 1ms)
→ 最先超时的节点能在其他节点超时前完成广播并收票
→ Split Vote 概率极低
RequestVote RPC
// Candidate 发送
RequestVote(args):
args.term = currentTerm // 候选人任期
args.candidateId = self.id
args.lastLogIndex = log.lastIndex()
args.lastLogTerm = log.lastTerm()
// Follower / 其他 Candidate 处理
on receive RequestVote(args):
if args.term < currentTerm:
reply(false, currentTerm) // 拒绝过期候选人
return
if args.term > currentTerm:
currentTerm = args.term
转为 Follower
votedFor = null
// 投票条件:本任期内未投票 AND 候选人日志至少和自己一样新
if (votedFor == null or votedFor == args.candidateId)
AND isAtLeastAsUpToDate(args):
votedFor = args.candidateId
重置选举计时器
reply(true, currentTerm)
else:
reply(false, currentTerm)
// 日志新旧比较:先比 term,再比 index(更大 = 更新)
isAtLeastAsUpToDate(args):
if args.lastLogTerm != log.lastTerm():
return args.lastLogTerm > log.lastTerm()
return args.lastLogIndex >= log.lastIndex()
Candidate 处理投票结果
Candidate on receive RequestVote reply:
if reply.term > currentTerm:
currentTerm = reply.term
转为 Follower; return
if reply.voteGranted:
votesReceived += 1
if votesReceived > N/2: // 赢得多数票
转为 Leader
初始化 nextIndex[i] = log.lastIndex() + 1 // 对每个 Follower
初始化 matchIndex[i] = 0
立即发送心跳(空 AppendEntries),宣示 Leader 身份
// 超时未赢得选举:
on 选举超时:
currentTerm += 1
重新开始选举(随机退避防止活锁)
日志复制
AppendEntries RPC
// Leader 发送(心跳 = entries 为空的 AppendEntries)
AppendEntries(args):
args.term = currentTerm
args.leaderId = self.id
args.prevLogIndex = nextIndex[followerId] - 1
args.prevLogTerm = log[prevLogIndex].term
args.entries = log[nextIndex[followerId]:] // 待复制的条目
args.leaderCommit = commitIndex
// Follower 处理
on receive AppendEntries(args):
// 1. 任期检查
if args.term < currentTerm:
reply(false, currentTerm); return
重置选举计时器 // 承认 Leader 合法
// 2. 日志一致性检查(prevLog 匹配)
if log[args.prevLogIndex].term != args.prevLogTerm:
reply(false, currentTerm) // 告知冲突,Leader 需回退 nextIndex
return
// 3. 追加日志(删除冲突条目,追加新条目)
for i, entry in enumerate(args.entries):
idx = args.prevLogIndex + 1 + i
if idx <= log.lastIndex() and log[idx].term != entry.term:
log.truncateFrom(idx) // 删除冲突及之后所有条目
break
log.append(args.entries)
// 4. 更新 commitIndex
if args.leaderCommit > commitIndex:
commitIndex = min(args.leaderCommit, log.lastIndex())
应用 [lastApplied+1, commitIndex] 的条目到状态机
reply(true, currentTerm)
Leader 处理 AppendEntries 回复
Leader on receive AppendEntries reply from Follower i:
if reply.term > currentTerm:
currentTerm = reply.term
转为 Follower; return
if reply.success:
matchIndex[i] = args.prevLogIndex + len(args.entries)
nextIndex[i] = matchIndex[i] + 1
// 尝试推进 commitIndex
// 找到满足条件的最大 N:
// log[N].term == currentTerm ← 只能提交当前任期的日志!
// matchIndex 中超过半数 >= N
for N = log.lastIndex() downto commitIndex+1:
if log[N].term == currentTerm
and count(matchIndex[i] >= N) > N_nodes/2:
commitIndex = N
break
else:
// 日志冲突,回退 nextIndex 重试
nextIndex[i] -= 1 // 简单回退,可优化为二分查找
立即重发 AppendEntries
nextIndex 与 matchIndex
只有 Leader 维护这两个数组,Follower 互相不感知彼此状态:
nextIndex[i] // 下次要发给 Follower i 的日志索引(乐观估计)
matchIndex[i] // 已确认复制到 Follower i 的最高索引(保守确认)
| 变量 | 初始值 | 更新时机 | 用途 |
|---|---|---|---|
nextIndex[i] |
lastLogIndex + 1 |
成功 +1,失败 -1 回退 | 决定下次发哪些日志 |
matchIndex[i] |
0 |
仅收到 success 后更新 | 决定能否推进 commitIndex |
两个变量分离的原因:nextIndex 是乐观的发送指针,可能被拒绝回退;matchIndex 是保守的确认指针,只有明确收到 success 才更新。合并成一个变量会导致网络延迟期间状态混乱。
新 Leader 当选后不继承这些数据,直接重置并重新探测:
新 Leader 当选:
nextIndex[i] = lastLogIndex + 1 // 乐观假设所有 Follower 都和自己一样新
matchIndex[i] = 0
立即发心跳,prevLog 不匹配的 Follower 回复失败:
nextIndex[i] -= 1 → 重试,直到找到匹配点
几轮心跳内即可重建所有 Follower 的位置信息
nextIndex / matchIndex 是可重新推导的协调信息,不是安全性所必需的持久化数据,丢了只需重新探测,不影响正确性。
Index 的来源
index 只来自客户端写请求,心跳不产生新 index。
推进 lastLogIndex 的唯一路径:
Client → Leader(写请求)
→ Leader 创建 LogEntry,index = lastLogIndex + 1
→ AppendEntries(entries=[新条目]) 发给 Follower
→ Follower 追加到本地日志,lastLogIndex + 1
不推进 lastLogIndex 的操作:
心跳:AppendEntries(entries=[]),entries 为空,index 不变
读请求:不写日志,不产生新 index
投票:RequestVote,不涉及日志写入
心跳只推进 commitIndex,不推进 lastLogIndex:
Leader: lastLogIndex=10, commitIndex=8
发心跳给 Follower(leaderCommit=8):
Follower 的 commitIndex:6 → 8 ✅(应用了 index 7、8 到状态机)
Follower 的 lastLogIndex:10 不变 ❌(没有新日志写入)
日志复制的本质
AppendEntries 的最终效果是:Follower 的日志完全等于 Leader 日志的前缀。
不管 Follower 本地有什么(落后 / 冲突 / 超前):
经过若干轮 AppendEntries 之后,全部向 Leader 对齐
Follower 落后: [1][2][3] → [1][2][3][4][5]
Follower 冲突: [1][2][3][4x][5x] → [1][2][3][4][5]
Follower 超前: [1][2][3][4x][5x][6x]→ [1][2][3][4][5]
安全起点由 prevLog 检查保证,不是覆盖策略本身:
真正的保护机制:
if log[prevLogIndex].term != prevLogTerm:
return false // 拒绝,让 Leader 往前退一步
这确保了覆盖起点之前的日志已与 Leader 完全一致
覆盖后不会产生中间空洞
读请求的处理方式
读请求不写日志,但一致性级别决定走哪里读:
| 方式 | 一致性 | 原理 | 适用场景 |
|---|---|---|---|
| ReadIndex | 线性一致 | Leader 先广播心跳确认自己仍是合法 Leader,再读本地状态机 | 默认推荐 |
| LeaseRead | 线性一致 | Leader 在租约期内(通常 = 选举超时/2)直接读,无需心跳 | 高性能场景(TiKV、etcd) |
| Follower 读 | 弱一致 | 直接读 Follower 本地状态机,可能返回旧数据 | 允许读到几毫秒前数据的场景 |
为什么 Leader 直接读本地也可能返回旧数据?
场景:Leader 被网络分区隔离,新 Leader 已在另一侧选出并写入新数据
旧 Leader 不知道自己已失效,直接读本地 → 旧数据
ReadIndex 的解法:
Leader 收到读请求
→ 记录当前 commitIndex = N
→ 广播心跳,等多数派回复(确认自己仍是合法 Leader)
→ 等 lastApplied >= N
→ 读本地状态机返回
→ 此时一定是最新已提交的数据 ✅
LeaseRead 的解法:
选举超时 = T,心跳间隔 = T/2
Leader 只要在 T/2 内收到过多数派心跳回复
就持有一个"租约":这段时间内不可能有新 Leader 产生
→ 可以直接读本地,无需额外心跳
→ 前提:依赖各节点时钟不能漂移超过选举超时
两阶段提交与客户端响应
Leader 和 Follower 都参与两阶段,但角色不同:
阶段一(写入日志):
Leader:写本地日志 → 发 AppendEntries → 等多数派回复 success
Follower:收到 AppendEntries → 写本地日志 → 回复 success
此时两者都处于"已写入未提交"状态
阶段二(提交):
Leader:收到多数派 success → 提交 → 应用状态机 → ✅ 返回客户端
Follower:收到下次心跳中的 leaderCommit → 推进 commitIndex → 应用状态机
客户端在 Follower 提交之前就收到响应:
Client 收到 success 时:
Leader:已提交 ✓
多数派 Follower:日志已写入,尚未提交 ✗
少数 Follower:可能还未收到
→ 此时立刻读 Follower 可能读到旧数据
→ 这也是为什么读请求默认也走 Leader
等 Follower 提交再返回 = 多一次网络往返,但安全性并不需要:多数派已写入日志,这条数据不会丢(新 Leader 一定包含它),提前返回是安全的。
异常场景分析
异常 1:Leader 宕机(最常见)
场景:Leader 在复制日志后、提交前宕机
状态:
Leader: log=[1,2,3,4],commitIndex=3,entry4 已发出但未确认
Follower1: log=[1,2,3,4] (已收到 entry4)
Follower2: log=[1,2,3] (未收到 entry4)
Follower3: log=[1,2,3] (未收到 entry4)
新选举:
Follower1 或 Follower2/3 都可能成为新 Leader
情况A:Follower1 当选新 Leader
Follower1 日志最新(含 entry4)
新 Leader 继续复制 entry4 给 Follower2/3
entry4 最终被提交 → 数据不丢失 ✅
情况B:Follower2 当选新 Leader(日志不含 entry4)
Follower1 的 entry4 term 是旧 Leader 的 term
新 Leader 不会主动提交旧 term 的日志(安全性限制)
新 Leader 写入新的 entry,提交时顺带将 entry4 也提交
→ entry4 仍会提交 ✅(如果 Follower1 的日志能赢得选举)
但如果 Follower2 日志更新(有更高 term 的条目):
Follower1 无法赢得选举(日志新旧比较失败)
entry4 被 Follower2 的日志截断覆盖
→ entry4 丢失,但这是正确行为!
(entry4 未被多数派持久化,Client 应重试)
异常 2:日志冲突(分区后恢复)
场景(5节点集群):
旧 Leader L1 在任期 3 时写入 entry[5]=X,复制给 F1、F2
L1 与 F3、F4 网络分区
F3 发起选举,任期 4,写入 entry[5]=Y,复制给 F4
此时:
L1: log=[..., (3,X)] commitIndex=4(只有 F1、F2 确认,未达多数)
F1: log=[..., (3,X)]
F2: log=[..., (3,X)]
F3: log=[..., (4,Y)] 新 Leader
F4: log=[..., (4,Y)]
分区恢复后:
L1 收到 F3 的心跳,发现 term=4 > term=3,退为 Follower
F3 向 L1、F1、F2 发送 AppendEntries
prevLogIndex 不匹配 → L1/F1/F2 回复冲突
F3 递减 nextIndex,回退到 entry[4]
F3 发送 entry[4] 开始的日志,L1/F1/F2 截断 entry[5]=X
追加 entry[5]=Y → 日志统一
结论:
entry[5]=X 被丢弃(未提交的日志在冲突时可以被覆盖,这是安全的)
已提交的日志(commitIndex 以内)永远不会被覆盖
异常 3:网络分区导致双 Leader(脑裂)
场景(5节点,分区 {L,F1} | {F2,F3,F4}):
分区 1:L(旧 Leader,term=3)
L 仍然认为自己是 Leader
向 F1 发心跳,F1 回复
L 接受 Client 写入,但只有 F1 确认,无法达到 Quorum
→ 旧 Leader 无法提交新写入 ✅(不影响安全性)
分区 2:{F2,F3,F4}(多数派)
F2 超时,发起选举,term=4
F2 赢得 F3、F4 的投票,成为新 Leader
F2 正常处理写入,可以达到 Quorum
脑裂期间:
有两个 Leader,但只有新 Leader(term=4)能提交
旧 Leader 的写入卡住(无法提交),Client 超时重试
分区恢复后:
旧 Leader L 收到 term=4 的 RPC,退为 Follower
L 的未提交写入被覆盖
→ Safety 完全保证
异常 4:选举超时设置不当
问题:如果选举超时 << 心跳间隔,Follower 会频繁误认为 Leader 宕机而发起选举
正确设置原则:
broadcastTime << electionTimeout << MTBF(平均无故障时间)
broadcastTime:单次 RPC 往返延迟(通常 0.5ms ~ 20ms)
electionTimeout:150ms ~ 300ms(随机化,防止同时超时)
MTBF:服务器平均稳定运行时间(几个月到几年)
随机化的意义:
固定超时 → 多个 Follower 同时超时 → 票数分散 → 无人胜出 → 反复选举
随机超时 → 最先超时的 Follower 优先广播 RequestVote
→ 其他 Follower 收到后重置超时,投票给第一个候选人
→ 通常一轮就能选出 Leader
异常 5:Follower 落后太多(日志差距巨大)
场景:某 Follower 宕机 1 小时后恢复,日志远落后于 Leader
简单回退方案:nextIndex 每次递减 1,需要大量 RPC
优化方案(冲突信息加速回退):
Follower 在拒绝 AppendEntries 时附带额外信息:
{
conflictTerm: log[prevLogIndex].term, // 冲突位置的 term
conflictIndex: 该 term 的第一条日志下标 // 跳过整个 term
}
Leader 收到后:
if 自己日志中存在 conflictTerm:
nextIndex[i] = 自己日志中 conflictTerm 的最后一条 + 1
else:
nextIndex[i] = conflictIndex // 直接跳到冲突 term 的起始位置
极端情况:Follower 落后太多,Leader 已做 Snapshot:
Leader 发送 InstallSnapshot RPC(见日志压缩章节)
安全性保证
Raft 的 Safety 由三层机制共同保证,缺一不可:
| 层次 | 机制 | 防止的问题 |
|---|---|---|
| 第一层 | 同 Term 只投一票 | 同一任期出现两个 Leader |
| 第二层 | 投票时检查日志新旧 | 新 Leader 丢失已提交的旧日志 |
| 第三层 | 只提交当前 Term 的日志 | 旧 Term 日志被错误提交后又遭覆盖 |
第一层:选举安全(Election Safety)
保证:任意时刻最多只有一个 Leader。
机制:
每个节点每个 term 只投一票,持久化 votedFor
假设 A、B 同时竞选 term=5:
A 赢得 quorum Q_A(> N/2)
B 赢得 quorum Q_B(> N/2)
Q_A ∩ Q_B ≠ ∅(两个多数派必有交集)
交集节点已投给 A → 无法再投 B
→ B 不可能同时获得多数票 ✅
第二层:Leader 完整性(Leader Completeness)
保证:一旦某条日志被提交,所有未来的 Leader 都包含该日志,即使所有 Follower 都还没有 commit,这条日志也不会丢失。
原因链条:
第一步:commit 的前提 = 多数派已写入日志
Leader 决定 commit 的条件:
收到多数派(> N/2)AppendEntries success
→ 这条日志已物理写入多数派节点的本地日志文件
(注意:是 write to log,不是 commit)
第二步:多数派写入 → 任何新 Leader 必然包含这条日志
5 节点集群,S1 commit 了 index=5,S1 随即宕机:
S1、S2、S3 的日志里有 index=5(多数派)
S4、S5 没有
新的选举:
S4 想当 Leader → 日志比 S2/S3 旧 → S2/S3 拒绝投票
S5 想当 Leader → 同上
S4+S5 只能拿到 2 票 < 3(多数) → 无法当选
只有 S2 或 S3 能当选(日志足够新)
S2、S3 的日志里都有 index=5 → 新 Leader 必然包含它 ✅
第三步:新 Leader 将这条日志复制给所有人并最终 commit
S2 当选新 Leader:将 index=5 复制给 S4、S5,最终所有节点 commit ✅
本质:两个多数派必有交集(鸽巢原理)
旧多数派(写入了 index=5):{S1, S2, S3}
新 Leader 需要的多数派: {S2, S3, S4} 或 {S2, S3, S5} 等
两个多数派必有交集
交集节点有 index=5,且会拒绝给日志更旧的候选人投票
→ 新 Leader 一定包含所有已 commit 的日志
一句话:Leader commit = 多数派已持久化 = 任何未来 Leader 必然包含它 = 数据永不丢失。Follower 有没有 commit 只是"何时应用到状态机"的问题,不是"数据是否存在"的问题。
第三层:只提交当前 Term 的日志
保证:避免"旧 term 日志被多数派复制后直接提交,随后又被新 Leader 覆盖"的窗口。
时序表(5 节点:S1~S5):
| 时序 | Leader | 事件 | S1 | S2 | S3 | S4 | S5 |
|---|---|---|---|---|---|---|---|
| T1 | S1(t=2) | S1 向 S2 复制 idx=2,随即宕机 | [1,2t2] |
[1,2t2] |
[1] |
[1] |
[1] |
| T2 | S5(t=3) | S5 当选(日志最旧但 S3/S4 投票),写入自己的 idx=2 | — | [1,2t2] |
[1] |
[1] |
[1,2t3] |
| T3 | S1(t=4) | S1 复活当选,将 idx=2(t2) 复制给 S3,达到多数派 | [1,2t2] |
[1,2t2] |
[1,2t2] |
[1] |
[1,2t3] |
| T4❌ | S1(t=4) | ❌ 直接提交 idx=2(t2),随即宕机 | [1,2t2✓] |
[1,2t2] |
[1,2t2] |
[1] |
[1,2t3] |
| T5❌ | S5(t=5) | S5 当选(S5.lastTerm=3 > S3.lastTerm=2),覆盖全部 idx=2 | — | [1,2t3] |
[1,2t3] |
[1] |
[1,2t3] |
| T4✅ | S1(t=4) | ✅ 追加 idx=3(t4) 并提交,idx=2 随之间接提交 | [1,2t2✓,3t4✓] |
[1,2t2✓,3t4✓] |
[1,2t2✓,3t4✓] |
[1] |
[1,2t3] |
| T5✅ | — | S5 落选(S5.lastTerm=3 < S3.lastTerm=4),数据安全 | — | [1,2✓,3✓] |
[1,2✓,3✓] |
[1] |
落选 |
核心差异:
危险路径(T4❌ → T5❌):
S1 提交 idx=2(t2) 后宕机
S5 竞选:S5.lastLogTerm=3 > S3.lastLogTerm=2
→ S3 投票给 S5,S5 胜选,覆盖 idx=2 → 已提交数据丢失 💥
安全路径(T4✅ → T5✅):
S1 先追加 idx=3(t4),提交 idx=3(idx=2 随之间接提交)
S5 竞选:S5.lastLogTerm=3 < S3.lastLogTerm=4
→ S3 拒绝投票,S5 仅得 2 票,落选 ✅
关键:idx=3(t4) 将 S3 的 lastLogTerm 从 2 提升到 4
使 S5(lastLogTerm=3)永远无法再赢得选举
日志压缩(Snapshot)
问题:日志无限增长,重放时间过长,磁盘空间耗尽
解决方案:快照(Snapshot)
状态机周期性将当前状态序列化为 Snapshot
Snapshot 包含:
lastIncludedIndex: 快照覆盖的最后一条日志 index
lastIncludedTerm: 对应的 term
state: 状态机完整状态
快照完成后,删除 lastIncludedIndex 之前的所有日志
InstallSnapshot RPC(Leader 向严重落后的 Follower 发送):
args.term = currentTerm
args.leaderId = self.id
args.lastIncludedIndex = snapshot.lastIncludedIndex
args.lastIncludedTerm = snapshot.lastIncludedTerm
args.data = snapshot.state // 分块传输
Follower on receive InstallSnapshot:
if args.term < currentTerm: return
if log contains args.lastIncludedIndex with matching term:
保留该 index 之后的日志,丢弃之前的
else:
丢弃所有日志
用 args.data 重置状态机
更新 lastIncludedIndex, lastIncludedTerm
成员变更
直接切换的危险:
旧配置 {S1,S2,S3},新配置 {S1,S2,S3,S4,S5}
如果各节点在不同时刻切换配置:
S1 用新配置,多数 = 3:S1+S4+S5 选出 Leader1
S2 用旧配置,多数 = 2:S2+S3 选出 Leader2
→ 脑裂!
联合共识(Joint Consensus):
变更过程分两阶段:
阶段1:进入联合配置 Cold+new
日志需要同时得到旧多数派 AND 新多数派的确认才能提交
Leader 选举也需要两个多数派都同意
阶段2:切换到 Cnew
仅需新配置的多数派
这样任意时刻最多只有一个 Leader,不存在脑裂窗口
PreVote 扩展
PreVote 是 Raft 论文 §9.6 提出的优化,etcd、TiKV 均已实现。
问题根源:分区节点的 Term 膨胀
场景:5 节点集群,S5 被网络分区隔离
S5 在分区期间:
反复选举超时 → currentTerm 不断自增
分区 10 分钟,每 300ms 一次 → currentTerm ≈ +2000
分区恢复后:
S5 重新连接,发送任意 RPC(携带 term=2000+)
所有节点收到更高 Term → 立即退为 Follower
现任 Leader(term=5)被迫退位
集群触发新一轮选举,服务中断数百毫秒
S5 本身日志落后,不可能赢得选举
→ 白白中断了一次服务
PreVote 解法:选举前先"试探"
标准 Raft 选举(两步):
Step 1: currentTerm += 1
Step 2: 广播 RequestVote(term)
PreVote 在 Step 1 之前增加一轮"预询问":
Step 0(PreVote):
不递增 currentTerm
广播 PreVote(term+1),询问"如果我现在发起选举,你会投我吗?"
其他节点回复 PreVoteGranted 的条件:
① 当前没有收到合法 Leader 心跳(Leader 可能真的宕机了)
② 候选人的日志至少和自己一样新
如果收到多数 PreVoteGranted:
→ 真正递增 currentTerm,发起正式 RequestVote
如果 PreVote 被拒绝(Leader 心跳正常):
→ currentTerm 保持不变,不扰乱集群 ✅
效果对比:
不加 PreVote:
S5 分区恢复 → RPC(term=2005) → 所有节点退为 Follower → 选举中断
加 PreVote 后:
S5 发送 PreVote(term=6)
S1~S4 收到后检查:Leader 心跳正常 → 拒绝 PreVote
S5 收不到多数 PreVote → currentTerm 不变,保持 term=5
现任 Leader 不受影响 ✅
PreVote 解决不了的问题
PreVote 只防"诚实但分区"的节点(Term 膨胀是无意的)
如果节点是恶意的:
直接跳过 PreVote,发送 RequestVote(term=9999)
→ 仍然会导致所有节点退为 Follower
PreVote ≠ 安全防御,只是工程优化
算法威胁模型
Raft 的设计假设:崩溃容错(CFT),节点只会崩溃,不会撒谎。
Raft 能容忍:
✅ 节点崩溃(宕机、断电)
✅ 网络分区(节点间通信中断)
✅ 消息延迟、乱序、重复
✅ 分区节点的 Term 膨胀(配合 PreVote)
Raft 不能容忍:
❌ 恶意节点发送伪造/矛盾消息(拜占庭故障)
❌ 攻击者持续发送高 Term RPC(DoS)
❌ 节点伪装成其他节点
实际部署的防御层次:
| 层次 | 手段 | 防御目标 |
|---|---|---|
| 网络层 | mTLS 双向认证(etcd 默认开启) | 外部攻击者无法发送任何 RPC |
| 网络层 | VPC 私网隔离 / 防火墙 | 集群节点只在内网可达 |
| 算法层 | PreVote | 防止内部分区节点的 Term 膨胀 |
| 算法层 | CheckQuorum | Leader 主动检测是否仍有多数派,防脑裂 |
| 算法层 | BFT(PBFT/HotStuff) | 节点本身不可信时(区块链、联盟链) |
结论:Raft 本身不防恶意攻击,这是算法设计范围内的已知局限。工程上通过 mTLS + 网络隔离将威胁模型降级为"节点只会崩溃",使 Raft 的假设成立。需要容忍恶意节点的场景,参见 Byzantine 容错(BFT与PBFT)。
节点数量的选择
容错与延迟的权衡
| 节点数 N | 可容忍故障数 f | 写入需要确认数 | 延迟代价 |
|---|---|---|---|
| 3 | 1 | 2 | 低 ✅ |
| 5 | 2 | 3 | 中 ✅ |
| 7 | 3 | 4 | 较高 |
| 9 | 4 | 5 | 高 ❌ |
容错收益递减:
3 → 5:多容忍 1 台故障,收益显著 ✅
5 → 7:多容忍 1 台故障,但写延迟增加 30~50%
7 → 9:再多容忍 1 台,实际意义越来越小
同时丢失 3 台(7 节点才能容忍)的概率
远低于同时丢失 2 台(5 节点),
边际收益不值得付出延迟代价
→ 生产环境 5 节点是行业默认
为什么必须用奇数节点
4 节点集群,quorum = 3:
发生 2-2 网络分区
两侧各 2 台,均无法达到 quorum=3
→ 整个集群不可写,完全阻塞
4 节点容错能力 = 只能丢 1 台(和 3 节点相同)
4 节点写延迟 = 需要 3 台确认(比 3 节点更高)
→ 偶数节点无任何收益,实际等于退化版的奇数节点
结论:永远用奇数(3、5、7…)
跨机房部署建议
| 机房数 | 推荐配置 | 说明 |
|---|---|---|
| 3 机房 | 3 节点,每机房 1 台 | 任一机房故障,剩余 2 台仍构成多数派 |
| 2 机房 | 5 节点,3+2 分布 | 主机房 3 台可独立达到 quorum,从机房 2 台故障不影响写入 |
| 2 机房 | ❌ 2+2(偶数)或 1+1 | 主机房故障时从机房无法形成多数派,不可用 |
Raft 的实际性能
"Raft 性能差"是个常见误解,需要分清对比基准:
对比单机内存写入:确实差(多了 1~2ms 网络往返)
对比其他强一致分布式方案:相当甚至更好
对比 2PC:没有协调者单点,故障处理更简单
Raft 单次写入实际延迟:
本地写 WAL + 一次 AppendEntries 往返 ≈ 1~5ms(局域网)
对数据库场景完全可接受
真正的吞吐瓶颈在于单 Raft 组所有写入都走同一个 Leader,解法是多组分片(见下节)。
数据规模大时:多 Raft 组 + 分片
共识组本身保持小(3~5 节点),通过横向分片实现扩展:
单 Raft 组的瓶颈:
所有写入 → 同一个 Leader → 单点写入上限(通常数万 QPS)
TiKV / CockroachDB 的分片方案:
数据按 key range 切分为多个 Region
每个 Region = 独立的 3~5 节点 Raft 组
不同 Region 的写入完全并行
Region1(key a~m)→ Raft 组 A,Leader 在节点 S1
Region2(key n~z)→ Raft 组 B,Leader 在节点 S3
Region3(...) → Raft 组 C,Leader 在节点 S2
结果:
每个共识组保持 3~5 节点 → 延迟低
集群可横向扩展至数百节点
写入吞吐量随分片数线性增长
一句话原则:共识组尽量小(3~5 节点),要扩规模就横向分片成多个独立小组,而不是把一个组做大。
与 Paxos 的对比
| 维度 | Raft | Multi-Paxos |
|---|---|---|
| 可理解性 | ✅ 高,子问题分解清晰 | ❌ 低,实现细节分散 |
| 日志连续性 | ✅ 强制连续,按序提交 | Slot 可以乱序提交 |
| Leader 选举 | 内置,基于日志新旧 | 需要额外机制 |
| 成员变更 | Joint Consensus 有明确规范 | 各实现不统一 |
| 性能上限 | 略低(日志必须连续) | 略高(可并行填充 slot) |
| 工程参考 | etcd、TiKV、CockroachDB | Chubby、Spanner |
参考资料
- Ongaro, D. & Ousterhout, J. (2014). In Search of an Understandable Consensus Algorithm (Extended Version)
- 《Designing Data-Intensive Applications》Ch.9 — Linearizability and Consensus
- etcd Raft 实现:https://github.com/etcd-io/raft
评论 (0)
发表评论