Skip to content

Raft内部原理与生产治理:etcd/raft状态机、日志复制、线性读与故障恢复 ​

本页从 Raft 论文的不变量进入工程实现,再对照 etcd/raft 的 raft、raftLog、Progress、RawNode、Ready、Storage、readOnly 和 Changer 解释一条提议怎样经过持久化、网络复制、Quorum 提交、状态机 Apply 与客户端响应。基础概念先看 Paxos、Raft、ZAB 与多数派,etcd 的 MVCC、Txn、Watch 和 Lease 继续看 etcd 专栏。

一、真实性、源码与版本基线 ​

本页在 2026-07-19 依据以下资料核验:

  • Raft Extended Paper:选主、日志复制、安全性、成员变更和客户端交互;
  • Diego Ongaro Dissertation:PreVote、成员变更、读和实现讨论;
  • etcd/raft v3.7.0:最新源码审计基线;
  • raft.go:Term、Campaign、PreVote、CheckQuorum、Leader 初始化、提交与消息状态机;
  • log.go:日志匹配、冲突检测、Commit、Apply、Restore;
  • tracker/progress.go:Follower 的 Probe、Replicate、Snapshot 三态与 Inflights;
  • rawnode.go、node.go:Tick、Step、Ready、Advance 与持久化顺序;
  • read_only.go:ReadIndex Quorum 确认;
  • confchange/confchange.go:Simple Change、Joint Consensus、Learner;
  • storage.go:HardState、Entries、Term、Snapshot 与 Compaction;
  • etcd v3.7.0 与 HashiCorp Raft v1.7.3 用于产品与实现边界交叉核对。

版本边界必须明确:

范围版本工具链本页用途
最新源码审计go.etcd.io/raft/v3 v3.7.0要求 Go 1.26核验最新类/函数、消息与状态语义
实际运行 Demogo.etcd.io/raft/v3 v3.6.0Go 1.23.6三节点选主、分区、冲突覆盖、恢复、ReadIndex 测试
旧 Java 客户端JDK 8 / Boot 2.7与 Raft 实现解耦关注超时、幂等、连接和 SDK 版本
现代 Java 客户端Java 17+ / Boot 3.x与 Raft 实现解耦关注观测、异步上下文、优雅停机和现代 SDK

v3.6 与 v3.7 的生成代码/API 细节已有变化,例如 protobuf 值类型与指针/getter 形式不同;本文只把跨版本稳定语义当作原理,把具体 Go 字段写法放在明确版本的 Demo 中。

二、学完后必须真正会什么 ​

  1. 写出 Raft 五个核心 Safety Property,并说明它们如何互相支撑。
  2. 区分 Persistent State、Volatile State、Leader-only Progress。
  3. 解释 Tick → Step → Ready → 持久化/发送/Apply → Advance 的工程闭环。
  4. 说明投票和 Append ACK 为什么必须在相关 HardState/Entries 持久化后发送。
  5. 解释 PreVote、CheckQuorum 与普通 RequestVote 分别防什么故障。
  6. 解释 Leader 上任为什么追加当前 Term 空条目。
  7. 解释 nextIndex、matchIndex、Probe、Replicate、Snapshot 和 Inflights。
  8. 解释 prevLogIndex/prevLogTerm、RejectHint 和按 Term 快速回退。
  9. 推导“Leader 只能按多数计数直接提交当前任期条目”的原因。
  10. 区分 lastIndex、committed、applied、stable 和业务结果。
  11. 说清 ReadIndex Safe、LeaseBased Read、Follower Stale Read 的安全前提。
  12. 解释 Snapshot 为何必须带 LastIncludedIndex/Term/ConfState 并原子安装。
  13. 解释 Joint Consensus 为什么要求旧、新配置两个 Quorum 同时通过。
  14. 说明 Learner 为什么追平后才能 Promote 为 Voter。
  15. 能按无 Leader、选举风暴、复制积压、Apply Lag、Snapshot 卡住执行 Runbook。

三、Raft 解决什么,不解决什么 ​

Raft 让多个非拜占庭节点对一串日志条目的顺序和提交达成一致,再把相同确定性命令按序 Apply 到复制状态机。它重点解决:

  • Leader 选举;
  • 日志复制;
  • 已提交日志不被覆盖;
  • 节点宕机、恢复和网络分区下的安全推进;
  • 成员配置变化。

它不自动解决:

  • 订单库、支付库和库存库的跨服务事务;
  • 客户端超时后的业务“恰好一次”;
  • 非确定性状态机导致的副本结果差异;
  • 节点恶意篡改消息的拜占庭故障;
  • 磁盘控制器虚假确认持久化;
  • 业务数据文件自动适合放进共识日志。

Raft 保证的是协议层安全性。业务仍需请求 ID、幂等结果表、查询确认、状态机、补偿和对账。

四、Raft 五个核心安全性质 ​

性质含义被破坏后的后果
Election Safety一个 Term 最多一个合法 Leader同任期出现两个可提交历史的 Leader
Leader Append-OnlyLeader 只追加自己的日志,不改写/删除自身已有条目已确认前缀可被 Leader 主动改写
Log Matching两日志若某 Index/Term 相同,则此前前缀相同复制状态机从相同位置得到不同历史
Leader Completeness已提交条目会出现在所有更高 Term Leader 日志中新 Leader 覆盖已提交业务事实
State Machine Safety一个节点在某 Index Apply 某命令后,其他节点不会在同 Index Apply 不同命令副本最终状态永久分叉

这些不是五个互相独立的开关。Election Restriction 让拥有足够新日志的候选更可能获票;日志匹配规则修复未提交冲突;当前 Term 提交限制把“多数复制”与 Leader Completeness 连起来;状态机只 Apply 已提交前缀,最终得到 State Machine Safety。

五、节点状态为什么必须分层 ​

5.1 必须持久化的状态 ​

  • currentTerm:见过的最高任期;
  • votedFor:当前 Term 投给哪个候选;
  • 日志条目:index + term + command/type。

它们必须在对外发送依赖该事实的投票响应或复制 ACK 前落到稳定存储。否则节点重启后可能在同 Term 投两票,或确认自己已保存但日志实际丢失。

5.2 可从集群重新获知的易失状态 ​

  • commitIndex:已知已提交最高位置;
  • lastApplied:已 Apply 到状态机最高位置;
  • 当前 Leader ID;
  • 选举和心跳计时器。

commitIndex 即使没每次同步落盘,也能从 Leader 重新传播;但条目和投票事实不能靠猜恢复。

5.3 Leader 针对每个 Follower 的 Progress ​

  • Match:已确认 Follower 拥有的最高日志 Index;
  • Next:下一次准备发给该 Follower 的 Index;
  • State:Probe、Replicate 或 Snapshot;
  • RecentActive:近期是否有响应;
  • Inflights:在途 Append 窗口;
  • PendingSnapshot:正在发送/等待安装的 Snapshot Index。

这些 Progress 在 Leader 切换后可重建,不属于跨重启必须持久化的协议事实。

六、为什么日志身份是 (Term, Index) ​

Index 只表示日志位置,Term 表示该条目由哪个领导任期产生。仅比较 Index 会把不同 Leader 在同一位置写出的冲突命令误认为相同。

text
node-A: (index=5, term=2, command=SET x=1)
node-B: (index=5, term=3, command=SET x=2)

两条日志 Index 都是 5,但 Term 不同,必须视为冲突。prevLogIndex + prevLogTerm 是 AppendEntries 的前置匹配点:前置点相同才能推导它之前的日志前缀相同。

七、etcd/raft 不是完整服务器:它是一台确定性状态机 ​

go.etcd.io/raft 负责协议状态转移,不替应用完成真实网络、磁盘、业务状态机或 Snapshot 文件传输。应用负责驱动:

mermaid
flowchart TD
    A["定时器调用Tick"] --> B["网络消息和提议进入Step"]
    B --> C["Raft计算新的Ready"]
    C --> D["应用持久化HardState、Entries、Snapshot"]
    D --> E["发送允许发送的Raft消息"]
    E --> F["Apply已提交条目到业务状态机"]
    F --> G["调用Advance确认处理完成"]

这层分离的好处是:协议算法可以被确定性测试;存储可替换成 WAL/数据库;传输可替换成 gRPC、HTTP/2 或自定义协议。代价是集成方若处理 Ready 顺序错误,会把一个正确算法集成为不安全系统。

八、Ready/Advance 契约:最容易写错的工程边界 ​

Ready 可能包含:

  • SoftState:Leader/角色等无需持久化的变化;
  • HardState:Term、Vote、Commit;
  • Entries:尚未写入稳定存储的日志;
  • Snapshot:需要持久化/安装的快照;
  • Messages:准备发送的 Raft 消息;
  • CommittedEntries:可 Apply 的已提交条目;
  • ReadStates:完成 Quorum 确认的安全读索引。

同步存储模式的安全顺序:

mermaid
flowchart TD
    A["取得Ready"] --> B["原子或有序保存Snapshot"]
    B --> C["保存HardState与Entries"]
    C --> D["需要时执行fsync"]
    D --> E["发送依赖持久化结果的消息"]
    E --> F["Apply CommittedEntries"]
    F --> G["Advance并取得下一批Ready"]

etcd/raft v3.7 MustSync() 会在存在新 Entries,或 Term/Vote 改变时要求同步;仅 Commit 前进通常不必再次同步全部日志,因为相关条目早已持久化。具体 WAL 原子性仍由应用实现。

8.1 为什么投票响应必须晚于持久化 Vote ​

  1. 节点收到候选 A 的 RequestVote。
  2. 内存中把 votedFor=A,立即回复同意。
  3. 还没落盘就断电。
  4. 重启后忘了投过 A,又给候选 B 投票。
  5. 同一 Term 可能形成两个不同多数派。

因此 currentTerm/votedFor 必须先稳定存储,再发送 VoteResp。

8.2 为什么 Append ACK 必须晚于日志持久化 ​

Follower 若先 ACK、后落盘,Leader 可能把它计入多数并回复客户端;Follower 随后断电丢日志,若其他带该日志节点也故障,已提交条目可能消失。协议里的“复制到多数”隐含的是满足持久化要求的多数,不是多数节点内存里短暂看过。

九、Tick、Heartbeat 与随机选举超时 ​

Tick 推进逻辑时钟,不等于固定一秒。应用按自己的调度周期调用 Tick,配置中的 HeartbeatTick/ElectionTick 是 Tick 数量。

通常需要满足:

text
heartbeat interval
  < election timeout
  且 election timeout 能覆盖正常网络P99、磁盘fsync、调度停顿和短GC

选举超时太短:网络抖动、Stop-The-World、CPU 抢占、慢 fsync 都会制造假故障和 Term 风暴。太长:Leader 真故障后恢复写入的时间变长。不能脱离部署延迟分布照抄“150–300ms”。跨地域集群尤其要按真实 RTT 和磁盘尾延迟设计。

随机化让多个 Follower 不容易同一时刻发起选举。它提高活性概率,不是安全性来源;即使同时竞选,投票和日志规则仍必须保证安全。

十、PreVote 为什么能减少重连扰动 ​

没有 PreVote 时,一个长期隔离节点会反复选举并增加自己的 Term。它重新连入集群后,以很高 Term 让稳定 Leader 退位,即使自己的日志太旧、最终也选不上 Leader,仍造成一次无意义中断。

PreVote 流程:

mermaid
flowchart TD
    A["Follower选举超时"] --> B["以未来Term发送PreVote"]
    B --> C["接收方检查日志新旧和领导租约"]
    C --> D["候选取得PreVote多数"]
    D --> E["此时才增加Term并进入正式RequestVote"]
    E --> F["正式多数后成为Leader"]

源码关键点:处理 MsgPreVote 时不会直接更新本地 Term;只有预投票获多数才进入正式 Candidate。PreVote 主要减少离群节点造成的 Term 扰动,不保证永远不会选举,也不能替代网络和资源治理。

十一、RequestVote 的完整判断 ​

接收者至少判断:

  1. 候选 Term 是否有效;
  2. 本 Term 是否尚未投票,或已投给同一候选;
  3. 候选最后日志是否至少与本地一样新;
  4. 启用 CheckQuorum/Leader Lease 优化时,近期有效 Leader 是否仍在租约窗口;
  5. 投票结果是否已持久化后再响应。

日志新旧比较:先比较 LastLogTerm,Term 更大者更新;Term 相同再比较 LastLogIndex。不能只比较日志长度。

text
本地最后日志:(term=8, index=100)
候选最后日志:(term=9, index=80)

候选虽然更短,但最后 Term 更高,按 Raft 的新旧定义仍更新。这个规则与 Leader Completeness 的证明配合使用。

十二、CheckQuorum:旧 Leader 为什么会主动退位 ​

etcd/raft 的 CheckQuorum 会记录各 Progress 的 RecentActive。Leader 若在一个 ElectionTimeout 内无法确认多数 Voter 活跃,会退回 Follower。

它带来的收益:

  • 少数派旧 Leader 更快拒绝/停止接受无意义提议;
  • Leader Lease 读获得必要前提之一;
  • 客户端更快发现该节点无法形成 Quorum。

边界:

  • 退位本身不提交任何日志;
  • 非对称网络可能让不同节点观察不同活跃状态;
  • CheckQuorum 不等于完美故障检测;
  • 即使角色还显示 Leader,未获得多数复制也不能提交。

十三、Leader 上任为什么追加空条目 ​

etcd/raft becomeLeader() 会重置 Progress,把自己转到 Replicate,并追加一个当前 Term 的空 Entry。它不是无意义心跳,主要作用是:

  1. 建立当前 Term 的日志位置;
  2. 该条目复制到多数后,可按当前 Term 规则推进 Commit;
  3. 一并确认此前安全前缀;
  4. ReadIndex 在当前 Term 尚无已提交条目前需要等待,空条目提交后可释放安全读;
  5. 让 Leader 建立对 Followers Progress 的真实认知。

空条目仍要持久化、复制和 Apply(业务 Data 为空通常不执行命令),不是绕过 Quorum 的特殊提交。

十四、Follower Progress 三态 ​

状态Leader 行为使用时机
Probe每轮少量探测,确认 Follower 匹配点,避免大量错误 Append刚成为 Leader、收到 Reject、Snapshot 完成后
Replicate乐观推进 Next,允许流水线发送多批 Append日志稳定匹配、Follower 正常追随
Snapshot暂停普通日志复制,等待 Snapshot 传输/安装结果Follower 所需日志已被 Compact
mermaid
flowchart TD
    A["新Leader把Follower置为Probe"] --> B["探测匹配日志位置"]
    B --> C["收到成功AppendResp"]
    C --> D["进入Replicate并流水线复制"]
    D --> E["Follower严重落后且日志已压缩"]
    E --> F["进入Snapshot并暂停普通Append"]
    F --> G["安装完成后回到Probe或Replicate"]

14.1 Match 与 Next 的不变量 ​

通常保持:

text
0 <= Match < Next

Match 是已确认事实;Next 是乐观或探测起点。收到过期/乱序 Reject 不能无条件把 Next 大幅回退,否则旧消息会破坏已经确认的进度。

14.2 Inflights 为什么必要 ​

Replicate 状态会流水线发送多批日志。Inflights 限制在途消息数和字节数:

  • 太小:高 RTT 环境吞吐不足;
  • 太大:Follower 慢时占满内存和网络,重传成本高;
  • 单条日志过大:即使消息数量少,也会形成带宽和 GC 尖峰。

十五、AppendEntries 完整复制链路 ​

mermaid
flowchart TD
    A["Leader从Follower.Next选择日志"] --> B["携带PrevIndex、PrevTerm、Entries、LeaderCommit"]
    B --> C["Follower先校验前置日志"]
    C --> D["匹配后删除冲突的未提交尾部"]
    D --> E["持久化新Entries"]
    E --> F["发送AppendResp确认Match"]
    F --> G["Leader更新Match、Next与Inflights"]
    G --> H["重新计算Quorum Commit"]

若前置点不匹配,Follower 不应追加后续 Entries,而是返回 Reject、RejectHint 和相关 Term 信息;Leader 调整 Next 后再探测。

十六、日志冲突与按 Term 快速回退 ​

最朴素实现每次 Reject 只让 nextIndex--,日志差距百万条时会往返百万次。工程实现利用冲突提示跳过整段相同 Term。

示例:

text
Leader:   1/1 2/1 3/2 4/2 5/4 6/4 7/5
Follower: 1/1 2/1 3/2 4/2 5/3 6/3
                     从index=5开始冲突

Follower 可返回冲突附近 Index/Term;Leader 用 findConflictByTerm 寻找“不大于该 Term 的最大猜测位置”,一次跳过同 Term 段。无论如何优化,都必须满足:

  • 已提交前缀绝不能截断;
  • 只覆盖冲突的未提交尾部;
  • 乱序 Reject 不得回退已经确认的 Match;
  • Snapshot/Compaction 边界无法查 Term 时要转入 Snapshot 路径。

十七、为什么多数复制还不总能直接提交 ​

Leader 计算 Voters 的 MatchIndex Quorum 位置,但 Raft 只通过这种“多数计数”直接提交 当前 Term 的条目。一旦当前 Term 条目提交,它之前的所有前缀(包括旧 Term)一起成为已提交历史。

原因来自经典 Figure 8 失败窗口:旧 Term 条目虽然短暂复制到多数,但这些“多数”可能不是同一时刻的稳定交集;若尚无当前 Term 条目把历史锁定,后续更高 Term 候选仍可能凭更高最后 Term 获票并覆盖该旧条目。直接按旧 Term 多数计数提交会造成两个冲突结果都被认为提交。

mermaid
flowchart TD
    A["Leader复制当前Term条目"] --> B["各Follower持久化后返回AppendResp"]
    B --> C["Progress.Match形成当前配置Quorum"]
    C --> D["候选Index处Term等于Leader当前Term"]
    D --> E["推进commitIndex"]
    E --> F["此前日志前缀一并确定"]
    F --> G["各节点按序Apply到状态机"]

etcd/raft v3.7 的 maybeCommit() 将当前 r.Term 与 Quorum Committed Index 一起交给 raftLog.maybeCommit(),后者只有 Index 前进且该位置 Term 匹配时才 Commit。

十八、Append、Persist、Commit、Apply、Respond 五个阶段 ​

阶段表示什么能否回复业务成功
AppendLeader 内存日志已有 Entry不能
Persist本节点稳定存储已有 Entry不能,尚未 Quorum
Commit协议确认该位置不会被未来 Leader 覆盖共识成立,但状态机结果可能尚未产生
Apply确定性命令已执行到本地状态机通常可得到业务结果
Respond客户端收到成功响应仍可能丢失,客户端超时仍不确定

不同产品可能在 Commit 后异步 Apply,也可能等本地 Apply 完成后返回具体 Txn 结果。排障必须区分:

text
lastIndex >= commitIndex >= appliedIndex

大量 commitIndex - appliedIndex 表示共识已决定但状态机执行跟不上;继续提高网络复制速度不会解决 Apply Lag。

十九、客户端超时与 Exactly Once 边界 ​

典型窗口:

  1. 客户端发送 requestId=R100。
  2. Leader 把命令复制到多数并 Commit。
  3. 状态机 Apply 成功。
  4. Leader 返回响应前宕机或网络丢包。
  5. 客户端看到超时。

客户端不能用新 requestId 盲目再执行。复制状态机应保存请求会话或幂等结果:

text
clientId + sequence/requestId -> 已执行结果

重试同一 ID 时返回历史结果。若产品只提供 KV 原语,业务可用 Compare-And-Swap、唯一键或状态机版本实现幂等。Raft 保证各副本一致地执行“去重逻辑”,但不会自动替业务生成去重规则。

二十、为什么 Leader 本地读也不天然线性一致 ​

旧 Leader 进入少数派后可能暂时仍认为自己是 Leader,本地状态也很新,但另一个多数分区已选出更高 Term Leader并提交新值。旧 Leader 直接本地读会返回过期结果。

常见读模式:

模式做法一致性与代价
Follower Local/Stale Read直接读本地已 Apply 状态延迟低,可能陈旧
Leader Barrier/ReadIndex Safe用心跳上下文确认当前仍握有 Quorum线性一致,增加 Quorum 确认/等待
LeaseBased Read在受控租约窗口内相信领导权延迟低,依赖时钟/暂停假设
写入日志做读屏障提交一个 Entry 后读取安全但每次读都写日志,成本高

二十一、ReadIndex Safe 完整流程 ​

mermaid
flowchart TD
    A["客户端发起线性一致读"] --> B["Leader确认当前Term已有已提交Entry"]
    B --> C["在Heartbeat中携带唯一Read Context"]
    C --> D["收到当前配置Quorum的HeartbeatResp"]
    D --> E["返回安全ReadIndex"]
    E --> F["等待本地appliedIndex大于等于ReadIndex"]
    F --> G["读取状态机并返回"]

两个条件缺一不可:

  1. Quorum 回应证明当前 Leader 仍处于有效领导关系;
  2. 状态机已 Apply 到 ReadIndex,证明本地状态包含该屏障之前所有已提交写。

etcd/raft 的 ReadIndex 请求可能丢失,调用方需要超时重试;ReadState 返回也不等于可以立刻读,必须检查 Apply 进度。

21.1 为什么先要求当前 Term 已提交 Entry ​

Leader 刚上任时,还不能仅凭旧 Term Commit 状态安全处理 ReadIndex。等待当前 Term 空条目提交,把当前领导权与日志历史建立安全联系后,才释放 Pending ReadIndex 请求。

二十二、LeaseBased Read 为什么更快也更挑环境 ​

LeaseBased 模式依赖:Leader 在一个受控时间窗口内确认多数活跃,并相信在窗口结束前不会有另一个合法 Leader。etcd/raft 源码明确要求 LeaseBased 必须启用 CheckQuorum。

但进程时钟可能倒退或长时间暂停,GC/虚拟化暂停也可能超过预期。若时钟/暂停没有可信上界,租约读的安全假设会变弱。生产选型要回答:

  • 使用单调时钟还是墙上时钟;
  • 最大暂停和调度延迟有没有可验证上界;
  • 跨地域 RTT 与 ElectionTimeout 的关系;
  • 读错旧值的业务后果;
  • 是否接受 ReadIndex 多一次 Quorum 确认换取更清晰安全性。

二十三、Snapshot 不只是日志压缩文件 ​

Snapshot 至少要表达:

  • 状态机在某个一致点的完整数据;
  • LastIncludedIndex;
  • LastIncludedTerm;
  • 当时生效的 ConfState;
  • 校验和、版本和业务 Schema 等实现元数据。

创建 Snapshot 的安全边界通常是 appliedIndex,不能对尚未 Apply 的 Commit 位置声称状态机已经包含。Compact 只删除 Snapshot 已覆盖的旧日志,且要保留边界 Term 供后续日志匹配。

23.1 Snapshot、WAL 与业务状态机必须对应 ​

若 Snapshot 数据对应 Index 100,但 Metadata 写成 120,恢复节点会错误跳过 101–120;若 Metadata 是 100、数据却已包含到 120,重放 101–120 又会重复执行。Snapshot 数据和 (Index, Term, ConfState) 必须来自同一一致点。

二十四、落后 Follower 安装 Snapshot 全过程 ​

mermaid
flowchart TD
    A["Leader发现Follower.Next早于FirstIndex"] --> B["Progress进入Snapshot并暂停普通Append"]
    B --> C["传输Snapshot数据与Metadata"]
    C --> D["Follower校验版本、校验和和成员配置"]
    D --> E["原子替换状态机与Raft快照状态"]
    E --> F["Follower报告安装成功并恢复日志探测"]
    F --> G["从Snapshot Index之后继续Append"]

关键失败窗口:

  • Snapshot 文件传到一半进程崩溃:不能暴露半文件为有效快照;
  • 状态机已替换但 Raft Metadata 未更新:恢复时重复 Apply;
  • Raft Metadata 更新但状态机替换失败:跳过实际未执行命令;
  • Leader 一直处于 Snapshot Progress,但传输失败未报告:Follower 永远不再收到 Append;
  • 安装旧 Snapshot 覆盖本地更新状态:必须拒绝 Out-of-date Snapshot。

etcd/raft 的 ReportSnapshot 失败报告很重要:Leader 在 Snapshot 状态会暂停普通探测,应用若吞掉失败,Progress 可能长期卡住。

二十五、Compaction 为什么不能超过 Applied ​

Storage 的 FirstIndex() 表示仍能通过 Entries 获取的第一条日志;更早日志已经由 Snapshot 覆盖。Compact 必须满足:

text
snapshotIndex <= appliedIndex
compactIndex <= 已有可恢复Snapshot覆盖位置

压缩未 Apply 或尚无 Snapshot 保护的日志,节点重启时无法重建业务状态。压缩过慢则 WAL/日志无限增长;压缩过快会使稍微落后的 Follower 频繁走大 Snapshot,反而增加网络和磁盘压力。生产要按日志增长速度、Follower 最大可接受落后窗口和 Snapshot 成本平衡。

二十六、Joint Consensus 为什么需要两个 Quorum ​

直接从 {A,B,C} 切到 {C,D,E},旧配置多数 {A,B} 与新配置多数 {D,E} 可以完全不相交。两个分区可能分别按各自配置提交冲突结果。

Joint Consensus 过渡状态:

text
C_old,new = majority(A,B,C) AND majority(C,D,E)
mermaid
flowchart TD
    A["旧配置C_old正常工作"] --> B["提交EnterJoint配置条目"]
    B --> C["每次决策同时满足旧、新两个Quorum"]
    C --> D["新节点追平并参与新配置"]
    D --> E["提交LeaveJoint配置条目"]
    E --> F["仅C_new成为投票配置"]

etcd/raft Changer.EnterJoint() 把 Incoming Voters 复制为 Outgoing 配置;LeaveJoint() 移除 Outgoing;Simple() 只允许一次聚合变化最多改变一个 Voter,并拒绝在 Joint 状态做 Simple Change。

26.1 为什么一次只能有一个未 Apply 配置变更 ​

Leader 的 pendingConfIndex 防止前一个配置条目尚未 Apply 时继续提议第二个变更。因为 Quorum 计算所依据的成员集合必须和日志中的配置历史一致;并发无序改成员会让“当前到底谁有投票权”不明确。

二十七、Learner 为什么不能直接当 Voter ​

新节点刚加入时可能没有日志和 Snapshot。若立即成为 Voter:

  • 3 节点加一个空节点变 4 Voter,多数从 2 变 3;
  • 再有一个旧节点故障,集群可能立刻失去 Quorum;
  • 空节点磁盘/网络追平会拖慢复制;
  • 误配置地址可能直接降低可用性。

安全流程:

  1. 以 Learner/Non-voter 加入,不计入 Quorum;
  2. 传 Snapshot 或追日志;
  3. 观察 MatchIndex、AppliedIndex、Snapshot 状态和资源;
  4. 与 Leader 足够接近后 Promote;
  5. 再移除旧 Voter;
  6. 每一步等待配置条目 Commit + Apply。

Learner 不提高故障容忍,它只是成员迁移的安全缓冲。

二十八、Leadership Transfer 不是直接改一个字段 ​

有计划维护时可把领导权转给健康、日志已追平的目标:

  1. 选择目标 Voter;
  2. 若目标落后,先继续 Append 使其 Match 追上;
  3. Leader 暂停或拒绝新的普通 Proposal,避免目标永远追不上;
  4. 向目标发送 TimeoutNow/等价触发;
  5. 目标以更高 Term 发起选举;
  6. 获得 Quorum 后成为新 Leader;
  7. 超时或目标不可达则取消 Transfer。

它不能保证零延迟,也不适合把领导权转给落后 Learner。直接杀 Leader 也能触发选举,但会增加不可控恢复窗口。

二十九、移除节点和节点 ID 复用风险 ​

移除节点必须通过已提交配置条目完成,不是删除本地配置文件。被移除节点可能仍运行、持有旧日志并发送消息;PreVote/CheckQuorum 能减少扰动,但运维仍应停止旧进程和隔离旧证书。

不要随意复用旧 Node ID:协议用 ID 关联投票权、Progress、消息来源和持久化历史。用新机器带空磁盘冒充旧 ID,或旧磁盘同时在两台机器启动,都会破坏实现假设。成员替换应先移除旧成员,再以新唯一 ID 加 Learner。

三十、WAL、Snapshot、状态机的崩溃一致性 ​

生产存储至少要处理:

  • Torn Write:一条记录只写了一部分;
  • fsync 返回和磁盘真实持久化语义;
  • WAL Record 校验和;
  • Snapshot 临时文件、校验、原子 Rename;
  • HardState 与 Entries 的有序/原子提交;
  • 状态机 Apply 与 AppliedIndex 的恢复;
  • 崩溃后重复 Apply 的幂等或原子 Batch。

推荐的恢复思路:

mermaid
flowchart TD
    A["加载最新完整Snapshot"] --> B["恢复Snapshot中的ConfState与状态机"]
    B --> C["校验并重放Snapshot之后WAL"]
    C --> D["恢复HardState、Entries与Commit"]
    D --> E["按序Apply尚未Apply的Committed Entries"]
    E --> F["加入Raft消息处理和选举"]

若业务状态机和 AppliedIndex 不在同一原子事务中,崩溃后可能不知道最后一条是否执行。常见办法是状态机写入与 AppliedIndex 同事务,或让命令按 requestId 幂等重放。

三十一、复制状态机为什么必须确定性 ​

所有节点收到相同日志并不保证状态相同,前提是 Apply 结果确定。危险命令:

java
// 每个副本执行时得到不同结果
record.setCreatedAt(System.currentTimeMillis());
record.setToken(UUID.randomUUID().toString());
record.setOwner(localHostName());

应由 Leader/客户端在提议前把确定值写进命令:

json
{
  "requestId": "R100",
  "createdAt": 1784440000123,
  "token": "fixed-token-from-command",
  "operation": "CREATE_CONFIG"
}

浮点、Locale、时区、Map 无序遍历、外部 HTTP 调用和本地文件状态也可能产生非确定结果。Apply 阶段不应再访问副本各自不同的外部系统。

三十二、提议积压与反压 ​

Leader 可以接受 Proposal 的速度若长期高于 Quorum Persist + Apply 速度,会产生:

  • Uncommitted Log Tail 增长;
  • 内存与 WAL 快速增长;
  • Follower Inflights 饱和;
  • Apply Lag 扩大;
  • 客户端超时后重试进一步放大;
  • Snapshot 更频繁、更大。

etcd/raft 有未提交大小限制、MaxSizePerMsg、MaxInflightMsgs/Bytes 等边界,但产品还应在入口按并发、字节和延迟做 Admission Control。只限制请求数、不限制单条命令大小,会被少量大 Value 打穿。

三十三、Raft 写延迟由什么组成 ​

稳定 Leader 下,一次写的关键路径近似:

text
客户端到Leader网络
+ Leader WAL append/fsync
+ 至少最快Quorum Followers网络与fsync
+ Leader推进Commit
+ 本地状态机Apply
+ 返回客户端

尾延迟通常由 Quorum 中较快多数决定,不需要等待所有节点;但最慢节点长期落后会增加 Snapshot、磁盘和恢复风险。性能优化维度:

  • Proposal Batch,摊薄 fsync;
  • 合理 MaxSizePerMsg 与 Inflights;
  • 独立低尾延迟磁盘;
  • 避免大 Entry;
  • Apply 状态机批处理;
  • 限制跨地域 Quorum 距离;
  • 监控而不是忽略慢 Follower;
  • 将大文件放对象存储,只在日志中提交引用和校验摘要。

三十四、Multi-Raft 为什么不是“运行很多独立线程” ​

TiKV、CockroachDB 等分片系统会有大量 Raft Group,每个范围一条独立共识日志。工程挑战变成:

  • 多 Group 共享网络和磁盘;
  • 调度 Tick/Ready,避免每 Group 一个线程;
  • 批量 fsync 与消息;
  • 热 Range 导致单 Group 热点;
  • Split/Merge 本身需要一致性元数据;
  • Snapshot 并发占满 I/O;
  • 跨 Group 事务仍需更高层协议。

Multi-Raft 提升分片并行度,不把跨 Group 操作自动变成单 Raft 事务。

三十五、常见产品怎样使用 Raft ​

产品Raft 负责额外层次/边界
etcd写命令顺序、成员和 CommitMVCC、Revision、Txn、Watch、Lease 在 Apply/存储层
Consul ServerCatalog/KV/控制面状态Agent、Gossip、健康检查不是 Raft 日志本身
Kafka KRaftController 元数据 QuorumTopic Partition 数据副本协议不能简单等同元数据 Raft
TiKV每个 Region 的复制和 LeaderMVCC、调度、分裂、事务在更高层
CockroachDBRange 复制与租约基础SQL、MVCC、事务和分布式执行在更高层
HashiCorp Raft提供 Raft 库和 FSM/Snapshot 接口API、存储、网络、FSM 由集成产品实现

不要把“用了 Raft”推导成所有 API 都线性一致。产品可能暴露 Serializable/Stale Read、Lease Read、异步 Watch 或本地缓存;必须看具体 API 语义。

三十六、JDK 8 与 Java 17+ 客户端边界 ​

Raft 集群常由 Go/Rust/C++ 实现,Java 版本不会改变协议;它影响客户端和微服务接入:

维度JDK 8 / Boot 2.7Java 17+ / Boot 3.x
SDK使用与 JDK 8 兼容的 etcd/Consul 客户端版本使用现代 SDK、Jakarta 与新观测体系
HTTP/gRPC老 Netty/gRPC 依赖需锁定兼容矩阵可用更新 gRPC/Netty,但仍要防依赖冲突
观测Sleuth/Micrometer 旧线Micrometer Observation/Tracing 主线
并发CompletableFuture/线程池仍需正确传播 Deadline;虚拟线程不减少 Quorum 延迟

两条线共同要求:稳定 requestId、总 Deadline、只对可安全重试操作重试、区分超时与未提交、不要在本地缓存上假装强一致。

三十七、商业场景:配置中心写入与服务订阅 ​

以“支付路由配置版本 42”为例:

  1. 管理端生成 requestId=config-pay-route-42。
  2. 请求被转发给当前 Leader。
  3. Leader 追加包含完整确定值的配置命令。
  4. Ready 把 Entries/HardState 交给 WAL,持久化后才发送 Append。
  5. 多数 Voter 持久化并 ACK。
  6. Leader 按当前 Term 规则 Commit。
  7. 各节点 Apply 到 KV/MVCC,产生产品层 Revision。
  8. Leader 在本地 Apply 后返回 Revision 及 requestId 结果。
  9. Watch 把 Revision 事件推给微服务;Watch 不是 Commit 本身。
  10. 客户端超时后用相同 requestId 查询/重试,不创建版本 43。
  11. 微服务收到配置后原子替换本地快照,失败则保留 Last Known Good。

Raft 日志 Index、etcd Revision、业务配置 Version 是三个不同概念,不能互相硬编码等同。

三十八、真实可运行 Demo:三节点 etcd/raft 分区恢复 ​

本 Demo 使用:

text
Go 1.23.6
go.etcd.io/raft/v3 v3.6.0

它验证:

  1. PreVote + RequestVote 选出 node-1;
  2. set version=7 复制并在三节点 Apply;
  3. 隔离 node-1 后,set version=8 只在旧 Leader 未提交尾部,任何节点都不 Apply;
  4. node-2/node-3 多数分区选出新 Leader;
  5. set version=9 在多数派提交;
  6. 网络恢复后,旧 Leader 的 version=8 冲突尾部被覆盖;
  7. 三节点最终只 Apply version=7 和 version=9;
  8. ReadIndex 完成后验证 applied >= readIndex;
  9. Ready 处理顺序先持久化 Snapshot/HardState/Entries,再投递依赖消息。

38.1 go.mod ​

go
module example.com/raft-internals-demo

go 1.23

require go.etcd.io/raft/v3 v3.6.0

38.2 raft_demo_test.go ​

go
package raftdemo

import (
    "fmt"
    "sort"
    "testing"

    "go.etcd.io/raft/v3"
    pb "go.etcd.io/raft/v3/raftpb"
)

type peer struct {
    id        uint64
    raw       *raft.RawNode
    storage   *raft.MemoryStorage
    applied   uint64
    commands  []string
    readIndex map[string]uint64
}

type link struct {
    from uint64
    to   uint64
}

type testCluster struct {
    peers   map[uint64]*peer
    blocked map[link]bool
}

func newTestCluster(t *testing.T, ids ...uint64) *testCluster {
    t.Helper()
    c := &testCluster{
        peers:   make(map[uint64]*peer),
        blocked: make(map[link]bool),
    }
    bootstrapPeers := make([]raft.Peer, 0, len(ids))
    for _, id := range ids {
        bootstrapPeers = append(bootstrapPeers, raft.Peer{ID: id})
    }
    for _, id := range ids {
        storage := raft.NewMemoryStorage()
        raw, err := raft.NewRawNode(&raft.Config{
            ID:              id,
            ElectionTick:    10,
            HeartbeatTick:   1,
            Storage:         storage,
            MaxSizePerMsg:   1 << 20,
            MaxInflightMsgs: 256,
            CheckQuorum:     true,
            PreVote:         true,
        })
        if err != nil {
            t.Fatalf("new raw node %d: %v", id, err)
        }
        if err := raw.Bootstrap(bootstrapPeers); err != nil {
            t.Fatalf("bootstrap node %d: %v", id, err)
        }
        c.peers[id] = &peer{
            id:        id,
            raw:       raw,
            storage:   storage,
            readIndex: make(map[string]uint64),
        }
    }
    c.drain(t)
    return c
}

func (c *testCluster) drain(t *testing.T) {
    t.Helper()
    for rounds := 0; rounds < 10000; rounds++ {
        progressed := false
        for _, id := range c.sortedIDs() {
            p := c.peers[id]
            if !p.raw.HasReady() {
                continue
            }
            progressed = true
            rd := p.raw.Ready()

            // 同步Ready契约:先持久化,再发送依赖消息。
            if !raft.IsEmptySnap(rd.Snapshot) {
                if err := p.storage.ApplySnapshot(rd.Snapshot); err != nil {
                    t.Fatalf("node %d apply snapshot: %v", id, err)
                }
            }
            if !raft.IsEmptyHardState(rd.HardState) {
                if err := p.storage.SetHardState(rd.HardState); err != nil {
                    t.Fatalf("node %d set hard state: %v", id, err)
                }
            }
            if err := p.storage.Append(rd.Entries); err != nil {
                t.Fatalf("node %d append entries: %v", id, err)
            }

            for _, ent := range rd.CommittedEntries {
                c.applyEntry(t, p, ent)
            }
            for _, state := range rd.ReadStates {
                p.readIndex[string(state.RequestCtx)] = state.Index
            }

            messages := append([]pb.Message(nil), rd.Messages...)
            p.raw.Advance(rd)
            for _, message := range messages {
                if c.blocked[link{from: id, to: message.To}] {
                    continue
                }
                destination := c.peers[message.To]
                if destination != nil {
                    if err := destination.raw.Step(message); err != nil {
                        t.Fatalf("deliver %s from %d to %d: %v",
                            message.Type, id, message.To, err)
                    }
                }
            }
        }
        if !progressed {
            return
        }
    }
    t.Fatal("raft cluster did not become idle")
}

func (c *testCluster) applyEntry(t *testing.T, p *peer, ent pb.Entry) {
    t.Helper()
    p.applied = ent.Index
    switch ent.Type {
    case pb.EntryConfChange:
        var change pb.ConfChange
        if err := change.Unmarshal(ent.Data); err != nil {
            t.Fatalf("decode conf change: %v", err)
        }
        p.raw.ApplyConfChange(&change)
    case pb.EntryConfChangeV2:
        var change pb.ConfChangeV2
        if err := change.Unmarshal(ent.Data); err != nil {
            t.Fatalf("decode conf change v2: %v", err)
        }
        p.raw.ApplyConfChange(&change)
    case pb.EntryNormal:
        if len(ent.Data) > 0 {
            p.commands = append(p.commands, string(ent.Data))
        }
    }
}

func (c *testCluster) sortedIDs() []uint64 {
    ids := make([]uint64, 0, len(c.peers))
    for id := range c.peers {
        ids = append(ids, id)
    }
    sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
    return ids
}

func (c *testCluster) isolate(id uint64) {
    for other := range c.peers {
        if other != id {
            c.blocked[link{from: id, to: other}] = true
            c.blocked[link{from: other, to: id}] = true
        }
    }
}

func (c *testCluster) heal() {
    c.blocked = make(map[link]bool)
}

func (c *testCluster) tick(t *testing.T, times int) {
    t.Helper()
    for i := 0; i < times; i++ {
        for _, id := range c.sortedIDs() {
            c.peers[id].raw.Tick()
        }
        c.drain(t)
    }
}

func (c *testCluster) leaderAmong(ids ...uint64) uint64 {
    for _, id := range ids {
        if c.peers[id].raw.Status().RaftState == raft.StateLeader {
            return id
        }
    }
    return 0
}

func contains(values []string, target string) bool {
    for _, value := range values {
        if value == target {
            return true
        }
    }
    return false
}

func TestElectionCommitPartitionRecoveryAndReadIndex(t *testing.T) {
    c := newTestCluster(t, 1, 2, 3)

    if err := c.peers[1].raw.Campaign(); err != nil {
        t.Fatal(err)
    }
    c.drain(t)
    if leader := c.leaderAmong(1, 2, 3); leader != 1 {
        t.Fatalf("expected node 1 leader, got %d", leader)
    }

    if err := c.peers[1].raw.Propose([]byte("set version=7")); err != nil {
        t.Fatal(err)
    }
    c.drain(t)
    for id, p := range c.peers {
        if !contains(p.commands, "set version=7") {
            t.Fatalf("node %d did not apply committed command: %v", id, p.commands)
        }
    }

    // 旧Leader只在少数派追加version=8,不能Commit或Apply。
    c.isolate(1)
    if err := c.peers[1].raw.Propose([]byte("set version=8")); err != nil {
        t.Fatal(err)
    }
    c.drain(t)
    for id, p := range c.peers {
        if contains(p.commands, "set version=8") {
            t.Fatalf("node %d applied minority-only command", id)
        }
    }

    // node-2/node-3形成多数,选出新Leader并提交version=9。
    c.tick(t, 30)
    newLeader := c.leaderAmong(2, 3)
    if newLeader == 0 {
        t.Fatal("majority partition did not elect a leader")
    }
    if err := c.peers[newLeader].raw.Propose([]byte("set version=9")); err != nil {
        t.Fatal(err)
    }
    c.drain(t)

    // 网络恢复后,旧Leader未提交的version=8被新Leader历史覆盖。
    c.heal()
    c.tick(t, 5)
    for id, p := range c.peers {
        if contains(p.commands, "set version=8") {
            t.Fatalf("node %d retained an uncommitted command", id)
        }
        if !contains(p.commands, "set version=9") {
            t.Fatalf("node %d did not catch up: %v", id, p.commands)
        }
    }

    // ReadIndex返回后仍要等待状态机Apply到该Index。
    ctx := []byte("read-after-version-9")
    c.peers[newLeader].raw.ReadIndex(ctx)
    c.drain(t)
    readIndex, ok := c.peers[newLeader].readIndex[string(ctx)]
    if !ok {
        t.Fatal("ReadIndex did not complete")
    }
    if c.peers[newLeader].applied < readIndex {
        t.Fatalf("applied=%d is behind readIndex=%d",
            c.peers[newLeader].applied, readIndex)
    }

    for _, id := range c.sortedIDs() {
        p := c.peers[id]
        fmt.Printf("node=%d state=%s lead=%d applied=%d commands=%v\n",
            id, p.raw.Status().RaftState, p.raw.Status().Lead,
            p.applied, p.commands)
    }
}

38.3 运行与已验证结果 ​

bash
go mod tidy
go test -v ./...

关键真实输出:

text
node 1 became leader at term 2
node 2 became leader at term 3
node 1 stepped down to follower since quorum is not active
found conflict at index 6 [existing term: 2, conflicting term: 3]
node=1 state=StateFollower lead=2 applied=7 commands=[set version=7 set version=9]
node=2 state=StateLeader   lead=2 applied=7 commands=[set version=7 set version=9]
node=3 state=StateFollower lead=2 applied=7 commands=[set version=7 set version=9]
PASS

完整验证:

text
Go: 1.23.6 windows/amd64
etcd/raft: v3.6.0
Tests: 1
Failures: 0
Errors: 0

这是协议集成测试,不是生产存储:MemoryStorage 没有真实 fsync,网络是内存消息队列。生产必须替换为 WAL、校验 Snapshot、真实传输和确定性业务 FSM。

三十九、关键失败窗口与恢复方式 ​

窗口现象为什么恢复
VoteResp 已发,Vote 未持久化重启后同 Term 再投票Ready 顺序错误先持久化 Term/Vote,再发送
AppendResp 已发,Entry 未 fsync多数看似提交,重启后日志丢失存储谎报稳定校验 WAL/fsync/磁盘语义
Leader 本地 Append 未 Quorum客户端长时间等待少数派不能 Commit快速失败并查询 Leader/Quorum
Commit 已推进,Apply 落后共识成功但 API 结果慢FSM/Backend 瓶颈查 Apply Lag 和状态机耗时
客户端超时但已 Apply重试产生重复命令响应丢失稳定 requestId 与结果表
PreVote 未启用,离群节点重连稳定 Leader 被高 Term 打断隔离节点反复涨 Term启用/验证 PreVote,修网络
CheckQuorum 退位短时无 LeaderLeader 看不到多数活跃查 RTT、GC、fsync、CPU
Follower Next 早于 FirstIndex长期追不上所需日志已 Compact发送并正确安装 Snapshot
Snapshot 失败未 ReportFollower 卡在 SnapshotLeader 暂停普通 AppendReportSnapshot failure并重试
配置变更未 Apply又发下一条成员视图混乱/提议被拒pendingConfIndex串行等待 Commit+Apply
新节点直接设 VoterQuorum 变大后失去可用性空节点计入多数Learner追平后Promote
旧 Node ID 被复用消息/持久化身份冲突违反唯一身份假设新 ID 加 Learner,移除旧节点
大 Entry 进入日志WAL/网络/GC尖峰Quorum复制整个负载对象存储保存大数据,日志存引用

四十、必须监控的指标 ​

40.1 领导与选举 ​

  • 当前 Leader、Term;
  • Leader Change 次数;
  • Election/PreVote 次数和失败;
  • CheckQuorum Step Down;
  • 每节点收到心跳时间;
  • Proposal Dropped/Forwarded。

40.2 日志与复制 ​

  • LastIndex、CommitIndex、AppliedIndex;
  • last - commit 未提交尾部;
  • commit - applied Apply Lag;
  • 每 Follower Match/Next;
  • Progress State 与 RecentActive;
  • Append Reject、RejectHint 回退;
  • Inflight 消息/字节和暂停状态。

40.3 存储与 Snapshot ​

  • WAL append/fsync P50/P95/P99/Max;
  • WAL 大小和损坏检测;
  • Snapshot 创建/发送/安装耗时;
  • Snapshot 失败、重试和 Pending 时长;
  • 磁盘容量、IOPS、吞吐、队列深度;
  • Apply Batch 耗时和 FSM 错误。

40.4 客户端 ​

  • Proposal 到 Commit、Apply、Response 分段延迟;
  • Pending Proposal 数与字节;
  • ReadIndex 延迟和超时;
  • NotLeader/NoLeader/Timeout;
  • requestId 重试与去重命中率。

四十一、生产故障排查 Runbook ​

41.1 无 Leader ​

  1. 列出当前成员和 Voter/Learner,确认是否还存在 Quorum。
  2. 按节点查看 Term、角色、Vote、最后日志 Term/Index。
  3. 检查双向网络,不只检查 Client 到 Server。
  4. 检查证书、成员 Peer URL、DNS、Firewall、MTU。
  5. 检查 GC/进程暂停、CPU Steal 和调度延迟。
  6. 检查 WAL fsync P99/Max、磁盘满、I/O Error。
  7. 检查 ElectionTick 是否相对真实尾延迟过短。
  8. 不要在多个节点同时强制“自立为主”。

41.2 频繁换主/Term 快速增长 ​

  1. 按时间线关联 Leader Change、心跳丢失、GC、fsync 和网络抖动。
  2. 查是否某隔离/已移除节点不断发 Vote。
  3. 确认 PreVote 和 CheckQuorum 配置。
  4. 查非对称网络:A→B 通、B→A 不通。
  5. 查宿主机时间、暂停、CPU 饱和和虚拟化迁移。
  6. 先修根因,再按延迟分布调整 Timeout。

41.3 有 Leader 但写超时 ​

  1. 判断 Proposal 是未 Append、未 Persist、未 Commit、未 Apply 还是响应丢失。
  2. 查 Leader 是否能收到最快 Quorum 的 AppendResp。
  3. 查 Leader/Follower WAL fsync。
  4. 查 Progress Match、Next、State、Inflights。
  5. 查 Pending Proposal 数量/字节和大 Entry。
  6. 查 CommitIndex 与 AppliedIndex 差值。
  7. 用相同 requestId 查询事实,不盲目重放业务写。

41.4 单个 Follower 长期落后 ​

  1. 比较 Leader LastIndex 与 Follower Match/Applied。
  2. 查 Progress 是 Probe、Replicate 还是 Snapshot。
  3. 查 Append Reject 与冲突 Term。
  4. 查网络带宽、丢包、磁盘 fsync 和 Apply。
  5. 确认所需日志是否已 Compact。
  6. 若走 Snapshot,查创建、传输、校验、安装和 ReportSnapshot。
  7. 不要先把它 Promote 成 Voter。

41.5 Apply Lag 持续扩大 ​

  1. 证明 Commit 仍在前进,问题位于 FSM/Backend。
  2. 按命令类型统计 Apply 延迟和大小。
  3. 查状态机锁竞争、数据库 Batch、Compaction 和 GC。
  4. 查是否在 Apply 内调用外部服务或执行非确定操作。
  5. 控制 Proposal 入口,避免积压继续增长。
  6. 优化确定性 Batch,不跳过已提交条目。

41.6 Snapshot 长期卡住 ​

  1. 查 Leader Progress.PendingSnapshot 和状态持续时间。
  2. 查 Snapshot 文件是否存在、校验是否成功。
  3. 查带宽、限速、磁盘容量和临时目录。
  4. 查 Follower 是否拒绝旧 Snapshot。
  5. 查应用是否正确调用 ReportSnapshot 成功/失败。
  6. 修复后确认回到 Probe/Replicate 并恢复 Match。

41.7 错误成员变更导致失去 Quorum ​

  1. 停止继续做新成员变更。
  2. 记录最后 ConfState、Joint 状态和成员 ID。
  3. 判断是否仍有合法多数可提交恢复配置。
  4. 若有,按产品文档逐个恢复 Peer、添加 Learner 并完成 Promote。
  5. 若无,不要在多个副本分别 Force New Cluster。
  6. 进入灾难恢复:选择权威 Snapshot/WAL,接受并记录可能数据损失边界。

四十二、灾难恢复为什么不能“多数节点各自恢复” ​

正常 Raft 恢复依赖 Quorum;多数永久丢失后,协议无法凭空证明哪个少数副本包含所有已提交条目。灾难恢复必须做业务决策:

  1. 冻结所有旧成员,避免恢复期间继续写。
  2. 收集每份 Snapshot/WAL 的 Cluster ID、Member ID、Term、Index、Commit、校验和。
  3. 选择最可信、最高且完整的历史,不能只看文件修改时间。
  4. 明确 RPO:最后哪些成功响应可能丢失。
  5. 用产品官方 Snapshot Restore/Force New Cluster 流程重建一个新集群身份。
  6. 所有客户端清理旧 Endpoint/证书/缓存。
  7. 对关键业务做事实对账。
  8. 防止旧集群节点重新上线形成两个写集群。

“Force New Cluster”是放弃旧 Quorum 证明后的人工裁决,不是普通故障转移按钮。

四十三、生产前必须做的故障演练 ​

演练必须验证
Kill Leader恢复时间、客户端重定向、重复请求去重
隔离旧 Leader少数派不 Commit、CheckQuorum 退位
单向丢包不对称网络下选举和日志恢复
延迟/抖动ElectionTimeout 是否误触发
慢 fsync写 P99、选举、Pending Proposal
磁盘满WAL/Snapshot 明确失败,不伪装成功
Follower 落后到 Compact 之前Snapshot 自动追平
Snapshot 中途失败ReportSnapshot 后恢复 Probe
Learner Promote追平门槛和 Quorum 不下降
Joint Change 中故障同时满足旧、新 Quorum,不双主
客户端响应丢失相同 requestId 返回同一结果
多数永久丢失Snapshot Restore、RPO 和旧集群隔离

四十四、常见错误认知 ​

错误说法正确理解
Leader 本地写盘就是成功还需当前配置 Quorum 和提交规则
多数节点内存收到就是提交ACK 依赖条目按要求持久化
Term 越大日志越完整投票比较 LastLogTerm,再比较 Index;单看 currentTerm 不够
PreVote 会阻止所有选举它减少离群节点扰动,合法故障仍要选举
CheckQuorum 能判断节点真宕机它只能依据近期通信活跃做超时判断
旧 Term 条目复制到多数即可直接提交Leader 只按多数计数直接提交当前 Term 条目
Leader 本地读一定最新失去多数但未察觉时可能陈旧,需要 ReadIndex/租约前提
ReadIndex 返回就能读还要等待本地 AppliedIndex 到达 ReadIndex
Snapshot 只是压缩包它是状态机数据与 Index/Term/ConfState 的一致恢复点
加一个 Voter 总会更可靠偶数节点常不增容错,空 Voter 还会提高 Quorum
Learner 已加入就能投票Learner 不计 Quorum,追平后才能 Promote
Raft 保证业务 Exactly Once超时后仍需 requestId 和结果复用
Multi-Raft 自动解决跨分片事务每个 Group 独立共识,跨 Group 仍需事务协议
强制重建集群没有数据风险无 Quorum 时无法证明少数历史包含全部 Commit

四十五、面试与关联知识 ​

标准回答、追问和场景题见 共识算法与 Raft 独立面试题。回答顺序建议:故障模型 → 安全不变量 → 选举 → Ready 持久化 → 日志复制 → 当前 Term 提交 → Apply/客户端不确定 → 线性读 → Snapshot/成员变更 → Runbook。

关联知识:

本章小结 ​

Raft 的可靠性不来自“有一个 Leader、复制到多数”这两个名词,而来自一组连续不可缺失的约束:投票与日志先持久化再响应,候选日志足够新,Append 用 Index/Term 校验前缀,Leader 按 Progress 状态复制并只直接提交当前 Term 条目,状态机只 Apply 已提交前缀,线性读确认 Quorum 后等待 Apply,Snapshot 与成员配置原子恢复,成员变更保持 Quorum 相交。生产排障也必须沿同一链路定位,不能看到 Leader 名称就跳过持久化、Commit、Apply 和业务结果。