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 的心跳超时;节点被误判为死,触发切换,旧主恢复后继续认为自己可用——脑裂。所以检测必须配两样东西:
- 滞回(hysteresis):连续 N 次失败才判定,恢复也要连续 N 次成功;
- 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 让旧主的写被存储层拒绝,而不是依赖“它应该已经停了”;④ 变更类操作走多数派,不靠单点判断。