17.3 分区期间的取舍

一句话

分区期间唯一必须回答的问题是“少数派还能不能写、写了之后谁来收拾”:CP 让少数派闭嘴(丢可用性),AP 让它继续写(丢一致性),第三种是只读或排队(把损失限制在可对账的范围内)。

三种策略,先看全貌

策略 少数派的行为 多数派的行为 业务可用性 数据风险 典型系统
CP(拒写) 写请求直接失败;线性一致读也失败 正常服务 少数派侧写全挂,读按配置 无分叉,但可能停服 etcd、ZooKeeper、Raft 系
AP(照写) 继续接受读写,本地留存 继续接受读写 两侧都“可用” 数据分叉,要靠合并/对账 Dynamo、Cassandra(可调)、Redis 主从
降级(只读 / 排队) 只读、写排队或转异步 正常服务 写延迟变大,读基本可用 排队可能丢(进程崩)或超时 多数业务系统的默认选择

🔑 三选一的判据不是“哪个更先进”,而是“这条数据写错了能不能事后发现并修复”。能对账、能补偿的(订单、积分)可以降级;不能的(余额、库存、唯一键)必须 CP。

前置的两块砖:P 为什么不是选项(17.1 CAP 的由来与准确表述)、“三选二”为什么是误读(17.2 为什么 “三选二” 是错的)。

多数派数学:独立故障很便宜,相关故障很贵

n 个副本、每次写要 q 个确认,单副本可用率 p 时,系统可用的概率是二项分布的尾和:

P(可用) = Σ_{k=q}^{n} C(n,k) · p^k · (1-p)^(n-k)

写段小程序算一下(q = n/2 + 1):

package main

import (
	"fmt"
	"math"
)

func binom(n, k int) float64 {
	r := 1.0
	for i := 1; i <= k; i++ {
		r = r * float64(n-k+i) / float64(i)
	}
	return r
}

// quorumAvail:n 个副本、写要 q 个确认,单副本可用概率 p
func quorumAvail(n, q int, p float64) float64 {
	sum := 0.0
	for k := q; k <= n; k++ {
		sum += binom(n, k) * math.Pow(p, float64(k)) * math.Pow(1-p, float64(n-k))
	}
	return sum
}

// anyAvail:任意一个副本能应答就算可用(AP 双写/本地读)
func anyAvail(n int, p float64) float64 {
	return 1 - math.Pow(1-p, float64(n))
}

func main() {
	fmt.Println("=== 独立故障下:多数派 vs 任意副本 ===")
	for _, p := range []float64{0.99, 0.999} {
		for _, n := range []int{3, 5, 7} {
			q := n/2 + 1
			fmt.Printf("p=%.3f n=%d q=%d:CP %.6f   AP %.6f   差 %.4f 个百分点\n",
				p, n, q, quorumAvail(n, q, p), anyAvail(n, p),
				(anyAvail(n, p)-quorumAvail(n, q, p))*100)
		}
	}

	// 分区期间少数派积压多少写:决定恢复成本
	fmt.Println("\n=== 分区期间积压的写(决定恢复成本) ===")
	for _, sec := range []float64{10, 30, 300} {
		for _, qps := range []float64{200, 1000} {
			fmt.Printf("  分区 %4.0fs × %4.0f QPS = %8.0f 条待合并/待补偿\n", sec, qps, sec*qps)
		}
	}
}

本机实测(macOS,Apple M1,Go 1.27):

=== 独立故障下:多数派 vs 任意副本 ===
p=0.990 n=3 q=2:CP 0.999702   AP 0.999999   差 0.0297 个百分点
p=0.990 n=5 q=3:CP 0.999990   AP 1.000000   差 0.0010 个百分点
p=0.990 n=7 q=4:CP 1.000000   AP 1.000000   差 0.0000 个百分点
p=0.999 n=3 q=2:CP 0.999997   AP 1.000000   差 0.0003 个百分点
p=0.999 n=5 q=3:CP 1.000000   AP 1.000000   差 0.0000 个百分点
p=0.999 n=7 q=4:CP 1.000000   AP 1.000000   差 0.0000 个百分点

=== 分区期间积压的写(决定恢复成本) ===
  分区   10s ×  200 QPS =     2000 条待合并/待补偿
  分区   10s × 1000 QPS =    10000 条待合并/待补偿
  分区   30s ×  200 QPS =     6000 条待合并/待补偿
  分区   30s × 1000 QPS =    30000 条待合并/待补偿
  分区  300s ×  200 QPS =    60000 条待合并/待补偿
  分区  300s × 1000 QPS =   300000 条待合并/待补偿

合并策略的语义边界(LWW 为什么不是“最终一致”而是“最终随机”)在 16.5 最终一致性 有完整讨论。

第一张表最重要的不是那些 0.999,而是差值:当单副本已经有 99% 可用时,多数派确认的可用性代价只有 0.03 个百分点——独立故障根本不是 CP 的痛点。CP 真正的代价来自相关故障:

故障场景(3 副本分布) CP(多数派) AP(任一副本)
3 AZ 各 1 副本,挂 1 个 AZ ✅ 剩 2 票,可用 ✅ 可用
3 AZ 各 1 副本,挂 2 个 AZ ❌ 只剩 1 票,不可用 ✅ 可用,但数据可能是旧的
两地三中心(北京 2 + 上海 1),北京整体故障 ❌ 只剩上海 1 票 ✅ 上海单点可写,恢复时冲突
两地三中心,北京↔上海链路断 ✅ 北京侧 2 票仍可写 ✅ 两侧都可写(分叉)
三地五副本(2/2/1),挂掉两地 ❌ 只剩 1 票 ✅ 可写(可能丢写)

⚠️ 这就是“多副本 ≠ 高可用”的根源:副本数买的是独立故障的容忍度,副本的分布买的是相关故障的容忍度。3 副本全在一个机房,机房一断,CP 与 AP 一起完蛋。

分区期间,客户端的体验是不对称的

分区不是一个“全局开关”,而是按客户端位置生效的:

  • 连在多数派一侧的客户端:无感(CP)或正常写(AP);
  • 连在少数派一侧的客户端:写 503(CP)、写成功但可能被覆盖(AP)、或写请求被挂起直到超时(降级没做好)。

多地域部署时,这意味着某个地域的用户会突然只能读不能写。这个事实必须在产品层有交代:错误文案、重试策略、能不能排队、排队多久。很多“分区事故”其实是“降级没设计,用户看到 500”。

检测:分区是“推断”出来的,不是“看到”的

节点无法直接观测“我处于少数派”,只能靠心跳超时推断:

检测方式 原理 代价
固定超时 连续 N 次心跳没回就当故障 超时设短 → 误判(GC、网络拥塞);设长 → 故障发现慢
φ accrual(自适应) 用最近心跳间隔的分布算“可疑度” 更平滑,但仍有误判(见 35.4 故障检测)
租约(lease) 主节点持有带时限的租约,到期自动失效 依赖时钟漂移上限;时钟跳变会出问题(见 21.11 领导者租约)

误判的代价可能比分区本身更大:一次 200ms 的 Stop-the-world GC,足够让 100ms 的心跳超时;节点被误判为死,触发切换,旧主恢复后继续认为自己可用——脑裂。所以检测必须配两样东西:

  1. 滞回(hysteresis):连续 N 次失败才判定,恢复也要连续 N 次成功;
  2. fencing:即使旧主“复活”,它的写也必须被拒绝——靠单调递增的令牌(fencing token),而不是靠“它应该已经停了”(见 48.3 分布式锁)。

恢复:分区结束后才是真正的工作量

按前面的输出:30 秒分区 × 200 QPS = 6000 条写要处理。恢复流程必须提前设计好:

步骤 做什么 做不好会怎样
1. 反熵 副本间比对差异(Merkle 树、版本向量) 差异永远留着,读到的值取决于打到哪台
2. 冲突合并 按数据类型选策略(LWW / CRDT / 业务规则) LWW 静默丢写,没人知道丢了什么
3. 读修复 读路径发现旧值就顺手修正 冷数据永远不修,直到某次读才暴露
4. 对账 / 补偿 资金、库存类必须与权威源核对 资损、超卖,且往往在几天后才发现
5. 告警与复盘 记录分区时长、受影响 key、合并结果 同类事故重复发生

资金类链路的对账与补偿是另一套工程(中间态、幂等、对账四件套),见 17.5 BASE 与柔性事务;分区结束后的延迟账(PACELC 的 else 分支)见 17.4 PACELC。

LWW 的丢写是可以量化的:同一个 key 上如果有 k 条并发写,LWW 只保留时间戳最大的那条,丢掉 k−1 条(“并发”的严谨定义见 23.6 happened-before 关系)。6000 条积压里若有 10% 落在少数热点 key 上(600 条、每 key 平均 3 条并发),就会静默丢掉约 400 条。这不是“最终一致”,这是“最终随机”。

决策表:按业务选策略

数据 / 业务 推荐策略 理由
账户余额、库存扣减、优惠券核销 CP 不变式不可合并,超发不可逆
唯一键(用户名、订单号) CP(或分区内分段发号) 冲突合并无法“取消”已发出的号
订单状态机 降级(写排队 + 幂等 + 对账) 可补偿,且用户能接受“处理中”
购物车 AP + 集合求并 合并语义天然正确
计数 / 点赞 / 浏览数 AP + 计数器 CRDT 求和幂等,短暂不一致无感
日志 / 事件流 AP + 追加合并 只追加、不更新,天然可合并
配置 / 选主元数据 CP 必须单一事实源

反模式

反模式 后果
分区时两侧都接受写,却没设计合并 恢复后静默丢写或数据分叉
用 LWW 合并资金/库存 冲突被“悄悄解决”,账对不上
心跳超时设得比 GC 停顿短 频繁误判、无谓切换,甚至脑裂
只靠“旧主应该已经退出”防脑裂 旧主复活后继续写,双主
少数派写排队但不设上限 内存/磁盘打满,恢复时雪崩
分区恢复不做对账 资损在几天后才被发现,错过追回窗口
把“多副本”当“高可用” 副本挤在一个机房,机房级故障全挂
分区期间不降级、直接 500 用户看到的是故障,不是“稍后重试”

自检

Q1 3 副本分布在 3 个 AZ,某天两个 AZ 之间的网络断了,但三个 AZ 的机器都活着。CP 与 AP 系统各自的写入行为是什么?客户端会看到什么?

▸ 答案:网络断成两侧(假设 1 个 AZ 在少数派),CP 的多数派一侧仍可写(2 票),少数派一侧的写全部失败并返回错误;连在少数派一侧的客户端表现为“写不可用”,读也可能被拒(取决于是否要求线性一致读)。AP 两侧都可写,但少数派一侧的数据在分区期间与多数派分叉,恢复后要靠合并策略处理;客户端看到“都成功”,但可能读到旧值,或自己的写被覆盖。两边都“没宕机”,差别只在少数派对请求的回答方式。

Q2 单副本可用率 99% 时,3 副本多数派的可用率是 99.97%,看起来只损失 0.03 个百分点。为什么还说 CP 很贵?

▸ 答案:因为这个 0.03 是独立故障下的账;CAP 讨论的是分区,而分区是相关故障——一个交换机、一条光纤、一个 AZ 出事,会让多个副本同时不可达。相关故障下,多数派可能瞬间只剩少数副本:3 副本跨 3 AZ 挂掉 2 个 AZ,CP 直接不可用(AP 仍可写)。所以 CP 的代价取决于副本的故障域分布与分区时长,不是那条 0.999 的曲线。独立故障算出来的高可用率,会让人误以为“多副本就够了”。

Q3 分区持续 30 秒,少数派一侧的写入速率是 1000 QPS。恢复后要处理多少条写?如果这些写里有 20% 落在同一批热点 key 上,用 LWW 合并会丢多少?

▸ 答案:积压 30 × 1000 = 30000 条。其中 20% 即 6000 条落在热点 key 上;若每个 key 平均有 3 条并发写,LWW 每个 key 只留 1 条,丢掉约 6000 × (2/3) ≈ 4000 条。这个数字必须写进恢复预案:要么改用能保留全部写的结构(追加日志 + 业务重放),要么把热点 key 改成 CP、在分区时就不允许少数派写。

Q4 为什么“检测到分区”这件事本身就可能出错?误判会带来什么后果,怎么防?

▸ 答案:节点只能通过心跳超时推断分区或故障,而心跳延迟可能来自 GC 停顿、CPU 抢占、网络拥塞、时钟跳变——不是真的分区。误判的后果:把健康节点踢出,或在租约到期后新旧主同时认为自己可写,产生脑裂。防法:① 连续 N 次失败 + 恢复也要连续 N 次成功(滞回);② 用 φ accrual 之类的自适应检测,而不是一个固定阈值;③ 用 fencing token 让旧主的写被存储层拒绝,而不是依赖“它应该已经停了”;④ 变更类操作走多数派,不靠单点判断。

关联