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 也要付这些成本,不只是跨域才有的问题。

关联