21.8 Raft:成员变更与快照
一句话
成员变更的唯一致命点是新旧配置的多数派可能不相交(一次加两个节点就够了),解法只有两条——一次只改一个节点,或走联合共识的两阶段双多数派;快照则是让日志能被截断、让落后节点能追上来的唯一手段,它必须把「最后一个被包含的 index/term + 当时的配置」一起存下来。
危险从哪来:两个配置,两套多数派
配置本身是"哪 N 个节点算数"。变更时集群处于"旧配置 C_old 与新配置 C_new 并存"的窗口,如果两者各自算多数派,就可能选出两个 leader:
C_old = {A,B,C} 多数派 q=2:{A,B} 或 {A,C} 或 {B,C}
C_new = {A,B,C,D,E} 多数派 q=3:{A,B,C} 或 {A,D,E} …
↑ {A,B} 与 {C,D,E} 没有任何交集
下面这段程序枚举所有配置切换,检查"旧配置的任意多数派"与"新配置的任意多数派"是否必相交:
package main
import "fmt"
func bits(x uint) int {
c := 0
for ; x > 0; x &= x - 1 {
c++
}
return c
}
func subsets(cfg uint, k int) []uint {
var out []uint
for m := cfg; ; m = (m - 1) & cfg {
if bits(m) == k {
out = append(out, m)
}
if m == 0 {
break
}
}
return out
}
func qsize(cfg uint) int { return bits(cfg)/2 + 1 }
// 旧配置与新配置的多数派是否必然相交
func alwaysOverlap(oldCfg, newCfg uint) (uint, uint, bool) {
for _, a := range subsets(oldCfg, qsize(oldCfg)) {
for _, b := range subsets(newCfg, qsize(newCfg)) {
if a&b == 0 {
return a, b, false
}
}
}
return 0, 0, true
}
func set(m uint) string {
s := "{"
for i := 0; i < 8; i++ {
if m&(1<<i) != 0 {
if len(s) > 1 {
s += ","
}
s += string(rune('A' + i))
}
}
return s + "}"
}
func main() {
fmt.Println("| 变更 | 旧 quorum | 新 quorum | 任意两个多数派必相交 | 不相交的例子 |")
fmt.Println("|---|---|---|---|---|")
type c struct {
desc string
old, new uint
}
cases := []c{
{"3 → 4(加一个)", 0b111, 0b1111},
{"4 → 5(加一个)", 0b1111, 0b11111},
{"5 → 6(加一个)", 0b11111, 0b111111},
{"6 → 7(加一个)", 0b111111, 0b1111111},
{"5 → 4(减一个)", 0b11111, 0b1111},
{"3 → 5(一次加两个)", 0b111, 0b11111},
{"5 → 7(一次加两个)", 0b11111, 0b1111111},
}
for _, cs := range cases {
a, b, ok := alwaysOverlap(cs.old, cs.new)
ex := "—"
if !ok {
ex = fmt.Sprintf("%s 与 %s", set(a), set(b))
}
fmt.Printf("| %s | %d | %d | %v | %s |\n", cs.desc, qsize(cs.old), qsize(cs.new), ok, ex)
}
}
输出(本机实测):
| 变更 | 旧 quorum | 新 quorum | 任意两个多数派必相交 | 不相交的例子 |
|---|---|---|---|---|
| 3 → 4(加一个) | 2 | 3 | true | — |
| 4 → 5(加一个) | 3 | 3 | true | — |
| 5 → 6(加一个) | 3 | 4 | true | — |
| 6 → 7(加一个) | 4 | 4 | true | — |
| 5 → 4(减一个) | 3 | 3 | true | — |
| 3 → 5(一次加两个) | 2 | 3 | false | {B,C} 与 {A,D,E} |
| 5 → 7(一次加两个) | 3 | 4 | false | {C,D,E} 与 {A,B,F,G} |
结论非常干脆:
- 一次加/减一个节点,任意新旧多数派必相交——可以直接切。
- 一次加两个,立刻出现不相交的多数派——必须走联合共识。
证明(一次加一个):设 |C_old| = N,则 |C_new| = N+1,旧多数派 q_o ≥ (N+1)/2、新多数派 q_n ≥ (N+2)/2,于是 q_o + q_n ≥ N + 1.5 > N+1 = |C_new|,由相交引理两者必相交。∎
方案一:一次只改一个节点(single-server change)
C_old = {A,B,C} → 日志里追加一条配置条目 C' = {A,B,C,D} → 提交它 → 才能开始下一次变更
| 约束 | 原因 | 违反的后果 |
|---|---|---|
| 变更条目本身走日志、走共识 | 配置也必须有序 | 各节点对"当前配置"理解不一致 |
| 上一次变更提交后才能开始下一次 | 否则等价于一次加两个 | 多数派不相交 → 双 leader |
| leader 变更期间挂掉 → 由新 leader 决定是否继续 | 未提交的配置条目会被覆盖 | 配置回滚到旧值(合法) |
| 用当前配置算 quorum,而不是新旧并集 | 一次只改一个,两者相交 | 计数错 → 决策等不到多数派 |
⚠️ 最常见的实现 bug:把"配置"当成另一份数据(比如配置中心里的副本列表)而不是日志里的条目,于是配置变更绕过了共识——这等价于"任意时刻可以直接切换配置",前面所有推导全部失效。
方案二:联合共识(joint consensus)
当确实需要一次改多个(例如换机房、缩容一半)时,用两阶段:
C_old → C_old,new(联合配置) → C_new
│
└─ 此阶段的任何决策(选举、提交)都要同时在 C_old 与 C_new 各拿一个多数派
| 阶段 | 需要谁的多数派 | 性质 |
|---|---|---|
C_old | 旧配置 | 变更前 |
C_old,new | 旧 + 新,两个都要 | 过渡期:不可能选出两个 leader,也不可能有两个"已提交"的冲突 |
C_new | 新配置 | 变更完成 |
可用性代价要算清楚:过渡期内,一次提交需要 max(q_old, q_new) 个节点(两个多数派的并集,实际是两者都要满足),集群能容忍的故障数从 N−q 降到"两个配置中较弱者"。例如 3→5 的联合阶段,任何决策需要旧配置 2 个 且 新配置 3 个节点;如果旧配置里的 A 挂了,而新配置的 D、E 还没同步好,就会卡住。
| 维度 | 一次一个 | 联合共识 |
|---|---|---|
| 安全性 | 安全(相交可证) | 安全(双多数派) |
| 一次能改几个 | 1 | 任意 |
| 可用性 | 过渡窗口极短,影响小 | 过渡期要求更严格,期间容忍故障更少 |
| 实现复杂度 | 低(只需"一次一个"的检查) | 高(两套配置、两套计数) |
| 典型实现 | etcd 的 MemberAdd 逐个加 | 论文方案,工程上较少完整实现 |
快照:日志不能无限长
日志是状态机的"重放脚本",它会无限增长。假设 5000 写/秒、每条约 200 B:
日志增长 ≈ 5000 × 200 B/s = 1 MB/s ≈ 86 GB/天
重启回放 86 GB 日志:按 50 MB/s 回放 ≈ 30 分钟 —— 期间不可用
快照 = 某一时刻的状态机镜像 + 它覆盖到的位置:
| 快照必须带 | 为什么 |
|---|---|
lastIncludedIndex | 之后从哪继续复制 |
lastIncludedTerm | 校验 prevLogTerm(少了它,日志匹配无法验证) |
| 状态机数据(序列化) | 恢复时直接载入,不用回放 |
| 当时的集群配置 | 否则恢复后不知道自己是哪个配置的成员 |
InstallSnapshot RPC 的流程:leader 发现 follower 需要的日志已被截断 → 分块发送快照(offset + done)→ follower 落盘、清空日志、把状态机重置到快照、回复 lastIncludedIndex。
| 触发条件 | 做法 |
|---|---|
| follower 落后但日志还在 | 补日志(便宜、可流水线) |
| follower 需要的日志已被截断 | 发快照(贵,但唯一可行) |
| 快照正在生成 | 用 copy-on-write / fork 冻结状态,避免边写边拍 |
| 快照传输失败 | 分块 + 断点续传式重试,别整包重发 |
三个具体的坑:
- 截断边界:快照覆盖到 index 100 后,日志里 1–100 被删除。此时给 follower 发 AppendEntries 不能再用
prevLogIndex < 100的条目做检查——要用快照的lastIncludedIndex/Term当"虚拟日志项"。实现里常见的做法是保留一条哨兵条目。 - 快照与 apply 的竞态:拍快照时状态机还在 apply 新日志。必须保证"快照里的状态对应且仅对应
lastIncludedIndex",否则恢复后状态与日志错位(表现为"快照恢复后立刻重复执行几条命令")。 - 快照期间的写放大:2 GB 状态机每分钟拍一次快照,等于每分钟多写 2 GB。所以要按"日志条数/大小阈值"触发(如每 1 万条或日志 64 MB),而不是按时间。
反模式
| 反模式 | 后果 |
|---|---|
| 一次加/减多个节点 | 新旧多数派不相交 → 双 leader、脑裂 |
| 配置不写入日志(放在配置中心/环境变量) | 绕过共识,节点对成员理解不一致 |
| 变更未提交就开始下一次 | 等价于并发变更,退化成联合共识却没走双多数派 |
快照不带 lastIncludedTerm | 无法校验日志匹配,可能接受分叉日志 |
| 快照不带配置 | 单节点恢复后不知道自己属于哪个集群 |
| 按时间拍快照 | 写放大不可控;应按日志量阈值 |
| 快照传输用单次大请求 | 超时/内存峰值/失败重传全量 |
| follower 落后就发快照 | 浪费带宽;日志还在就补日志 |
自检
Q1 3 节点集群要扩到 5 节点。为什么不能一次性把配置从 {A,B,C} 改成 {A,B,C,D,E}?
▸ 答案:{A,B,C} 的多数派是 2(如 {A,B}),{A,B,C,D,E} 的多数派是 3(如 {C,D,E}),两者可以不相交。若在切换窗口里,旧配置的节点选出 leader 并提交,同时新配置的节点也选出 leader 并提交,就会出现两个"已提交"的冲突历史,且谁都覆盖不了对方。所以要分两步:先 {A,B,C,D}(3→4,可证相交),提交后再 {A,B,C,D,E}(4→5,同样可证相交)。
Q2 联合共识的过渡期,为什么可用性会下降?
▸ 答案:过渡期 C_old,new 要求任何决策同时在旧配置和新配置各拿一个多数派,也就是需要两个多数派的并集都同意。故障容忍度取两个配置中较弱者:例如 3→5 时,需要旧配置 2 个节点 + 新配置 3 个节点同时可用,任一侧凑不齐就卡住。窗口越短影响越小,所以工程上常把它压缩到"一次一个"来避免这个代价。
Q3 快照为什么必须保存 lastIncludedTerm?只存 index 不行吗?
▸ 答案:不行。AppendEntries 的一致性检查是 (prevLogIndex, prevLogTerm) 二元组;快照截断后,prevLogIndex 落在快照范围内时,只能用 lastIncludedIndex/Term 当虚拟日志项来校验。只存 index 就无法判断"同一位置的历史是否相同",日志匹配性质失效——这正是 21.7 里那条"快照必须记住 term"的工程前提。
Q4 5000 写/秒、每条约 200 B 的集群,日志一天长多少?不快照会怎样?
▸ 答案:5000 × 200 = 1 MB/s,一天约 86 GB。不快照的后果:① 磁盘被吃满;② 节点重启回放 86 GB,按 50 MB/s 算要 30 分钟,期间该节点不可用、且可能拖慢整个集群;③ 新节点加入要补 86 GB 日志(而不是几 GB 的快照)。所以快照不是为了"省空间",是为了恢复时间与成员变更的可行性。
Q5 拍快照时状态机还在 apply 新日志,最容易出什么错?怎么避免?
▸ 答案:快照内容与 lastIncludedIndex 不一致——比如快照里已经包含了 index 105 的效果,但记录的 lastIncludedIndex 是 100。恢复后从 101 重放,101–105 会被执行第二次(重复扣款、计数翻倍)。避免方式:拍快照时冻结状态(copy-on-write/fork 或短暂停写),并明确"快照对应且仅对应 lastIncludedIndex",同时记录该位置之后不包含任何新日志。