21.2 Quorum 与 W + R > N 的证明

一句话

quorum 的全部安全性只来自一条几何事实——任意两个 quorum 必相交;W + R > N 是这条事实在读写模型里的代数写法,f + 1(Raft)与 2f + 1(PBFT)是它在两种故障模型下的两个特例。

先定义清楚三个词

  • quorum(法定人数):任何一个决策必须获得其同意的节点集合。多数派 quorum 的大小是 q = ⌊N/2⌋ + 1。
  • W / R:写要成功的副本数 / 读要访问的副本数。W、R 都是 quorum 的一种特例。
  • f:能容忍同时坏掉的节点数。崩溃模型 N = 2f + 1,拜占庭模型 N = 3f + 1。
N q = ⌊N/2⌋+1 2q 2q > N 容错 f = N−q quorum 个数 C(N,q)
3 2 4 true 1 3
4 3 6 true 1 4
5 3 6 true 2 10
6 4 8 true 2 15
7 4 8 true 3 35

注意 4 节点和 6 节点的尴尬:容错没有增加,成本却增加了(N=4 与 N=3 都只容忍 1 个故障;N=6 与 N=5 都只容忍 2 个)。这就是共识集群通常选 3 或 5 的原因,而不是 4 或 6。

相交引理:为什么多数派一定能碰头

引理:设 A, B ⊆ {1..N},若 |A| + |B| > N,则 A ∩ B ≠ ∅。

证明(反证):若 A ∩ B = ∅,则 A ∪ B 有 |A| + |B| 个不同元素,全部落在只有 N 个元素的集合里,于是 |A| + |B| ≤ N,与条件矛盾。∎

把它套到多数派:q = ⌊N/2⌋+1 ⇒ 2q ≥ N+1 > N,所以任意两个多数派必相交。整个共识理论(以及所有 quorum 系统)的地基就是这一行。

W + R > N 的证明

模型假设(缺一条,定理就不成立,后面会逐条说):

  1. 每个写带一个单调递增的版本号(时间戳、LSN、zxid、term+index 都行);
  2. 读从它访问的 R 个副本里取版本最高的那个值返回;
  3. 写成功后不会被回滚(没有"补偿删除");
  4. 一个副本一旦确认写,就不会悄悄丢掉它(不丢数据)。

定理:若 W + R > N,则任何一次读都与"最近一次成功的写"相交,因此该读能看到这次写(或更新的值)。

证明:设最近一次成功的写落在集合 S_w,|S_w| = W;本次读访问集合 S_r,|S_r| = R。由 W + R > N 与相交引理,S_w ∩ S_r ≠ ∅。取其中任一节点 x:x 上有这次写,且按假设 4 它还在;读在 R 个副本里取最高版本(假设 2),x 的版本不低于这次写,所以返回的值不会比这次写更旧。∎

逆命题:若 W + R ≤ N,则存在一种执行使读看不到最近一次写——写只落 S_w,读只读 S_r 且 S_w ∩ S_r = ∅。这不是"概率问题",是"可以构造",所以不能靠运气。

下面这段程序把 N=3..7 的所有 (W, R) 组合穷举了一遍,验证"W + R > N"与"不存在不相交对"完全等价:

package main

import "fmt"

// 用位图表示一个 quorum:第 i 位为 1 表示节点 i 在集合里
func bits(x uint) int {
	c := 0
	for ; x > 0; x &= x - 1 {
		c++
	}
	return c
}

func subsets(n, k int) []uint {
	var out []uint
	for m := uint(0); m < 1<<n; m++ {
		if bits(m) == k {
			out = append(out, m)
		}
	}
	return out
}

// 是否存在一对不相交的 w-quorum 与 r-quorum
func disjointPair(n, w, r int) (uint, uint, bool) {
	for _, a := range subsets(n, w) {
		for _, b := range subsets(n, r) {
			if a&b == 0 {
				return a, b, true
			}
		}
	}
	return 0, 0, false
}

func set(m uint, n int) string {
	s := "{"
	for i := 0; i < n; i++ {
		if m&(1<<i) != 0 {
			if len(s) > 1 {
				s += ","
			}
			s += string(rune('A' + i))
		}
	}
	return s + "}"
}

func main() {
	fmt.Println("| N | q = N/2+1 | 2q | 2q > N | 容错 f = N−q | quorum 个数 C(N,q) |")
	fmt.Println("|---|---|---|---|---|---|")
	for n := 3; n <= 7; n++ {
		q := n/2 + 1
		fmt.Printf("| %d | %d | %d | %v | %d | %d |\n", n, q, 2*q, 2*q > n, n-q, len(subsets(n, q)))
	}

	// 穷举校验:W+R>N 与「不存在不相交对」是否完全等价
	checked, okCount := 0, 0
	for n := 3; n <= 7; n++ {
		for w := 1; w <= n; w++ {
			for r := 1; r <= n; r++ {
				checked++
				_, _, dis := disjointPair(n, w, r)
				if (w+r > n) == !dis {
					okCount++
				}
			}
		}
	}
	fmt.Printf("\n定理校验:N=3..7 共 %d 组 (W,R),W+R>N 与「不存在不相交对」一致的有 %d 组\n\n", checked, okCount)

	// N=5 的矩阵
	n := 5
	fmt.Println("N=5,行 W、列 R:交 = 必相交(读必看到最近写),× = 存在不相交对(可能读到旧值)")
	fmt.Print("| W\\R |")
	for r := 1; r <= n; r++ {
		fmt.Printf(" %d |", r)
	}
	fmt.Println("\n|---|---|---|---|---|---|")
	for w := 1; w <= n; w++ {
		fmt.Printf("| **%d** |", w)
		for r := 1; r <= n; r++ {
			if _, _, dis := disjointPair(n, w, r); dis {
				fmt.Print(" × |")
			} else {
				fmt.Print(" 交 |")
			}
		}
		fmt.Println()
	}

	// 举一个不相交的实例:N=5, W=2, R=3
	a, b, _ := disjointPair(5, 2, 3)
	fmt.Printf("\n反例:写只落 %s,读只读 %s —— 交集为空,读永远看不到那次写\n", set(a, 5), set(b, 5))
}

输出(本机实测):

| N | q = N/2+1 | 2q | 2q > N | 容错 f = N−q | quorum 个数 C(N,q) |
|---|---|---|---|---|---|
| 3 | 2 | 4 | true | 1 | 3 |
| 4 | 3 | 6 | true | 1 | 4 |
| 5 | 3 | 6 | true | 2 | 10 |
| 6 | 4 | 8 | true | 2 | 15 |
| 7 | 4 | 8 | true | 3 | 35 |

定理校验:N=3..7 共 135 组 (W,R),W+R>N 与「不存在不相交对」一致的有 135 组

N=5,行 W、列 R:交 = 必相交(读必看到最近写),× = 存在不相交对(可能读到旧值)
| W\R | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| **1** | × | × | × | × | 交 |
| **2** | × | × | × | 交 | 交 |
| **3** | × | × | 交 | 交 | 交 |
| **4** | × | 交 | 交 | 交 | 交 |
| **5** | 交 | 交 | 交 | 交 | 交 |

反例:写只落 {A,B},读只读 {C,D,E} —— 交集为空,读永远看不到那次写

读法:

  • 矩阵的对角线不是安全线:W=R=2, N=5 时 4 ≤ 5,存在不相交对,读可能读到旧值。安全的边界是 W + R = 6 那条斜线。
  • W=5(全写)时任意 R 都安全,但写没有任何容错:任何一台挂掉写就失败。
  • W=1, R=5 也相交(6 > 5),但一次写只落一个节点,那个节点挂了这次写就没了——相交不等于持久。这是 W=1 的隐藏代价。

参数怎么选:三种典型配置

配置 N W R 写延迟 读延迟 容错(写/读) 适用
读写都多数派 3 2 2 1 RTT(等 2 个) 1 RTT(问 2 个) 1 / 1 元数据、配置、锁
读多写少 5 3 3 1 RTT(等 3 个) 1 RTT(问 3 个) 2 / 2 通用、跨 AZ
写少读多(读优化) 5 5 1 2 RTT(等全部) 本地 0 / 4 只读镜像、报表
写多读少(写优化) 5 1 5 本地 1 RTT(问全部) 4 / 0 采集、日志(容忍丢最近写)

🔑 参数选择不是"W+R>N 之后随便挑",而是先定"哪个方向不能丢、哪个方向必须快":写要可靠就抬 W,读要快就降 R,两者的代价都落在延迟与可用性上。

为什么 W + R > N 仍然不等于线性一致

这是最容易答错的一题。W + R > N 只保证"读能看到最近一次成功的写",而线性一致还要求:

缺失的前提 反例 补法
写可能被回滚 写落到 2 个副本,其中 1 个随后崩溃又被一个旧值副本替换(无回滚日志),版本最高的值消失 需要提交协议(共识日志),不能只靠覆盖写
没有全序 两个并发写落在不同 quorum,读可能先看到 A 再看到 B,另一个客户端看到相反顺序 单 leader + 日志定序(见 21.6 Raft:日志复制)
旧 leader 还在写 分区的少数派 leader 继续接受写,版本号甚至更高 任期/租约 + fencing(见 48.4 Leader 选举)
读不取最高版本 读到第一个响应的副本("最快者胜") 必须比较版本;或走 ReadIndex/租约(见 21.12 共识算法对比)

所以 W + R > N 是必要不充分条件:Dynamo 风格的 AP 系统用它换"读到你自己的写(在多数派健康时)",而线性一致需要共识(16.2 线性一致性 给了形式定义)。

quorum 在共识里被加强了一次

共识用的不只是"多数派相交",而是"相交 + 候选人日志至少一样新":

机制 作用 少了会怎样
多数派相交 新 leader 与旧 leader 的 quorum 必有交集 两个 leader 同时存在
选举限制(日志至少一样新) 交集里的节点不会投票给日志更旧的候选人 落后的节点当选,覆盖已提交条目(图 8 反例,见 21.7 Raft:安全性证明)
任期号单调 旧 leader 的请求被拒绝 旧 leader 复活后继续提交

⚠️ 三种"quorum 计算"的错误,线上都出现过:① 把 observer/learner 算进多数派(它们不投票也不该计数);② 按机器数而不是副本数算(两个副本同机架,一起挂);③ 跨机房时按全局 N 算多数派,但某一机房本地凑不出多数派 → 该机房整体不可用(要按故障域设计,见 36.4 两地三中心与多活)。

反模式

反模式 后果
用 W=1 追求写延迟,却对外声称"强一致" 单副本写,节点一挂就丢数据
N=4/6 的集群 容错与 N−1 相同,成本更高、quorum 更大
把 sloppy quorum(hinted handoff)当成 W+R>N 写落到了"临时节点",与读集合可能不相交,必须靠 read-repair 兜底
认为 W+R>N 就能防脑裂 它只保证读能看到写,不阻止两个 leader 同时写
quorum 里混入不参与投票的角色 实际 quorum 变小,安全性被悄悄削弱
只按节点数算 quorum,不看故障域 一个机架故障就能让整个集群失去多数派

自检

Q1 5 个节点,取 W=2, R=3,W+R=5 ≤ N。请构造一个"读不到刚写完的值"的执行。

▸ 答案:写只被 A、B 确认(W=2 就返回成功);读只访问 C、D、E(R=3)。{A,B} ∩ {C,D,E} = ∅,读的三个副本上都没有这次写,取最高版本也只能拿到旧值。这正是程序输出的那个反例。要修:把 W 抬到 3,或把 R 抬到 4(3+3=6>5、2+4=6>5)。

Q2 为什么 N=4 的集群不划算?

▸ 答案:N=4 的多数派是 3,容错 N−q = 1,与 N=3 一样只容忍 1 个故障,但每次决策要等 3 个节点、集群多一台机器、quorum 个数从 3 变 4。N=6 同理(q=4,容错 2,与 N=5 相同)。所以共识集群的节点数取 2f+1:3、5、7。

Q3 W + R > N 保证了线性一致吗?如果读者的回答是"保证",你会追问什么?

▸ 答案:不保证,只保证"读能看到最近一次成功的写"。需要追问四件事:① 写失败会不会被回滚(有没有提交协议);② 并发写有没有全序(有没有 leader/日志);③ 旧 leader/分区少数派还会不会接受写(有没有任期与 fencing);④ 读是否取最高版本(还是谁先响应算谁)。四问里任何一个答不上来,都不能声称线性一致。

Q4 为什么说"相交不等于持久"?举一个 W+R>N 但仍然丢数据的配置。

▸ 答案:N=5, W=1, R=5 满足 6>5,读一定与写相交;但写只落在 1 个副本上,这个副本在数据被复制出去之前崩溃(且磁盘损坏/被替换),这次写就永久丢失。相交保证的是"读得到",持久保证的是"写得下且不丢"——后者要求写落多个副本(W ≥ 2 且落盘),或由共识日志负责复制。

Q5 集群里加了两个 observer,quorum 会变吗?为什么?

▸ 答案:不会。observer 只同步日志、不参与投票,多数派仍按投票成员计算(如 3 个投票成员 + 2 observer,quorum 仍是 2)。如果误把 observer 计入分母(变成 5 个里的 3 个),实际需要 3 票才能提交,而真正能投票的只有 3 个——任何一台投票成员挂掉,集群就提交不了;更糟的是,如果实现里真的让 observer 投票,quorum 相交的前提也被破坏。

关联