Raft 共识算法是怎么工作的?
一句话回答
Raft 让一组节点对同一份操作日志达成一致,各节点按相同顺序执行日志,就得到相同的状态(复制状态机)。它把问题拆成三块:领导者选举(节点分为 Leader、Follower、Candidate,Follower 在随机的选举超时内收不到心跳就发起选举,拿到多数票当选,每个任期最多一个 Leader);日志复制(所有写请求交给 Leader,Leader 把日志复制到多数节点后才提交);安全性(只有日志足够新的节点才能当选,保证已提交的日志不会丢)。5 个节点的集群能容忍 2 个节点故障。etcd、Consul 都基于 Raft。
详细解析
为什么需要共识
单个节点存数据有单点问题,多副本又要解决"副本之间听谁的":网络会丢包、延迟,节点会宕机重启,如果每个副本各自接受写入,数据就分叉了。共识算法保证:只要多数节点正常并能互相通信,系统就能继续工作,并且所有节点看到的已提交日志完全一致。这就是 CAP 里的 CP:少数派一侧的节点不能提供写服务。
Raft 和 Paxos 解决的是同一个问题,容错能力相当。Raft 的设计目标是容易理解,把共识拆成相对独立的子问题,并通过强 Leader 简化了日志管理,所以工程实现大多选 Raft。
角色和任期
| 角色 | 职责 |
|---|---|
| Leader | 处理所有客户端写请求,向 Follower 复制日志,定期发送心跳 |
| Follower | 被动接收 Leader 的日志和心跳,响应投票请求 |
| Candidate | 选举期间的临时角色,向其他节点拉票 |
任期(term) 是一个单调递增的编号,每次选举开始任期加 1,起到逻辑时钟的作用:节点发现对方的任期比自己大,就更新任期并变回 Follower;收到任期比自己小的请求,直接拒绝。
领导者选举
Follower ──选举超时,没收到心跳──> Candidate:任期 + 1,投自己一票,向其他节点发 RequestVote
^ │
│ 发现更大的任期或新 Leader ├── 获得多数票 ──> Leader,立即发送心跳
│ ├── 收到新 Leader 的心跳 ──> 退回 Follower
└──────────────────────────────────┴── 超时,没人获得多数 ──> 任期 + 1,重新选举
- 每个节点在一个任期内最多投一票,先到先得,所以一个任期最多选出一个 Leader
- 选举超时是随机的(论文中的例子是 150~300ms),让节点错开发起选举,减少多个 Candidate 同时拉票、平分选票的情况
日志复制
- 客户端把写请求发给 Leader,Leader 把它追加到自己的日志,每条日志带有任期和下标
- Leader 并行地向 Follower 发送 AppendEntries,里面带着新日志前一条的下标和任期
- Follower 检查自己在那个位置的日志任期是否匹配,不匹配就拒绝;Leader 回退,找到双方一致的位置,从那里开始覆盖 Follower 的日志
- 日志被复制到多数节点后,Leader 把它标记为已提交,应用到状态机,返回客户端
- Leader 在后续的 AppendEntries 和心跳里带上已提交的位置,Follower 据此应用日志
这个检查保证了日志匹配特性:两个节点的日志在某个下标上任期相同,那么这个位置以及之前的所有日志都完全相同。
安全性和网络分区
选举限制:投票时比较双方最后一条日志,任期大的更新,任期相同则更长的更新;候选人的日志不如自己新,就不投票。已提交的日志一定存在于多数节点上,而当选也需要多数票,两个多数派必有交集,所以新 Leader 一定包含所有已提交的日志。
只按计数提交当前任期的日志:Leader 不能因为一条旧任期的日志已经复制到多数节点就提交它(论文图 8 给出了这样做会被覆盖的反例),而是等当前任期的日志过半后,连同之前的日志一起提交。
网络分区:5 个节点分成 {A, B} 和 {C, D, E},旧 Leader A 在少数派一侧:
少数派 {A, B}:A 仍以为自己是 Leader,但写请求复制不到多数节点,无法提交,客户端超时
多数派 {C, D, E}:选举超时后选出新 Leader(任期更大),继续提供服务
分区恢复:A 收到更大任期的消息,退回 Follower,未提交的日志被新 Leader 的日志覆盖
所以不会出现两个 Leader 同时提交数据的"脑裂"。但旧 Leader 如果直接用本地数据响应读请求,可能读到旧值,要保证线性一致读,论文给了两个前提:新 Leader 上任时先提交一条空日志(no-op),确认哪些日志已提交;处理读请求前和多数节点交换一次心跳,确认自己没被取代。etcd 的 ReadIndex 就是按这个思路实现的,读请求不用写日志。
代码示例
两个关键判断:投票时的"日志是否足够新",以及 Leader 推进提交位置。
// 候选人的日志至少和自己一样新,才投票:先比最后一条日志的任期,再比长度
function isUpToDate(candidate, mine) {
if (candidate.lastLogTerm !== mine.lastLogTerm) return candidate.lastLogTerm > mine.lastLogTerm
return candidate.lastLogIndex >= mine.lastLogIndex
}
// 找最大的 N:多数节点的 matchIndex >= N,且 log[N] 属于当前任期
function advanceCommitIndex({ matchIndex, log, currentTerm, commitIndex }) {
const sorted = [...matchIndex].sort((a, b) => b - a) // matchIndex 包含 Leader 自己
const majorityIndex = sorted[Math.floor(matchIndex.length / 2)] // 多数节点都已复制到的位置
for (let n = majorityIndex; n > commitIndex; n--) {
if (log[n].term === currentTerm) return n
}
return commitIndex
}
// 5 个节点,日志下标从 1 开始
const log = [null, { term: 1 }, { term: 1 }, { term: 2 }, { term: 3 }]
console.log(advanceCommitIndex({ matchIndex: [4, 4, 3, 1, 1], log, currentTerm: 3, commitIndex: 1 })) // 1:下标 3 属于旧任期,不能直接提交
console.log(advanceCommitIndex({ matchIndex: [4, 4, 4, 1, 1], log, currentTerm: 3, commitIndex: 1 })) // 4:当前任期的日志过半,连同之前的一起提交
面试官可能追问
为什么集群节点数一般是奇数?
容错能力取决于多数派:3 个节点容忍 1 个故障,4 个节点也只能容忍 1 个(多数是 3),5 个节点容忍 2 个。偶数节点不增加容错能力,反而多一个节点参与复制,写入更慢。节点也不是越多越好,每次写入都要等多数节点确认,常见规模是 3 或 5 个。
一个节点被网络隔离后不停地发起选举,恢复后会干扰集群吗?
会。它的任期会一直增长,重新连上后,更大的任期会让当前 Leader 退位,触发一次不必要的选举。Raft 作者 Diego Ongaro 的博士论文提出了 Pre-Vote:发起正式选举前先"预投票",确认自己能赢得多数票才真正增加任期。etcd 等实现支持这个机制。
日志无限增长怎么办?集群成员怎么变更?
用快照压缩日志:把某个位置之前的状态保存为快照,删掉之前的日志;落后太多的 Follower 直接通过 InstallSnapshot 接收快照。成员变更如果直接从旧配置切到新配置,切换过程中可能出现两个不相交的多数派,论文用联合共识过渡,作者后来又提出了每次只增删一个节点的简化方法。
易错点
- 认为写请求复制到 Leader 就算成功,实际要复制到多数节点才提交
- 认为 Raft 集群只要有节点存活就能写,实际少于多数时整个集群不可写
- 把选举限制理解成"日志更长的当选",实际先比最后一条日志的任期,再比长度
- 以为 Leader 的读一定是最新的,网络分区时旧 Leader 可能读到旧值
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。