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 冻结状态,避免边写边拍
快照传输失败 分块 + 断点续传式重试,别整包重发

三个具体的坑:

  1. 截断边界:快照覆盖到 index 100 后,日志里 1–100 被删除。此时给 follower 发 AppendEntries 不能再用 prevLogIndex < 100 的条目做检查——要用快照的 lastIncludedIndex/Term 当"虚拟日志项"。实现里常见的做法是保留一条哨兵条目。
  2. 快照与 apply 的竞态:拍快照时状态机还在 apply 新日志。必须保证"快照里的状态对应且仅对应 lastIncludedIndex",否则恢复后状态与日志错位(表现为"快照恢复后立刻重复执行几条命令")。
  3. 快照期间的写放大: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",同时记录该位置之后不包含任何新日志。

关联