分布式一致性原理(四):工程化共识 —— Raft 算法

前言

上一篇我们从集合交集定律推导出了多数派机制,并在此基础上推演了 Basic Paxos 和 Multi-Paxos 的 Leader 优化思想。但我们也提到,Paxos 的原论文只给出了核心思想,留下了大量工程空白:Leader 怎么选?日志怎么复制?故障怎么恢复?

这些空白导致了一个尴尬的现状------每个团队实现的"Paxos"各不相同,相互之间难以对齐,甚至出现了"世界上只有一种共识算法,就是 Paxos,但没人知道它到底是什么"的调侃。

2014 年,Diego Ongaro 和 John Ousterhout 发表了 Raft 算法,设计的首要目标不是性能,而是可理解性(Understandability)。Raft 把共识问题拆解成三个相对独立的子问题:

  1. 领导者选举(Leader Election):谁来当 Leader?
  2. 日志复制(Log Replication):Leader 怎么把数据同步到所有节点?
  3. 安全性(Safety):怎么保证换 Leader 后数据不丢不乱?

本篇将逐一展开这三个子问题。

一、Raft 的设计哲学

强 Leader 模型

在 Basic Paxos 中,任何节点都可以发起提案,这种"人人平等"的设计带来了灵活性,但也带来了活锁和实现复杂度。

Raft 做了一个大胆的简化:一切以 Leader 为中心。 集群中的节点分为 Leader、Follower、Candidate 三种角色(后面会详细介绍),核心规则是:

  • 客户端的所有写请求只能发给 Leader。
  • 日志只能从 Leader 流向 Follower,单向复制,绝不反向
  • Follower 和 Candidate 收到客户端请求时,直接转发给 Leader。
graph TB C["Client"] -->|"写请求"| L["Leader"] L -->|"复制日志"| F1["Follower 1"] L -->|"复制日志"| F2["Follower 2"] L -->|"复制日志"| F3["Follower 3"] L -->|"复制日志"| F4["Follower 4"] F1 -.->|"转发请求"| L F2 -.->|"转发请求"| L style C fill:#f0f0f0 style L fill:#ff6b6b style F1 fill:#4ecdc4 style F2 fill:#4ecdc4 style F3 fill:#4ecdc4 style F4 fill:#4ecdc4

这种"独裁"模型的好处很明显:所有决策由一个节点统一做,不存在并发冲突,实现起来也简单得多。

状态机复制(Replicated State Machine)

💡 什么是状态机? 状态机是一个抽象模型:给定一个初始状态,按顺序输入一系列命令,每条命令都会确定性地将状态从一个值转变为另一个值。相同的初始状态 + 相同的命令序列 = 相同的最终状态。数据库、KV 存储、配置中心本质上都是状态机。

Raft 的目标是让一组服务器表现得像一台可靠的服务器。实现方式是状态机复制

  1. Client 发送命令给 Leader。
  2. Leader 把命令追加到自己的日志中。
  3. Leader 将日志复制给所有 Follower。
  4. 多数派 Follower 确认写入后,Leader 提交该日志,将命令应用到自己的状态机,并回复 Client。
  5. Leader 通过后续心跳将提交进度同步给 Follower,Follower 将已提交的日志应用到各自的状态机。

最终,所有节点持有相同的日志、按相同的顺序执行,状态必然一致:

ini 复制代码
Client 命令 ──> 日志(有序序列)──> 状态机(执行命令)──> 一致的状态

  节点1:  [set x=1] [set y=2] [del x] ──> 状态机 ──> {y=2}
  节点2:  [set x=1] [set y=2] [del x] ──> 状态机 ──> {y=2}
  节点3:  [set x=1] [set y=2] [del x] ──> 状态机 ──> {y=2}
                                            ↑
                                    相同的日志 = 相同的状态

所以 Raft 的核心任务就是:维护一个一致的分布式日志

三种角色

Raft 中的每个节点在任意时刻处于三种状态之一:

  • Leader(领导者) :处理所有客户端请求,负责日志复制。整个集群同一时刻最多只有一个 Leader
  • Follower(跟随者) :被动角色,只响应 LeaderCandidate 的请求,不主动发起任何操作。
  • Candidate(候选人)Follower 在选举超时后转变为 Candidate,发起投票竞选 Leader

三者之间的转换关系:

stateDiagram-v2 %% 1. 定义样式类 (类似于 CSS) %% Follower: 浅蓝背景,深蓝边框 classDef followerStyle fill:#e1f5fe,stroke:#0277bd,stroke-width:2px,color:#01579b,font-weight:bold %% Candidate: 浅黄背景,深橙边框 (警示色,代表中间状态) classDef candidateStyle fill:#fff9c4,stroke:#fbc02d,stroke-width:2px,color:#f57f17,font-weight:bold %% Leader: 浅绿背景,深绿边框 (成功色,代表正常工作) classDef leaderStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:4px,color:#1b5e20,font-weight:bold %% 2. 绘图与应用样式 %% 注意::::styleName 紧跟在节点名称后面 [*] --> Follower:::followerStyle: 节点启动 Follower --> Candidate:::candidateStyle: 选举超时 (心跳中断) Candidate --> Leader:::leaderStyle: 获得多数派投票 Candidate --> Follower: 收到 Leader 心跳 / 发现更高 Term Candidate --> Candidate: 选举超时 (瓜分选票,无人胜出) Leader --> Follower: 发现更高 Term (退位) %% 强制图例说明(可选,不需要可以删掉下面这行) note left of Follower: 初始状态

所有节点启动时都是 Follower。只有在超时未收到 Leader 心跳时,才会转为 Candidate 发起选举。

💡 选举超时(Election Timeout):每个节点内部维护一个随机计时器(通常 150ms~300ms)。Follower 每次收到 Leader 心跳就重置计时器;如果计时器到期仍未收到心跳,就认为 Leader 已失联,转为 Candidate 发起选举。Candidate 在选举期间如果超时未拿到多数派投票,也会触发同一计时器重新选举。

二、领导者选举(Leader Election)

任期(Term):Raft 的逻辑时钟

分布式系统中没有全局物理时钟,Raft 用**任期(Term)**来充当逻辑时钟。

  • 每个任期由一个递增的整数标识:Term 1, Term 2, Term 3...
  • 每个任期最多只有一个 Leader(也可能选举失败,没有 Leader)。
  • 任期是 Raft 判断信息新旧的唯一标准------如果一个节点收到了更高任期的消息,说明自己的信息已经过时,必须立刻"让位"。
--- config: theme: forest --- timeline title Raft 任期时间线 Term 1 : 选举 → Leader A 当选 : 正常服务 Term 2 : 选举失败(选票瓜分) : 无 Leader Term 3 : 选举 → Leader B 当选 : 正常服务 Term 4 : 选举 → Leader C 当选 : 正常服务

任期的规则很简单:

  • 节点发起选举时,将自己的 Term +1。
  • 节点在通信中发现对方的 Term 比自己大 → 立刻更新自己的 Term,并退回 Follower 状态。
  • 节点收到 Term 比自己小的请求 → 直接拒绝。

选举流程

当一个 Follower 在**选举超时(Election Timeout)**时间内没有收到 Leader 的心跳,它就认为 Leader 挂了,发起选举:

  1. 自增当前 Term。
  2. 转变为 Candidate 状态。
  3. 给自己投一票。
  4. 向所有其他节点发送 RequestVote RPC。
--- config: theme: forest look: neo --- sequenceDiagram participant F1 as 节点A (Follower→Candidate) participant F2 as 节点B (Follower) participant F3 as 节点C (Follower) Note over F1: 选举超时,未收到心跳
Term: 1 → 2
给自己投一票 rect rgb(240, 248, 255) F1->>F2: RequestVote(Term=2, CandidateId=A) F1->>F3: RequestVote(Term=2, CandidateId=A) F2-->>F1: 投票给 A ✅ F3-->>F1: 投票给 A ✅ end Note over F1: 获得 3/3 票(含自己)
当选 Leader 🎉 rect rgb(240, 255, 240) F1->>F2: AppendEntries(心跳) F1->>F3: AppendEntries(心跳) end Note over F1,F3: A 开始以 Leader 身份服务
定期发送心跳维持权威

每个节点在同一个 Term 内只能投一票 ,先到先得。这保证了同一任期内最多只有一个节点能获得多数派投票,即最多一个 Leader

投票三结局

Candidate 发起选举后,有三种可能的结局:

结局一:赢得选举 → 获得多数派投票,成为 Leader,立即开始发送心跳。

结局二:别人赢了 → 在等待投票的过程中,收到了来自新 Leader 的 AppendEntries 心跳(且 Term ≥ 自己的 Term)。说明已经有人当选了,自己乖乖退回 Follower。

结局三:没人赢(选票瓜分) → 多个 Candidate 同时发起选举,把选票分散了,谁都没拿到多数派。超时后重新选举。

第三种情况如果不处理,可能会反复发生------多个节点总是同时超时、同时发起选举、同时瓜分选票。

随机超时:打破对称

Raft 用一个极其简单的方式解决选票瓜分:随机化选举超时时间

每个节点的选举超时从一个固定区间(例如 150ms~300ms)中随机选取。这样,大多数情况下只有一个节点最先超时并发起选举,在其他节点超时之前就已经赢得了多数派投票。

css 复制代码
节点A 超时:|████████████████░░░░░░░░░░| 187ms → 率先发起选举
节点B 超时:|████████████████████████░░| 263ms → 还没超时就收到A的投票请求
节点C 超时:|██████████████████████████| 291ms → 还没超时就收到A的心跳

论文中的实验数据表明,150ms~300ms 的随机超时区间在大多数网络环境下,选举通常在一轮内完成。

三、日志复制(Log Replication)

Leader 选出来了,接下来的核心任务是:把客户端的命令安全地复制到所有节点

日志的结构

每条日志(Log Entry)包含三个要素:

  • 索引(Index):日志在序列中的位置,从 1 开始递增。
  • 任期(Term):创建该日志时 Leader 的任期号。
  • 命令(Command):客户端请求的具体操作。
ini 复制代码
日志序列示意:

Index:   1         2         3         4         5
Term:   [1]       [1]       [2]       [2]       [2]
Cmd:  [set x=1] [set y=2] [del x]  [set z=3] [set x=5]

复制流程

当 Leader 收到客户端的写请求时:

--- config: theme: forest look: neo --- sequenceDiagram participant C as Client participant L as Leader participant F1 as Follower 1 participant F2 as Follower 2 C->>L: set x = 42 Note over L: 1. 追加到本地日志 rect rgb(240, 248, 255) Note over L,F2: 2. 并行发送 AppendEntries RPC L->>F1: AppendEntries(日志: set x=42) L->>F2: AppendEntries(日志: set x=42) F1-->>L: 成功 ✅ F2-->>L: 成功 ✅ end Note over L: 3. 多数派确认(含自己 3/3)
提交日志,执行命令 L-->>C: 返回结果: OK rect rgb(240, 255, 240) Note over L,F2: 4. 下一次心跳通知 Follower 提交 L->>F1: AppendEntries(commitIndex 更新) L->>F2: AppendEntries(commitIndex 更新) Note over F1: 执行 set x=42 Note over F2: 执行 set x=42 end

关键点:

  1. Leader 先写本地日志,然后并行发送给所有 Follower,不需要等所有人都回复。
  2. 只要多数派(含 Leader 自己)确认写入,Leader 就认为日志已提交(Committed),可以执行命令并回复客户端。
  3. Follower 通过后续的 AppendEntries 得知 Leader 的 commitIndex 更新后,在本地执行已提交的日志。

⚠️注意,并非每个 Follower 都会立刻成功------有的节点可能因为日志不一致而拒绝,有的节点可能已经宕机。但这不会阻塞客户端请求:Leader 只需要多数派确认就够了,不一致的节点会在后续的 AppendEntries 中被逐步修复(下文"一致性检查"会详述)。

那如果连多数派都凑不够呢? :比如 5 节点挂了 3 个(注意,宕机不等于退出集群,多数派仍按总数 5 计算,需要 3 票 )。此时日志无法提交,Leader 不会回复客户端,请求会一直挂起,直到客户端自身超时放弃。Raft 不会主动返回错误------宁可不响应也不给错误的结果。这正是第三篇中 CAP 定理的体现:节点故障超过半数时,Raft 牺牲可用性来保证一致性

AppendEntries RPC:不只是"追加"

AppendEntries 是 Raft 中最核心的 RPC,它同时承担三个职责:

  1. 日志复制:携带新的日志条目让 Follower 追加。
  2. 心跳维持:即使没有新日志,Leader 也定期发送空的 AppendEntries 来维持自己的权威,防止 Follower 超时发起选举。
  3. 提交通知 :通过 leaderCommit 字段告诉 Follower "哪些日志已经可以执行了"。

一致性检查

Leader 发送 AppendEntries 时,会附带前一条日志的 Index 和 Term (称为 **prevLogIndex **和 prevLogTerm)。

Follower 收到后会检查:我在 prevLogIndex 位置的日志,Term 是否等于 prevLogTerm

  • 匹配 → 说明到这个位置为止,我和 Leader 的日志是一致的,可以安全追加新日志。
  • 不匹配 → 拒绝,Leader 会将 prevLogIndex 回退一位重试。
ini 复制代码
场景:Leader 发送 AppendEntries,prevLogIndex=4, prevLogTerm=2

Leader 日志:   [1|1] [2|1] [3|2] [4|2] [5|2]   ← 要追加 index=5

Follower A:    [1|1] [2|1] [3|2] [4|2]          ← index=4 的 Term=2 ✅ 匹配,追加
Follower B:    [1|1] [2|1] [3|1]                 ← index=4 不存在 ❌ 拒绝
Follower C:    [1|1] [2|1] [3|2] [4|3]          ← index=4 的 Term=3≠2 ❌ 拒绝

这个机制通过逐步回退,最终一定能找到 Leader 和 Follower 日志一致的那个点,然后从那个点开始覆盖 Follower 的日志。

Leader 的强制覆盖

当 Follower 的日志和 Leader 不一致时,Raft 的处理原则很霸道:以 Leader 为准,强制覆盖 Follower 的冲突日志

这背后的逻辑是:Leader 的日志一定包含所有已提交的条目(下一节安全性中会严格论证),所以用 Leader 的日志覆盖 Follower 不会丢失任何已提交的数据。

来看一个具体的修复过程:

ini 复制代码
初始状态(Leader 任期为 3):

Leader:     [1|1] [2|1] [3|2] [4|2] [5|3]
Follower:   [1|1] [2|1] [3|2] [4|3] [5|3] [6|3]
                                ↑
                          从这里开始分叉(index=4 的 Term 不同)

修复过程:

第1次: Leader 发送 prevLogIndex=4, prevLogTerm=2
       Follower 检查 index=4 → Term=3 ≠ 2 → 拒绝 ❌

第2次: Leader 回退, 发送 prevLogIndex=3, prevLogTerm=2
       Follower 检查 index=3 → Term=2 = 2 → 匹配 ✅

第3次: Leader 从 index=4 开始发送自己的日志
       Follower 删除 index≥4 的旧日志,接受 Leader 的版本

修复后:
Leader:     [1|1] [2|1] [3|2] [4|2] [5|3]
Follower:   [1|1] [2|1] [3|2] [4|2] [5|3]    ← 与 Leader 一致 ✅

注意 Follower 原来有 6 条日志,修复后只有 5 条------多出来的、未提交的日志被丢弃了。这是安全的,因为这些日志从未被多数派确认过,不算"已提交"。

四、安全性(Safety)

选举和日志复制只是 Raft 的"骨架"。如果没有额外的安全性约束,上述机制其实存在漏洞。本节回答两个关键问题:谁能当 Leader?什么时候才能提交日志?

选举限制:不是谁都能当 Leader

假设没有任何限制,任意 Follower 都能当选 Leader。考虑这个场景:

less 复制代码
5 节点集群,当前 Leader 是 A(Term=2):

节点A (Leader): [1|1] [2|1] [3|2] [4|2] [5|2]   ← index 5 已提交(多数派确认)
节点B:          [1|1] [2|1] [3|2] [4|2] [5|2]
节点C:          [1|1] [2|1] [3|2] [4|2] [5|2]
节点D:          [1|1] [2|1]                        ← 落后很多
节点E:          [1|1] [2|1]                        ← 落后很多

如果 A、B、C 同时宕机,D 当选新 Leader:
- D 只有 2 条日志,index 3~5 的已提交数据全部丢失!

Raft 的解决方案:Candidate 在 RequestVote 中携带自己最后一条日志的 Index 和 Term。投票者只会把票投给日志"不比自己旧"的 Candidate。

"不比自己旧"的比较规则:

  1. 先比 Term:最后一条日志的 Term 越大越新。
  2. Term 相同则比 Index:Index 越大越新。
ini 复制代码
投票判断示例:

Candidate 最后日志: (Index=5, Term=2)
Voter 最后日志:     (Index=3, Term=3)

比较: Candidate Term=2 < Voter Term=3 → Candidate 更旧 → 拒绝投票 ❌

---

Candidate 最后日志: (Index=5, Term=2)
Voter 最后日志:     (Index=3, Term=2)

比较: Term 相同,Candidate Index=5 > Voter Index=3 → Candidate 更新 → 投票 ✅

这条规则保证了新 Leader 在所有 Candidate 中日志是最新的,也为上一节"Leader 强制覆盖"提供了安全性基础------Leader 日志最全,覆盖别人不会丢数据。

不过,选举比较的是节点实际持有的最后一条日志,不区分它是否已提交。这意味着一条从未被多数派确认的高 Term 日志,也能让节点在比较中胜出。所以光有选举限制还不够,我们还需要对"提交"本身做约束。

提交限制:当前任期提交原则

来看一个经典的反例:

ini 复制代码
5 节点集群,时间线如下:

T1: 节点S1 是 Leader(Term=2),将 index=2 的日志(Term=2)复制给了 S2
    S1: [1|1] [2|2]
    S2: [1|1] [2|2]
    S3: [1|1]
    S4: [1|1]
    S5: [1|1]

# s5 此前已通过心跳同步到 Term=2
T2: S1 宕机,S5 发起选举(Term=2+1=3),当选 Leader(Term=3),收到新日志但还没复制就宕机了
    S5: [1|1] [2|3]

T3: S1 恢复,重新当选 Leader(Term=4),继续复制 index=2(Term=2)给 S3
    S1: [1|1] [2|2]   ← 此时 index=2 已经在 S1、S2、S3 上,构成多数派
    S2: [1|1] [2|2]
    S3: [1|1] [2|2]
    S4: [1|1]
    S5: [1|1] [2|3]
    
    ⚠️ 如果此时 S1 提交 index=2(Term=2),然后 S1 再次宕机------
    S5 可能当选(S5 最后日志的 Term=3 > S2、S3 最后日志的 Term=2,日志更"新")
    S5 会用 [2|3] 覆盖掉已经"提交"的 [2|2]
    
    💥 已提交的数据被覆盖了!

问题出在哪?S1 在 Term=4 时提交了一条旧任期(Term=2)的日志 。虽然这条日志已经被多数派写入,但它的 Term 太旧了,无法阻止一个持有更高 Term 日志的节点(S5)当选并覆盖它

Raft 的解决方案:Leader 选举成功后不直接提交旧任期的日志,而是先写入一条当前任期的日志(可以是客户端的新请求,也可以是一条空的 no-op 日志)。

旧任期的日志怎么办?它们会在当前任期的日志被提交时间接提交------因为日志是有序的,提交了 index=3(Term=4),意味着 index≤3 的日志都已提交。

ini 复制代码
正确做法:

T3: S1 当选 Leader(Term=4),先写入一条当前任期的日志
    S1: [1|1] [2|2] [3|4]    ← 新日志 Term=4
    
    将 index=3(Term=4)复制到多数派后提交
    此时 index=2(Term=2)也被间接提交

    即使 S1 再宕机,S5 也无法当选了:
    S2、S3 持有 Term=4 的日志,比 S5 的 Term=3 更新
    S5 拿不到多数派投票 ✅

安全性保证总结

  • 选举限制:确保了选出来的 Leader 日志够全------日志落后的节点拿不到多数派选票,当不了 Leader。

  • 提交限制:确保了提交的日志 Term 够高------只提交当前任期的日志,旧任期日志间接提交,不给未提交的高 Term 日志可乘之机。

选出来的够全,提交的够高,二者合力保证了 Raft 的核心安全性------Leader 完备性(Leader Completeness Property)

如果一条日志在某个任期内被提交,那么所有更高任期的 Leader 一定包含这条日志。

换句话说:已提交的数据,无论换多少任 Leader,都不会丢。

graph TD A["🗳️ 选举限制
选出来的 Leader 日志够全"] --> C["🔒 Leader 完备性
新 Leader 一定包含所有已提交日志"] B["📝 提交限制
提交的日志 Term 够高"] --> C C --> D["✅ 强制覆盖安全
被覆盖的一定是未提交数据"] C --> E["✅ 客户端承诺
回复 OK 的写入永不丢失"] style A fill:#4ecdc4 style B fill:#4ecdc4 style C fill:#ff6b6b,color:#fff style D fill:#ffe66d style E fill:#ffe66d

五、成员变更(Membership Change)

生产环境中,集群节点不可能一成不变------要扩容、要缩容、要替换故障机器。但在一个正在运行共识协议的集群中"换人",远比想象中危险。

直接切换的危险

假设一个 3 节点集群要扩容到 5 节点。如果所有节点在同一时刻直接从旧配置切换到新配置,理论上没问题。但分布式系统中不存在"同一时刻"------不同节点切换的时间点不同,就会出现两个多数派共存的窗口:

css 复制代码
旧配置:{A, B, C}         多数派 = 2
新配置:{A, B, C, D, E}   多数派 = 3

如果 A、B 已切换到新配置,C、D、E 还在旧配置(或刚加入):

  旧配置视角:{A, B, C} 中 C 还认旧配置,C 自己就能和某个节点凑成多数派(2/3)
  新配置视角:{A, B, ...} 已切换,A、B 加上 D 也能凑成多数派(3/5)

  💥 两个多数派可能选出两个 Leader!

单节点变更(Single-server Changes)

Raft 推荐的方案是最保守也最安全的:每次只增加或移除一个节点

为什么一次只变一个就安全?因为旧配置和新配置的多数派一定有交集

ini 复制代码
旧配置 3 节点,加 1 个变 4 节点:
  旧多数派 = 2,新多数派 = 3
  2 + 3 = 5 > 4 → 必有交集 ✅

旧配置 4 节点,加 1 个变 5 节点:
  旧多数派 = 3,新多数派 = 3
  3 + 3 = 6 > 5 → 必有交集 ✅

有交集就意味着:不可能同时存在两个网络分区的多数派,也就不可能选出两个 Leader。这和 Quorum 的集合交集定律是同一个道理。

具体流程:

  1. Leader 收到成员变更请求(比如"添加节点 D")。
  2. Leader 将新配置作为一条特殊的日志条目追加并复制。
  3. 这条日志一旦写入,节点立即使用新配置,不需要等到提交。
  4. 新配置日志被多数派确认后提交,变更完成。

如果要从 3 节点扩到 5 节点,就分两步:先 3→4,等稳定后再 4→5。

联合共识(Joint Consensus)

单节点变更简单安全,但每次只能加减一个,大规模变更需要多轮操作。Raft 还给出了联合共识方案,允许一次性从旧配置切换到任意新配置:

  1. Leader 先进入过渡配置 C_old,new :任何决策都需要同时获得旧配置的多数派和新配置的多数派
  2. 过渡配置提交后,Leader 再切换到新配置 C_new
  3. C_new 提交后,变更完成,旧配置中不在新配置里的节点可以下线。
graph LR A["C_old
旧配置"] -->|"Leader 提交过渡配置"| B["C_old,new
联合共识"] B -->|"Leader 提交新配置"| C["C_new
新配置"] style A fill:#4ecdc4 style B fill:#ffe66d style C fill:#4ecdc4

过渡阶段需要"双多数派"确认,保证了在任何时刻都不会出现两个独立的多数派。代价是实现复杂度较高,实际工程中大多选择单节点变更。

六、日志压缩(Log Compaction)

问题:日志不能无限增长

Raft 的所有数据都以日志形式存储。随着系统运行,日志会不断增长:

  • 磁盘空间被持续消耗。
  • 节点重启时需要重放所有日志才能恢复状态,耗时越来越长。
  • 新节点加入时需要同步全量日志,网络开销巨大。

Snapshot 机制

Raft 的解决方案是快照(Snapshot):定期将当前状态机的完整状态保存为一个快照文件,然后丢弃快照之前的所有日志。

ini 复制代码
快照前:
  日志: [1|1] [2|1] [3|2] [4|2] [5|3] [6|3] [7|3] [8|4]
  状态机: {x=5, y=2, z=3}

执行快照(截止到 index=6):
  快照文件: { lastIndex=6, lastTerm=3, state={x=5, y=2, z=3} }
  日志: [7|3] [8|4]    ← index≤6 的日志全部丢弃

快照中需要记录:

  • lastIncludedIndex:快照包含的最后一条日志的 Index。
  • lastIncludedTerm:该日志的 Term。
  • 状态机数据:当前的完整状态。

每个节点独立决定何时做快照,不需要 Leader 协调。常见策略是日志大小超过阈值时触发。

InstallSnapshot RPC

当 Follower 落后太多,Leader 需要发送的日志已经被快照覆盖(被丢弃了)时,Leader 无法通过正常的 AppendEntries 同步数据。此时 Leader 会通过 InstallSnapshot RPC 直接把快照发给 Follower:

ini 复制代码
场景:Follower 落后太多

Leader 日志:     [快照: 截止index=100] [101|5] [102|5] [103|5]
Follower 日志:   [1|1] [2|1] [3|2]    ← 远远落后

Leader 无法发送 index=4~100 的日志(已被快照覆盖)
→ 直接发送 InstallSnapshot

Follower 收到后:
1. 丢弃自己的全部日志
2. 用快照恢复状态机
3. 从 index=101 开始正常接收 AppendEntries

这个机制也让新节点加入集群变得简单------直接接收当前快照,不需要从第一条日志开始同步。

总结

本篇完整介绍了 Raft 算法:

  • 领导者选举:通过任期(Term)实现逻辑时钟,通过随机超时打破选票瓜分,保证每个任期最多一个 Leader。
  • 日志复制 :Leader 将日志并行复制到 Follower,多数派确认后提交。通过 prevLogIndex/prevLogTerm 做一致性检查,不一致时逐步回退并强制覆盖。
  • 安全性:选举限制保证新 Leader 日志最全,提交限制避免旧任期日志被错误覆盖。两者共同保证已提交的数据永远不会丢失。
  • 成员变更:单节点变更利用多数派交集保证安全,联合共识支持一次性大规模变更。
  • 日志压缩:Snapshot 机制定期保存状态机快照,丢弃旧日志,解决日志无限增长和节点恢复慢的问题。

Raft 的成功在于它把 Multi-Paxos 的思想拆解成了可独立理解的模块,每个模块的规则都足够清晰。这也是为什么 Nacos、etcd、Consul等主流系统都选择了 Raft 而非直接实现 Paxos。

下篇预告:

Raft 是共识算法领域的"现代标准",但它不是唯一的选择。在 Raft 之前,ZooKeeper 的 ZAB 协议就已经在 Hadoop 生态中大规模落地。ZAB 和 Raft 同为强 Leader 模型,但在选举策略和数据恢复上走了不同的路。

下一篇,我们将深入 ZAB 协议,看看它和 Raft 之间的殊途同归与关键差异。


思考题

  1. 为什么 Raft 要求每个节点在同一个 Term 内只能投一票?如果允许投多票会怎样?

参考答案
会导致同一任期内出现两个 Leader(脑裂)。

具体场景(5 节点):

  • 节点 A 和 B 同时发起选举(Term=2)
  • 如果允许投多票:节点 C 先投给 A,又投给 B
  • A 获得 {A, C, D} 三票 → 当选 Leader
  • B 获得 {B, C, E} 三票 → 也当选 Leader
  • 同一任期两个 Leader,各自接受写请求,数据分裂

总结:每个 Term 只投一票,保证了多数派的排他性------同一任期内不可能有两组多数派分别选出不同的 Leader。这和 Paxos 中 Acceptor 承诺不再接受更小编号的道理一样。

  1. 如果 Leader 刚提交日志就宕机了,还没来得及通知任何 Follower 更新 commitIndex,这条日志会丢吗?

参考答案
不会丢。

  • "提交"的含义:日志被多数派写入,不是 Leader 单方面决定的。Leader 宕机时,多数派节点上已经持久化了这条日志。
  • 新 Leader 选举:由于选举限制,只有拥有最新日志的节点才能当选新 Leader。新 Leader 的日志中一定包含这条已提交的日志。
  • 恢复过程:新 Leader 上任后,通过正常的日志复制流程,会把这条日志同步到所有节点,并最终更新 commitIndex。

总结:commitIndex 的通知丢了不要紧,只要日志本身已经被多数派持久化,它就不会丢。新 Leader 一定会"继承"并"重播"它。

  1. 为什么 Leader 不能直接提交旧任期的日志?只看"多数派已写入"不够吗?

参考答案
不够,因为"多数派写入"不能阻止更高 Term 的节点覆盖它。

核心问题:

  • 旧任期的日志虽然被多数派写入了,但选举比较的是"最后一条日志的 Term"
  • 另一个节点如果持有更高 Term 的日志(即使只有一条),在日志比较中它就更"新"
  • 如果这个节点联合其他没有旧日志的节点形成多数派,它就能当选 Leader
  • 当选后它会用自己的日志覆盖掉那些已被"提交"的旧 Term 日志

解法:Leader 只提交当前任期的日志。当前任期的日志被多数派写入后,持有它的节点在选举比较中一定胜出(Term 最大),从而阻止了没有这条日志的节点当选。旧任期的日志随着当前任期日志的提交被间接提交,安全性得到保证。

  1. 假设集群中有 5 个节点,Leader 发送了一条日志但只有 1 个 Follower 确认了(加上 Leader 自己是 2/5),此时 Leader 宕机。这条日志会被提交吗?

参考答案
不确定------取决于新 Leader 是谁。

  • 如果新 Leader 有这条日志(即那个已确认的 Follower 当选):新 Leader 会继续复制这条日志,最终可能被提交。
  • 如果新 Leader 没有这条日志(另外 3 个节点之一当选):这条日志会被新 Leader 的日志覆盖,永远不会被提交。

关键点:只有被多数派写入的日志才算"已提交"。2/5 没有达到多数派(需要 3/5),所以这条日志处于"未提交"状态,命运取决于后续选举结果。这不是 bug,而是 Raft 的设计保证:未被多数派确认的数据,本来就不应该被承诺给客户端。

相关推荐
三水不滴1 小时前
SpringBoot + Redis 滑动窗口计数:打造高可靠接口防刷体系
spring boot·redis·后端
老迟聊架构1 小时前
深入理解低延迟与高吞吐:从架构哲学到技术抉择
后端·架构
小亮✿2 小时前
算法—并查集
数据结构·c++·算法
hrhcode2 小时前
【Netty】一.Netty架构设计与Reactor线程模型深度解析
java·spring boot·后端·spring·netty
三水不滴2 小时前
千万级数据批处理实战:SpringBoot + 分片 + 分布式并行处理方案
spring boot·分布式·后端
流云鹤2 小时前
2026牛客寒假算法基础集训营2(A B I F E H)
算法
Lun3866buzha2 小时前
紧固件智能检测与分类_ATSS_R101_FPN_1x_COCO算法解析与Pytorch实现
pytorch·算法·分类
笨蛋不要掉眼泪2 小时前
从单体到分布式:一次完整的架构演进之旅
分布式·架构
MSTcheng.2 小时前
【Leetcode二分查找】『在排序数组中查找元素的第一个和最后一个位置&搜索插入位置』
算法·leetcode·职场和发展