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 | 核验最新类/函数、消息与状态语义 |
| 实际运行 Demo | go.etcd.io/raft/v3 v3.6.0 | Go 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 中。
二、学完后必须真正会什么
- 写出 Raft 五个核心 Safety Property,并说明它们如何互相支撑。
- 区分 Persistent State、Volatile State、Leader-only Progress。
- 解释
Tick → Step → Ready → 持久化/发送/Apply → Advance的工程闭环。 - 说明投票和 Append ACK 为什么必须在相关 HardState/Entries 持久化后发送。
- 解释 PreVote、CheckQuorum 与普通 RequestVote 分别防什么故障。
- 解释 Leader 上任为什么追加当前 Term 空条目。
- 解释
nextIndex、matchIndex、Probe、Replicate、Snapshot 和 Inflights。 - 解释
prevLogIndex/prevLogTerm、RejectHint 和按 Term 快速回退。 - 推导“Leader 只能按多数计数直接提交当前任期条目”的原因。
- 区分
lastIndex、committed、applied、stable和业务结果。 - 说清 ReadIndex Safe、LeaseBased Read、Follower Stale Read 的安全前提。
- 解释 Snapshot 为何必须带 LastIncludedIndex/Term/ConfState 并原子安装。
- 解释 Joint Consensus 为什么要求旧、新配置两个 Quorum 同时通过。
- 说明 Learner 为什么追平后才能 Promote 为 Voter。
- 能按无 Leader、选举风暴、复制积压、Apply Lag、Snapshot 卡住执行 Runbook。
三、Raft 解决什么,不解决什么
Raft 让多个非拜占庭节点对一串日志条目的顺序和提交达成一致,再把相同确定性命令按序 Apply 到复制状态机。它重点解决:
- Leader 选举;
- 日志复制;
- 已提交日志不被覆盖;
- 节点宕机、恢复和网络分区下的安全推进;
- 成员配置变化。
它不自动解决:
- 订单库、支付库和库存库的跨服务事务;
- 客户端超时后的业务“恰好一次”;
- 非确定性状态机导致的副本结果差异;
- 节点恶意篡改消息的拜占庭故障;
- 磁盘控制器虚假确认持久化;
- 业务数据文件自动适合放进共识日志。
Raft 保证的是协议层安全性。业务仍需请求 ID、幂等结果表、查询确认、状态机、补偿和对账。
四、Raft 五个核心安全性质
| 性质 | 含义 | 被破坏后的后果 |
|---|---|---|
| Election Safety | 一个 Term 最多一个合法 Leader | 同任期出现两个可提交历史的 Leader |
| Leader Append-Only | Leader 只追加自己的日志,不改写/删除自身已有条目 | 已确认前缀可被 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 在同一位置写出的冲突命令误认为相同。
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 文件传输。应用负责驱动:
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 确认的安全读索引。
同步存储模式的安全顺序:
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
- 节点收到候选 A 的 RequestVote。
- 内存中把
votedFor=A,立即回复同意。 - 还没落盘就断电。
- 重启后忘了投过 A,又给候选 B 投票。
- 同一 Term 可能形成两个不同多数派。
因此 currentTerm/votedFor 必须先稳定存储,再发送 VoteResp。
8.2 为什么 Append ACK 必须晚于日志持久化
Follower 若先 ACK、后落盘,Leader 可能把它计入多数并回复客户端;Follower 随后断电丢日志,若其他带该日志节点也故障,已提交条目可能消失。协议里的“复制到多数”隐含的是满足持久化要求的多数,不是多数节点内存里短暂看过。
九、Tick、Heartbeat 与随机选举超时
Tick 推进逻辑时钟,不等于固定一秒。应用按自己的调度周期调用 Tick,配置中的 HeartbeatTick/ElectionTick 是 Tick 数量。
通常需要满足:
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 流程:
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 的完整判断
接收者至少判断:
- 候选 Term 是否有效;
- 本 Term 是否尚未投票,或已投给同一候选;
- 候选最后日志是否至少与本地一样新;
- 启用 CheckQuorum/Leader Lease 优化时,近期有效 Leader 是否仍在租约窗口;
- 投票结果是否已持久化后再响应。
日志新旧比较:先比较 LastLogTerm,Term 更大者更新;Term 相同再比较 LastLogIndex。不能只比较日志长度。
本地最后日志:(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。它不是无意义心跳,主要作用是:
- 建立当前 Term 的日志位置;
- 该条目复制到多数后,可按当前 Term 规则推进 Commit;
- 一并确认此前安全前缀;
- ReadIndex 在当前 Term 尚无已提交条目前需要等待,空条目提交后可释放安全读;
- 让 Leader 建立对 Followers Progress 的真实认知。
空条目仍要持久化、复制和 Apply(业务 Data 为空通常不执行命令),不是绕过 Quorum 的特殊提交。
十四、Follower Progress 三态
| 状态 | Leader 行为 | 使用时机 |
|---|---|---|
| Probe | 每轮少量探测,确认 Follower 匹配点,避免大量错误 Append | 刚成为 Leader、收到 Reject、Snapshot 完成后 |
| Replicate | 乐观推进 Next,允许流水线发送多批 Append | 日志稳定匹配、Follower 正常追随 |
| Snapshot | 暂停普通日志复制,等待 Snapshot 传输/安装结果 | Follower 所需日志已被 Compact |
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 的不变量
通常保持:
0 <= Match < NextMatch 是已确认事实;Next 是乐观或探测起点。收到过期/乱序 Reject 不能无条件把 Next 大幅回退,否则旧消息会破坏已经确认的进度。
14.2 Inflights 为什么必要
Replicate 状态会流水线发送多批日志。Inflights 限制在途消息数和字节数:
- 太小:高 RTT 环境吞吐不足;
- 太大:Follower 慢时占满内存和网络,重传成本高;
- 单条日志过大:即使消息数量少,也会形成带宽和 GC 尖峰。
十五、AppendEntries 完整复制链路
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。
示例:
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 多数计数提交会造成两个冲突结果都被认为提交。
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 五个阶段
| 阶段 | 表示什么 | 能否回复业务成功 |
|---|---|---|
| Append | Leader 内存日志已有 Entry | 不能 |
| Persist | 本节点稳定存储已有 Entry | 不能,尚未 Quorum |
| Commit | 协议确认该位置不会被未来 Leader 覆盖 | 共识成立,但状态机结果可能尚未产生 |
| Apply | 确定性命令已执行到本地状态机 | 通常可得到业务结果 |
| Respond | 客户端收到成功 | 响应仍可能丢失,客户端超时仍不确定 |
不同产品可能在 Commit 后异步 Apply,也可能等本地 Apply 完成后返回具体 Txn 结果。排障必须区分:
lastIndex >= commitIndex >= appliedIndex大量 commitIndex - appliedIndex 表示共识已决定但状态机执行跟不上;继续提高网络复制速度不会解决 Apply Lag。
十九、客户端超时与 Exactly Once 边界
典型窗口:
- 客户端发送
requestId=R100。 - Leader 把命令复制到多数并 Commit。
- 状态机 Apply 成功。
- Leader 返回响应前宕机或网络丢包。
- 客户端看到超时。
客户端不能用新 requestId 盲目再执行。复制状态机应保存请求会话或幂等结果:
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 完整流程
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["读取状态机并返回"]两个条件缺一不可:
- Quorum 回应证明当前 Leader 仍处于有效领导关系;
- 状态机已 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 全过程
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 必须满足:
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 过渡状态:
C_old,new = majority(A,B,C) AND majority(C,D,E)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;
- 空节点磁盘/网络追平会拖慢复制;
- 误配置地址可能直接降低可用性。
安全流程:
- 以 Learner/Non-voter 加入,不计入 Quorum;
- 传 Snapshot 或追日志;
- 观察 MatchIndex、AppliedIndex、Snapshot 状态和资源;
- 与 Leader 足够接近后 Promote;
- 再移除旧 Voter;
- 每一步等待配置条目 Commit + Apply。
Learner 不提高故障容忍,它只是成员迁移的安全缓冲。
二十八、Leadership Transfer 不是直接改一个字段
有计划维护时可把领导权转给健康、日志已追平的目标:
- 选择目标 Voter;
- 若目标落后,先继续 Append 使其 Match 追上;
- Leader 暂停或拒绝新的普通 Proposal,避免目标永远追不上;
- 向目标发送 TimeoutNow/等价触发;
- 目标以更高 Term 发起选举;
- 获得 Quorum 后成为新 Leader;
- 超时或目标不可达则取消 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。
推荐的恢复思路:
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 结果确定。危险命令:
// 每个副本执行时得到不同结果
record.setCreatedAt(System.currentTimeMillis());
record.setToken(UUID.randomUUID().toString());
record.setOwner(localHostName());应由 Leader/客户端在提议前把确定值写进命令:
{
"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 下,一次写的关键路径近似:
客户端到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 | 写命令顺序、成员和 Commit | MVCC、Revision、Txn、Watch、Lease 在 Apply/存储层 |
| Consul Server | Catalog/KV/控制面状态 | Agent、Gossip、健康检查不是 Raft 日志本身 |
| Kafka KRaft | Controller 元数据 Quorum | Topic Partition 数据副本协议不能简单等同元数据 Raft |
| TiKV | 每个 Region 的复制和 Leader | MVCC、调度、分裂、事务在更高层 |
| CockroachDB | Range 复制与租约基础 | 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.7 | Java 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”为例:
- 管理端生成
requestId=config-pay-route-42。 - 请求被转发给当前 Leader。
- Leader 追加包含完整确定值的配置命令。
- Ready 把 Entries/HardState 交给 WAL,持久化后才发送 Append。
- 多数 Voter 持久化并 ACK。
- Leader 按当前 Term 规则 Commit。
- 各节点 Apply 到 KV/MVCC,产生产品层 Revision。
- Leader 在本地 Apply 后返回 Revision 及 requestId 结果。
- Watch 把 Revision 事件推给微服务;Watch 不是 Commit 本身。
- 客户端超时后用相同 requestId 查询/重试,不创建版本 43。
- 微服务收到配置后原子替换本地快照,失败则保留 Last Known Good。
Raft 日志 Index、etcd Revision、业务配置 Version 是三个不同概念,不能互相硬编码等同。
三十八、真实可运行 Demo:三节点 etcd/raft 分区恢复
本 Demo 使用:
Go 1.23.6
go.etcd.io/raft/v3 v3.6.0它验证:
- PreVote + RequestVote 选出 node-1;
set version=7复制并在三节点 Apply;- 隔离 node-1 后,
set version=8只在旧 Leader 未提交尾部,任何节点都不 Apply; - node-2/node-3 多数分区选出新 Leader;
set version=9在多数派提交;- 网络恢复后,旧 Leader 的 version=8 冲突尾部被覆盖;
- 三节点最终只 Apply version=7 和 version=9;
- ReadIndex 完成后验证
applied >= readIndex; - Ready 处理顺序先持久化 Snapshot/HardState/Entries,再投递依赖消息。
38.1 go.mod
module example.com/raft-internals-demo
go 1.23
require go.etcd.io/raft/v3 v3.6.038.2 raft_demo_test.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 运行与已验证结果
go mod tidy
go test -v ./...关键真实输出:
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完整验证:
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 退位 | 短时无 Leader | Leader 看不到多数活跃 | 查 RTT、GC、fsync、CPU |
| Follower Next 早于 FirstIndex | 长期追不上 | 所需日志已 Compact | 发送并正确安装 Snapshot |
| Snapshot 失败未 Report | Follower 卡在 Snapshot | Leader 暂停普通 Append | ReportSnapshot failure并重试 |
| 配置变更未 Apply又发下一条 | 成员视图混乱/提议被拒 | pendingConfIndex | 串行等待 Commit+Apply |
| 新节点直接设 Voter | Quorum 变大后失去可用性 | 空节点计入多数 | 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 - appliedApply 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
- 列出当前成员和 Voter/Learner,确认是否还存在 Quorum。
- 按节点查看 Term、角色、Vote、最后日志 Term/Index。
- 检查双向网络,不只检查 Client 到 Server。
- 检查证书、成员 Peer URL、DNS、Firewall、MTU。
- 检查 GC/进程暂停、CPU Steal 和调度延迟。
- 检查 WAL fsync P99/Max、磁盘满、I/O Error。
- 检查 ElectionTick 是否相对真实尾延迟过短。
- 不要在多个节点同时强制“自立为主”。
41.2 频繁换主/Term 快速增长
- 按时间线关联 Leader Change、心跳丢失、GC、fsync 和网络抖动。
- 查是否某隔离/已移除节点不断发 Vote。
- 确认 PreVote 和 CheckQuorum 配置。
- 查非对称网络:A→B 通、B→A 不通。
- 查宿主机时间、暂停、CPU 饱和和虚拟化迁移。
- 先修根因,再按延迟分布调整 Timeout。
41.3 有 Leader 但写超时
- 判断 Proposal 是未 Append、未 Persist、未 Commit、未 Apply 还是响应丢失。
- 查 Leader 是否能收到最快 Quorum 的 AppendResp。
- 查 Leader/Follower WAL fsync。
- 查 Progress Match、Next、State、Inflights。
- 查 Pending Proposal 数量/字节和大 Entry。
- 查 CommitIndex 与 AppliedIndex 差值。
- 用相同 requestId 查询事实,不盲目重放业务写。
41.4 单个 Follower 长期落后
- 比较 Leader LastIndex 与 Follower Match/Applied。
- 查 Progress 是 Probe、Replicate 还是 Snapshot。
- 查 Append Reject 与冲突 Term。
- 查网络带宽、丢包、磁盘 fsync 和 Apply。
- 确认所需日志是否已 Compact。
- 若走 Snapshot,查创建、传输、校验、安装和 ReportSnapshot。
- 不要先把它 Promote 成 Voter。
41.5 Apply Lag 持续扩大
- 证明 Commit 仍在前进,问题位于 FSM/Backend。
- 按命令类型统计 Apply 延迟和大小。
- 查状态机锁竞争、数据库 Batch、Compaction 和 GC。
- 查是否在 Apply 内调用外部服务或执行非确定操作。
- 控制 Proposal 入口,避免积压继续增长。
- 优化确定性 Batch,不跳过已提交条目。
41.6 Snapshot 长期卡住
- 查 Leader Progress.PendingSnapshot 和状态持续时间。
- 查 Snapshot 文件是否存在、校验是否成功。
- 查带宽、限速、磁盘容量和临时目录。
- 查 Follower 是否拒绝旧 Snapshot。
- 查应用是否正确调用 ReportSnapshot 成功/失败。
- 修复后确认回到 Probe/Replicate 并恢复 Match。
41.7 错误成员变更导致失去 Quorum
- 停止继续做新成员变更。
- 记录最后 ConfState、Joint 状态和成员 ID。
- 判断是否仍有合法多数可提交恢复配置。
- 若有,按产品文档逐个恢复 Peer、添加 Learner 并完成 Promote。
- 若无,不要在多个副本分别 Force New Cluster。
- 进入灾难恢复:选择权威 Snapshot/WAL,接受并记录可能数据损失边界。
四十二、灾难恢复为什么不能“多数节点各自恢复”
正常 Raft 恢复依赖 Quorum;多数永久丢失后,协议无法凭空证明哪个少数副本包含所有已提交条目。灾难恢复必须做业务决策:
- 冻结所有旧成员,避免恢复期间继续写。
- 收集每份 Snapshot/WAL 的 Cluster ID、Member ID、Term、Index、Commit、校验和。
- 选择最可信、最高且完整的历史,不能只看文件修改时间。
- 明确 RPO:最后哪些成功响应可能丢失。
- 用产品官方 Snapshot Restore/Force New Cluster 流程重建一个新集群身份。
- 所有客户端清理旧 Endpoint/证书/缓存。
- 对关键业务做事实对账。
- 防止旧集群节点重新上线形成两个写集群。
“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 和业务结果。
