Skip to content

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

本页从 Raft 论文的不变量进入工程实现,再对照 etcd/raft 的 raftraftLogProgressRawNodeReadyStoragereadOnlyChanger 解释一条提议怎样经过持久化、网络复制、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.gonode.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.0HashiCorp 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. 解释 nextIndexmatchIndex、Probe、Replicate、Snapshot 和 Inflights。
  8. 解释 prevLogIndex/prevLogTerm、RejectHint 和按 Term 快速回退。
  9. 推导“Leader 只能按多数计数直接提交当前任期条目”的原因。
  10. 区分 lastIndexcommittedappliedstable 和业务结果。
  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 和业务结果。