17.6 CAP 之后的补充理论
一句话
CAP 只说“分区时 C/A 二选一”,之后二十年补的是三件事:可用性是连续的(harvest / yield)、延迟也要记账(PACELC)、以及哪些操作根本不需要协调(CALM / I-confluence / HAT)。
一张表看完这二十年
| 理论 | 年份 | 补了 CAP 的哪个盲点 | 工程含义 |
|---|---|---|---|
| Harvest / Yield | 1998 | 可用性不是布尔,是“回答多少请求、回答里含多少数据” | 降级设计:少返回一些,也好过不返回 |
| BASE | 2008 | 把“弱一致”变成一套可执行的工程纲领 | 中间态、补偿、幂等、对账(见 17.5 BASE 与柔性事务) |
| CALM | 2010 | 哪些计算天生不需要协调 | 单调程序可免协调;非单调必须协调 |
| causal+ | 2011 | 分区期间能实现的最强一致性是什么 | 因果一致 + 收敛,是“始终可用”的上限 |
| CRDT | 2011 | 用什么数据结构可以无冲突合并 | 计数器、集合、寄存器……见 25.6 CRDT (计数 / 集合 / 寄存器) |
| PACELC | 2012 | 无分区时也要在延迟与一致性之间选 | 见 17.4 PACELC |
| HAT | 2013 | 哪些隔离级别可以做到“高可用” | 读已提交可以;快照隔离/可串行化不行 |
| I-confluence | 2014 | 哪些不变式可以“不协调也不破坏” | 余额 ≥ 0 不行;计数 ≥ 0 可以 |
Harvest / Yield:可用性是连续的
CAP 的 A 是“每个请求都必须返回”,非 0 即 1。现实中更常用的是两个连续量:
yield = 被回答的请求 / 总请求 (服务了多少人)
harvest = 回答中包含的数据 / 应有数据 (答案有多完整)
| 场景 | yield | harvest | 用户感受 |
|---|---|---|---|
| 正常 | 100% | 100% | 无感 |
| 一个分片挂了,返回其余分片的结果 | 100% | 90% | 搜索少几条,但能用 |
| 只读降级 | 100%(读)/ 0(写) | 100% | 能看不能改 |
| 熔断 | 20% | 100% | 大部分请求被快速拒绝,但没拖垮系统 |
| 全挂 | 0 | 0 | 不可用 |
🔑 “基本可用”不是“凑合能用”,而是主动选择在哪个维度降级:宁可 harvest 少一点,也要保住 yield;还是宁可拒绝一部分请求(yield 降),也要保证答案完整。这是产品决策,不是数据库参数。
causal+:始终可用的系统里,最强能做到因果一致
Mahajan、Alvisi、Dahlin 2011 年的结果可以记成一句话:在“始终可用 + 分区容忍”的约束下,因果一致(加上收敛,即 causal+)是能实现的最强一致性模型。
为什么不是线性一致?因为线性一致要求“读看到最新写”,这需要协调,而协调在分区时必然牺牲可用性(17.1 CAP 的由来与准确表述 的证明)。因果一致不要求“最新”,只要求“不违反因果”:如果你看到了我回复你的消息,那么你一定也看得到我回复所依据的那条消息(“因果”就是 happened-before 偏序,定义见 16.4 因果一致性 与 23.6 happened-before 关系)。
工程含义:
- 想“分区两侧都能写”,上限就是因果一致(再往上必须付协调成本);
- 因果一致的实现通常靠版本向量 / 逻辑时钟(见 23.4 向量时钟),读时等待“因果依赖已到齐”;
- 它不保证“读到最新”,所以读己之写仍要额外的会话保证(16.6 会话保证)。
HAT:弱隔离可以高可用,强隔离不行
Bailis 等人 2013 年的《Highly Available Transactions》系统性地回答了“哪些事务隔离级别可以在分区时保持高可用”:
| 隔离级别 / 保证 | 能否高可用(HAT) | 原因 |
|---|---|---|
| 读已提交(Read Committed) | ✅ | 不需要跨副本定序 |
| 单调原子视图(MAV) | ✅ | 只要求“看到原子提交的中间状态”,不要求全局序 |
| 快照隔离(SI) | ❌ | 需要全局快照或冲突检测,跨分区要协调 |
| 可串行化(Serializable) | ❌ | 需要全局序,等价于要求协调 |
| 线性一致读 | ❌ | CAP 的直接推论 |
⚠️ 这张表最容易被误读成“弱隔离就一定高可用”。准确说法是:HAT 给出的是“在始终可用的前提下,你最多能拿到什么”;具体实现仍要看你是否真的避免了跨分区协调。很多号称“用了快照隔离还很可用”的系统,其实是在分区时降级(拒写、切主),也就是牺牲了 CAP 的 A。
CALM:单调的计算不需要协调
CALM 定理(Hellerstein 2010 提出,Ameloot 等人 2011 年形式化)说的是:一个计算如果能用单调逻辑表达,就存在一个“不需要协调”的分布式实现。
直觉版:如果“多知道一点信息”永远不会让已经得出的结论变错,那么这个结论就可以各自算、最后合并;反之,就必须先协调。
| 计算 | 单调? | 能不能免协调 |
|---|---|---|
| 求和 | ✅(加法可交换、可结合) | 能:各分区求和再相加 |
| 求最大值 | ✅ | 能:各分区取 max 再取 max |
| 集合并 | ✅(幂等) | 能:合并取并集 |
| 去重计数 | ❌ | 不能:两边各数一遍再相加会重复计数 |
| “余额 ≥ 0”检查 | ❌ | 不能:结论依赖“当前值”,并发下会反转 |
| “用户名唯一”检查 | ❌ | 不能:两个分区各自都认为没冲突 |
实测一下最能说明问题:
package main
import "fmt"
func main() {
// 两个分区各自看到一部分写入
aUsers := []string{"u1", "u2", "u3"}
bUsers := []string{"u3", "u4"}
aAmount, bAmount := 300.0, 200.0
fmt.Printf("分区 A:%v,金额 %.0f\n", aUsers, aAmount)
fmt.Printf("分区 B:%v,金额 %.0f\n", bUsers, bAmount)
// ① 求和:单调,直接相加就是对的
fmt.Printf("\n① 金额求和:%.0f + %.0f = %.0f → 合并正确,无需协调\n",
aAmount, bAmount, aAmount+bAmount)
// ② 去重计数:非单调,直接相加会重复计数
set := map[string]bool{}
for _, u := range append(append([]string{}, aUsers...), bUsers...) {
set[u] = true
}
fmt.Printf("② 去重计数:|A|+|B| = %d,真值 %d → 直接相加错了 %d\n",
len(aUsers)+len(bUsers), len(set), len(aUsers)+len(bUsers)-len(set))
fmt.Printf(" 改成集合求并(幂等、可交换)→ %d,正确;这就是 CRDT 的并集\n", len(set))
// ③ 不变式:两个分区各自检查都通过,合并后违反
balance, withdraw := 100.0, 60.0
fmt.Printf("\n③ 余额 %.0f,两个分区各收到一笔取款 %.0f:\n", balance, withdraw)
fmt.Printf(" 分区 A 检查 %.0f - %.0f >= 0 → 通过\n", balance, withdraw)
fmt.Printf(" 分区 B 检查 %.0f - %.0f >= 0 → 通过\n", balance, withdraw)
fmt.Printf(" 合并后 %.0f - %.0f - %.0f = %.0f → 不变式「余额 >= 0」被破坏\n",
balance, withdraw, withdraw, balance-withdraw-withdraw)
}
输出:
分区 A:[u1 u2 u3],金额 300
分区 B:[u3 u4],金额 200
① 金额求和:300 + 200 = 500 → 合并正确,无需协调
② 去重计数:|A|+|B| = 5,真值 4 → 直接相加错了 1
改成集合求并(幂等、可交换)→ 4,正确;这就是 CRDT 的并集
③ 余额 100,两个分区各收到一笔取款 60:
分区 A 检查 100 - 60 >= 0 → 通过
分区 B 检查 100 - 60 >= 0 → 通过
合并后 100 - 60 - 60 = -20 → 不变式「余额 >= 0」被破坏
I-confluence:不变式能不能“不协调也不破”
CALM 说的是“计算”,I-confluence(Bailis 2014)把它推到“不变式”:如果任意顺序、任意切分地应用所有操作,不变式都不会被破坏,那么这份数据可以不做协调。
| 不变式 | I-confluent? | 例子 / 反例 |
|---|---|---|
| 计数 ≥ 0(只增) | ✅ | 点赞数、访问计数:合并取和/max,永不为负 |
| 追加日志的顺序 | ✅ | 只追加、不更新,合并即拼接 |
| 库存 ≥ 0 | ❌ | 两个分区各卖 1 件,合并后超卖(见 17.3 分区期间的取舍) |
| 余额 ≥ 0 | ❌ | 上面的实测:-20 |
| 用户名唯一 | ❌ | 两侧都能注册 alice,合并时冲突 |
| 每个用户最多 3 台设备 | ❌ | 两侧各绑 3 台,合并后 6 台 |
🔑 判断口诀:“先算后合”会不会改变结论? 会,就必须协调;不会,就可以各自算、最后合。是否单调、是否 I-confluent,是比“选 CP 还是 AP”更细、更有用的判断。
这些性质最终落到“这条数据允许读到多旧的值”,谱系见 16.7 一致性模型谱系图。
把它们放回工具箱
| 问题 | 用什么回答 | 结论 |
|---|---|---|
| 分区时少数派怎么办? | CAP / 17.3 分区期间的取舍 | 拒写、照写、还是只读/排队 |
| 平时要不要付跨副本延迟? | PACELC / 17.4 PACELC | EC 付延迟,EL 付陈旧与合并 |
| 这条数据要不要协调? | CALM / I-confluence | 单调 & I-confluent → 免协调 |
| 一致性最多能做到多强? | CAP + causal+ | 始终可用 ⇒ 上限是因果一致 |
| 弱隔离能不能高可用? | HAT | 读已提交可以,SI/可串行化不行 |
| 故障时“部分可用”怎么衡量? | Harvest / Yield | 分别盯 yield 与 harvest |
| 共识为什么这么难? | FLP(见 22.2 FLP 不可能性) | 异步 + 崩溃故障下,确定性共识无法保证终止——这是另一个问题,不是 CAP |
⚠️ 最后一行值得强调:CAP 与 FLP 不是一回事。CAP 讲“分区时一致性与可用性二选一”,FLP 讲“异步网络里,确定性共识算法不能保证终止”。它们常被混着引用,但推导方向完全不同:Raft 是 CP 的(分区时少数派不写),同时它也绕开了 FLP 的“必须终止”要求(靠随机超时与选举,允许“暂时选不出主”)。
判断流程:从数据到策略
① 这个操作是单调的吗?(只增、可交换、幂等)
├─ 是 → 免协调:AP/EL,用 CRDT/集合并/计数
└─ 否 → ②
② 不变式是 I-confluent 吗?(先算后合会不会破坏它)
├─ 是 → 可以分区,但要在读时或合并时校验
└─ 否 → ③
③ 能否缩小协调范围?
├─ 能 → 单 key / 单分片 / 单地域内协调(分段发号、单元化)
└─ 不能 → ④
④ 必须协调:选 EC/CP,接受延迟或分区时不可用;
同时设计降级、对账、补偿
第 ④ 步的降级、对账、补偿具体怎么做,见 17.5 BASE 与柔性事务。
反模式
| 反模式 | 后果 |
|---|---|
| 拿定理当架构:“我们符合 CALM,所以不用协调” | CALM 只对单调计算成立,业务里大量操作不单调 |
| 用 CRDT 表示资金 | 资金需要“减法”与“不小于零”,不满足 I-confluence |
| 认为“弱隔离 = 高可用” | SI 与可串行化都不在 HAT 里 |
| 把 CAP 与 FLP 混着引用 | 结论推错方向:一个讲 C/A,一个讲终止性 |
| 只盯 yield 不盯 harvest | 每个请求都回 200,但内容缺一半,用户照样不能用 |
| 以为因果一致“没有代价” | 要版本向量、要等依赖到齐,读延迟与元数据成本都在 |
| 认为“最终一致”可以没有时间界 | 没有界就没有 SLA,也没有对账的触发条件 |
自检
Q1 为什么“去重计数”不满足 CALM 的免协调条件?给出两种能改成“可合并”的写法。
▸ 答案:因为去重计数不是单调的:合并两个分区时把各自的计数相加会把重复元素数两遍(实测 3+2=5,真值 4),而“知道更多信息”会让已有结论变错。改法:① 合并集合(并集是幂等、可交换、单调的),基数在读取时计算——代价是存储元素本身;② 用 OR-Set / 唯一 ID 集合 之类的 CRDT,元素带唯一标识,合并按 ID 去重,基数同样在读取时取。两种写法的共同点:把“计数”这个非单调运算,换成“集合合并”这个单调运算。
Q2 同样是“不能为负”,“余额 ≥ 0”不 I-confluent,而“点赞数 ≥ 0”可以。差别在哪?
▸ 答案:差别在是否存在减法与依赖当前值的判定。点赞数只增(或只做 max/求和),任意顺序合并不可能为负;余额要扣减,判定“扣完是否 ≥ 0”依赖当前值,两个分区各自看到 100、各扣 60,合并后是 -20(实测),不变式被破坏。一般规律:只增/只取 max 的量天然 I-confluent;涉及减法、唯一性、容量上限的量,通常需要协调,或者改造成“预留 + 最终确认”。
Q3 HAT 说快照隔离不能高可用,那为什么一些系统“用了快照隔离还很可用”?
▸ 答案:两种可能。① 它们在分区时降级:少数派拒写或切主,牺牲了 CAP 的 A,只是工程上做了快速失败与重试,用户感知不明显;② 它们的快照隔离只在单个分区内生效(单分片、单地域),跨分片的快照并没有真正提供。也就是说,“可用”是在业务层用降级换来的,不是隔离级别本身带来的。判断方法:看分区时少数派的写是不是被拒绝了。
Q4 用一句话区分 CAP、PACELC、FLP 三个“不可能性/权衡”结论,并说明它们各自不能推出什么。
▸ 答案:CAP:分区期间,线性一致与“每个请求都返回”不可兼得(不能推出“平时也要牺牲一致性”);PACELC:无分区时,延迟与一致性仍需取舍(不能推出“分区时可以两者都要”);FLP:异步网络中只要有一个进程可能崩溃,确定性共识就无法保证终止(不能推出“共识不可用”——工程上用超时、随机化、部分同步假设绕开)。三者分别约束“分区时”“平时”“终止性”,互相不能替代。