17.4 PACELC:无分区时的延迟-一致性权衡
一句话
分区是偶发的,协调成本是每天的:PACELC 把“平时”的账单独记——想让多个副本给出一致的答案,就得付一次跨副本往返;不想付,就接受陈旧读。
两个分支:P 时与平时
Abadi 在 2012 年提出 PACELC,把 CAP 没覆盖的那段时间补上:
if (Partition) → 在 Availability 与 Consistency 之间选
else(Else) → 在 Latency 与 Consistency 之间选
| 缩写 | 含义 |
|---|---|
| PC / EC | 分区时选一致性,平时也选一致性——每个请求都付协调延迟 |
| PA / EL | 分区时选可用,平时选低延迟——接受陈旧读 |
| PC / EL | 分区时选一致性,平时用某种“局部一致 + 低延迟”的折中(如按记录的一致性) |
| PA / EC | 分区时选可用,平时选一致性(少见:单主 + 异步复制,读主库) |
按 Abadi 2012 年的分类(产品会演进,别把这张表当现状):
| 系统 | 分类 | 它的代价 |
|---|---|---|
| Dynamo、Cassandra、Riak | PA/EL | 读可能陈旧;冲突靠应用或 LWW 解决 |
| Spanner、VoltDB | PC/EC | 跨地域提交延迟;Spanner 还要 commit-wait |
| PNUTS | PC/EL | 按记录的时间线一致性,跨地域读可能旧 |
| MongoDB(2012 年时) | PA/EC | 单主写保证一致,异步复制导致故障时可能丢写 |
🔑 这张表真正想说的不是“谁强谁弱”,而是同一个系统在“分区时”和“平时”可以给出两套不同的答案。CAP 只问了第一行,日常设计 90% 的时间在第二行——而“三选二”这种误读,见 17.2 为什么 “三选二” 是错的。
为什么 else 分支才是日常
分区是偶发的(见 17.1 CAP 的由来与准确表述 的量级估算),而每一次跨副本确认都在付延迟:
| 请求类型 | 要等谁 | 典型延迟(同机房 / 同城 / 跨城 / 跨洲) |
|---|---|---|
| 本地写 + 异步复制 | 只等本地落盘 | 0.1–1ms,但副本可能落后 |
| 同机房多数派写 | 等 1 个同机房副本确认 | 0.5–1ms |
| 同城三 AZ 多数派写 | 等 1 个同城 AZ 确认 | 1–3ms |
| 跨城多数派写(两地三中心) | 等异地副本确认 | 30ms 起(北京–上海) |
| 跨洲多数派写 | 等另一个大洲确认 | 150ms 起(中美 180ms) |
| 线性一致读(ReadIndex / lease 过期) | 一次多数派往返 | 同上 |
| 本地陈旧读 | 不等任何人 | 0,但可能读到旧值 |
算一遍:副本放哪儿,决定了你付多少延迟
模型:leader 在本地,写要 q 票,并行发给最近的 q−1 个副本,提交延迟 = 这些副本 RTT 的最大值(忽略排队与落盘)。
package main
import (
"fmt"
"sort"
)
// 每个副本到 leader 的 RTT(ms);leader 自己算 1 票
type placement struct {
name string
rtts []float64
quorum int
}
func commitLatency(p placement) float64 {
need := p.quorum - 1 // 还差几票
if need <= 0 {
return 0
}
sort.Float64s(p.rtts)
if need > len(p.rtts) {
return -1
}
return p.rtts[need-1] // 并行发送,等最慢的那个必需的副本
}
func main() {
cases := []placement{
{"单 AZ 3 副本", []float64{0.5, 0.5}, 2},
{"同城 3 AZ", []float64{1, 1}, 2},
{"两地三中心(2 北京 + 1 上海)", []float64{0.5, 30}, 2},
{"三地五副本(2 北京 + 2 上海 + 1 广州)", []float64{0.5, 30, 30, 40}, 3},
{"全球五副本(2 美东 + 2 欧洲 + 1 亚太)", []float64{0.5, 80, 80, 200}, 3},
}
fmt.Println("=== 多数派提交延迟(模型:并行发送,取第 q-1 近的副本) ===")
for _, c := range cases {
fmt.Printf("%-40s q=%d 提交延迟 ≈ %6.1f ms\n", c.name, c.quorum, commitLatency(c))
}
fmt.Println("\n=== Spanner 型 TrueTime 的追加成本(commit-wait ≈ 2ε) ===")
for _, eps := range []float64{1, 4, 7} {
fmt.Printf(" ε=%.0fms → 多等 %.0fms\n", eps, 2*eps)
}
}
本机实测(模型计算,RTT 取常见量级):
=== 多数派提交延迟(模型:并行发送,取第 q-1 近的副本) ===
单 AZ 3 副本 q=2 提交延迟 ≈ 0.5 ms
同城 3 AZ q=2 提交延迟 ≈ 1.0 ms
两地三中心(2 北京 + 1 上海) q=2 提交延迟 ≈ 0.5 ms
三地五副本(2 北京 + 2 上海 + 1 广州) q=3 提交延迟 ≈ 30.0 ms
全球五副本(2 美东 + 2 欧洲 + 1 亚太) q=3 提交延迟 ≈ 80.0 ms
=== Spanner 型 TrueTime 的追加成本(commit-wait ≈ 2ε) ===
ε=1ms → 多等 2ms
ε=4ms → 多等 8ms
ε=7ms → 多等 14ms
第三行是最值得盯的:把 2 个副本放在北京、1 个放在上海,多数派(2 票)可以在北京本地凑齐,提交延迟只要 0.5ms。但它同时意味着——北京整体故障(或北京与上海分区时北京侧网络异常),你就只剩 1 票,系统不可写。快路径与容灾能力,被同一张副本分布表同时决定:
| 部署形态 | 提交延迟 | 能容忍的故障 |
|---|---|---|
| 单 AZ 3 副本 | 0.5ms | 单机故障;机房故障全挂 |
| 同城 3 AZ,q=2 | 1ms | 单 AZ 故障 |
| 两地三中心(北京 2 + 上海 1),q=2 | 0.5ms | 单机故障;北京整体故障即不可写 |
| 两地三中心,q=2 但北京只放 1 副本 | 30ms | 单机房故障 + 北京整体故障可切上海 |
| 三地五副本,q=3 | 30ms | 单地域故障;跨地域分区时多数派侧可用 |
| 全球五副本,q=3 | 80ms | 单大洲故障;跨洲分区时多数派侧可用 |
⚠️ “两地三中心”常被当成容灾方案,但如果 2 个副本放在主中心、且 quorum 能在主中心凑齐,它只解决了数据副本,没解决可用性——主中心一挂,服务就不可写。要让异地副本真正生效,必须让它成为多数派的一部分,代价就是那 30ms。
读路径:一致性的另一半账
写要协调,读也一样:
| 读方式 | 延迟 | 保证 | 适合 |
|---|---|---|---|
| 本地读(可能陈旧) | ≈ 0 | 最终一致 | 商品详情、Feed、报表 |
| ReadIndex(一次多数派往返) | 1 RTT | 线性一致 | 余额、库存、锁 |
| 租约读(lease 内本地读) | ≈ 0(租约内) | 线性一致 | 高频强一致读;依赖时钟假设(实现见 21.5 Raft:选主) |
| 会话读(读己之写) | 0,但可能要等复制位点 | 会话级 | 用户刚提交后看自己的数据(见 16.6 会话保证) |
一致性越强越贵,这个顺序在 16.7 一致性模型谱系图 里有完整谱系。
EL 的代价是“陈旧窗口”:副本落后多少,用户就可能看到多旧的数据。这个窗口必须被显式约束,否则“最终一致”会变成“不知道多旧”:
- 用复制延迟监控(p99 通常几十到几百 ms,压力大时可到秒级);
- 对“读己之写”用会话粘滞或位点等待;
- 对超过阈值的陈旧读直接改走主库(降级路径)。
EC 与 EL 的工程对照
| 维度 | EC(选一致性) | EL(选延迟) |
|---|---|---|
| 写路径 | 多数派确认 | 本地确认,异步复制 |
| 读路径 | 多数派或租约 | 本地 |
| 故障时 | 可能拒写/拒读 | 可能读到旧值、可能丢写 |
| 冲突处理 | 不需要(写时已定序) | 需要(LWW / CRDT / 对账) |
| 跨域流量 | 每次写都要跨域 | 只有复制流量 |
| 成本 | 延迟、跨域带宽、协调组件 | 冲突处理、对账、修复、用户体验 |
| 典型场景 | 账户、库存、配置、选主 | 社交、内容、计数、日志 |
🔑 一致性不是“要不要”,而是“在哪一层、对哪条数据、付多少”。同一个系统里,余额走 EC,浏览数走 EL,是正常设计;全都走 EC 会慢,全都走 EL 会资损。
反模式
| 反模式 | 后果 |
|---|---|
| “我们选了 EL,所以不用管一致性” | EL 只是不付跨域协调,收敛、冲突、对账一个都不能少 |
| 只在分区时才考虑 L/C | 平时的 99.9% 时间都在付延迟,却没算过账 |
| 全球部署却要求单 RTT 强一致 | 物理上做不到,只能靠“假装”(把旧值当新值) |
| 把“两地三中心”当高可用 | quorum 能在主中心凑齐,主中心故障就不可写 |
| 跨域写用同步复制,但超时设 100ms | 跨域 RTT 就 150ms,写永远超时 |
| 用本地读做“读己之写” | 用户刚保存就看不到自己的修改 |
| 不监控复制延迟 | 陈旧窗口无界,“最终”变成“可能永远” |
| 以为“选完 EC/EL 就结束了” | 忽略了 CALM/I-confluence 那层判断(见 17.6 CAP 之后的补充理论) |
自检
Q1 一家公司在北京和上海各放 2 个副本(共 4 个),写用多数派(q=3)。它的提交延迟大概是多少?如果一个机房整体故障会怎样?
▸ 答案:leader 在北京时,本地 1 个副本 + 上海 1 个副本就能凑到 2 票?注意 q=3 需要 3 票:本地 1 + 上海 1 = 2,还差 1,必须等上海的第二个副本(同样约 30ms)→ 提交延迟 ≈ 30ms(跨城 RTT)。北京机房故障后,只剩上海 2 票 < 3 → 不可写(只能读)。想让“单个机房整体故障仍可写”,两个机房是不够的:任何 quorum 配置都会在“机房故障时仍有多数派”和“分区时不会两侧都可写”之间二选一(3+3 副本、q=3 时看似两边都能选主,其实分区时两侧各有 3 票,会同时可写)。这也是为什么需要三个故障域(三 AZ / 两地三中心里的第三点),把多数派放到三个域里。
Q2 为什么 Spanner 即使在“没有分区”的时候也要多等一会儿?这笔钱买的是什么?
▸ 答案:它用 TrueTime 给出“绝对时间的不确定区间 ±ε”,为了保证外部一致性(跨洲事务的实时顺序),提交后要等到 2ε 过去,确保“这个事务的时间戳一定早于下一个事务”这一判断不被时钟误差破坏。这笔钱买的是跨地域的线性一致 + 事务,代价是每次提交多几毫秒到十几毫秒(ε 量级 7ms 时约 14ms)。这也说明:即使没有分区,强一致的账单也照付——只是付给了时钟,而不是付给网络。
Q3 一个内容社区把“文章详情”做成 EL(本地读)、“作者收益余额”做成 EC(多数派)。这个组合合理吗?分别要补什么工程手段?
▸ 答案:合理,这是典型的混合取舍。文章详情补:① 陈旧窗口监控(复制延迟 p99)、② 作者发布后自己的“读己之写”(会话保证或发布后等位点)、③ 缓存与 CDN 的失效策略(见 9.1 边缘缓存原理)。余额补:① 多数派写 + 线性一致读、② 分区时少数派拒写、③ 幂等与对账兜底(见 17.5 BASE 与柔性事务)。两者不要共用一个“默认一致性”配置。
Q4 “EL 就是牺牲一致性换延迟”——这句话哪里不准确?
▸ 答案:不准确在“牺牲”二字。EL 牺牲的是读的新鲜度(以及写后立即读自己写的保证),但没有牺牲最终收敛,也没有免除冲突解决的责任。更精确的说法是:EL 把“写时协调”的成本转移到了“读时可能陈旧 + 事后合并”的成本上。另外,PACELC 的 L 也不只是网络 RTT,还包括排队、落盘、GC——同一机房内选 EC 也要付这些成本,不只是跨域才有的问题。