CAP 理论

CAP 讲的是网络分区那一刻的取舍:被切开的少数派一侧,是拒绝请求保证不分叉(CP),还是继续服务、接受读到旧值和写冲突(AP)。讲清 Gilbert–Lynch 的严格定义、为什么单机 PostgreSQL 不叫 CA、etcd / MongoDB / Cassandra 分区时各自发生什么,以及面试答法。

CAP theorem 认为:分布式系统只能在 Consistency、Availability、Partition tolerance 三个特性中同时满足两个。

这句话流传最广,也最容易误导。更准确的读法是:网络把副本切成两半时,被切开的那一侧收到请求,要么拒绝(保 C),要么用自己手上可能过期的数据应答(保 A)。没有分区的时候,CAP 什么也没要求;正常运行时的延迟与一致性取舍,归 PACELC 管。

cap-tradeoff

示例

  • 强一致场景:电商下单扣库存,宁愿拒绝请求也不要超卖
  • 高可用场景:聊天/社交 feed,允许短暂旧数据,但系统要持续可用

下面详细看 CAP 中的三个特性:

Consistency

Consistency 意味着所有 client 在同一时间看到相同数据,无论连接哪台 node。为实现这一点,数据写入某个 node 后,必须同步到足够多的 nodes(全部,或保证读写有交集的多数派),写入才算“成功”。

CAP 里的 C 有严格含义。Eric Brewer 在 2000 年 PODC 的 keynote 提出这个猜想,Seth Gilbert 和 Nancy Lynch 2002 年给出证明,证明里的 consistency 是 atomic / linearizable:所有操作看起来像在某个全局顺序上一个接一个发生,一次写完成后,之后开始的任何读都必须看到它或更新的值。这和 ACID 的 C(约束不被破坏)是两回事。

Availability

Availability 意味着任何对数据的请求都能得到响应,即使部分 nodes 已经宕机。

Gilbert–Lynch 的定义是:每个没有故障的节点收到的每个请求,都必须最终返回一个非错误的响应。注意两点:返回错误或一直超时都不算 available;这个定义没有时间上限,所以它和 SLA 里的 99.99% 不是一个概念。

Partition tolerance

Partition tolerance 意味着系统能在消息丢失或部分失败时继续工作。具备 partition tolerance 的系统能承受一定网络故障,不导致整体网络不可用。数据会在多个 nodes / networks 组合中充分复制,保证系统在间歇性故障中仍可运行。

证明里的 partition 是「网络可以丢掉任意多条节点之间的消息」。交换机故障、跨机房专线断开、安全组规则改错、一台机器 GC 停顿 30 秒让别人以为它死了,在其他节点看来都是同一件事:消息不再到达。

有约束的设计问题

一个会员积分系统,数据库 3 个副本分在同一区域的 AZ-a、AZ-b、AZ-c,每个 AZ 都有应用服务器。某天 AZ-c 和另外两个 AZ 之间的网络断了 4 分钟,但 AZ-c 内部正常,负载均衡仍把一部分用户送到 AZ-c 的应用服务器。

这 4 分钟里 AZ-c 会收到两类请求:

  • 积分兑换:用户用 1000 积分换一张券。同一笔积分被 AZ-c 和 AZ-a 各兑换一次,就是资损。
  • 每日签到:记录今天签到过、加 5 分。两边各记一次,恢复后按「同一天只算一次」合并就行。

AZ-c 只有 1 个副本,看不到另外 2 个。它能做的只有两件事:拒绝(兑换失败,请稍后重试),或者用本地副本应答(可能读到旧余额,可能写出和另一侧冲突的数据)。这就是 CAP 描述的全部选择,而且它是按请求类型选,不是按数据库选:兑换走 CP,签到走 AP,完全可以在同一个系统里。

Consistency-Availability Tradeoff

现实世界里网络并不稳定,因此分布式系统必须选择 Partition Tolerance (P)。这意味着 Consistency (C) 与 Availability (A) 之间需要取舍。

Brewer 在 2012 年的回顾文章 CAP Twelve Years Later 里专门纠正过「三选二」的说法:分区很少发生,没有分区时系统可以同时有 C 和 A;真正的选择只出现在分区期间,而且可以按操作、按数据分别做,分区结束后再做恢复和补偿。

为什么没有「CA 数据库」

旧版资料常把 PostgreSQL、MariaDB 列为 CA database。问题在于:

  • 单机 PostgreSQL 不在 CAP 的讨论范围里。 只有一个节点就没有节点之间的分区;它挂了就是整体不可用,这是单点故障,不是在 C 和 A 之间做了选择。
  • 一旦加上复制,就要回答分区时怎么办。 PostgreSQL 配了同步 standby(synchronous_standby_names 非空)后,standby 不可达时 primary 上的提交会一直等待,这是选了 C;用默认的异步流复制,primary 继续提交,但此时如果把 standby 提升为新 primary,最近已确认的提交会丢失,读 standby 也会读到旧值,严格说两边都不满足。Martin Kleppmann 2015 年的 A Critique of the CAP Theorem 专门讨论过这一点:按 Gilbert–Lynch 的严格定义,很多常见系统既不是 CAP 意义上的 C,也不是 A,硬贴 CP / AP 标签反而说不清它的行为。
  • 分区不是你能「不选」的东西。 系统能选的只有分区发生时的行为,所以「放弃 P」在多节点系统里没有意义。

所以更准确的问题是:这个系统在这种配置下、这类操作上,分区时会怎样。

CP:少数派一侧拒绝服务

CP database 保障 consistency 与 partition tolerance,牺牲 availability。当 nodes 发生 partition 时,拿不到多数派的那一侧停止接受写(通常也停止强一致读),直到恢复连接。

Example: etcd、Apache ZooKeeper、Apache HBase,以及按 majority 读写配置的 MongoDB

  • etcd(Raft):写要多数派确认,3 成员的集群法定人数是 2。被切到少数派的成员收不到 leader 心跳,发起选举也凑不够票;它上面的写和 linearizable 读会失败或超时,只有显式要求的 serializable 读还能返回本地(可能过期的)数据。
  • MongoDB 副本集:看不到多数派成员的 primary 会自己 step down 变成 secondary,另一侧的多数派选出新 primary。官方文档写明,默认配置下选出新 primary 的中位时间通常不超过 12 秒,这段时间里写会失败,驱动的 retryable writes 可以自动重试一次。

AP:两侧继续服务,事后收敛

AP database 保障 availability 与 partition tolerance,牺牲 consistency。发生 partition 时所有 nodes 仍可用,但部分 nodes 可能返回旧数据;partition 解决后再重新同步。

Example: Apache Cassandra(低 consistency level 时)、CouchDB

  • Cassandra 的 consistency level 按请求选。RF = 3 时,用 ONE 读写,AZ-c 里那个副本自己就能应答,两侧都继续服务;用 QUORUM(需要 2 个副本),AZ-c 一侧凑不够,协调节点直接返回 UnavailableException。同一个集群,一个参数就从 AP 变成了 CP。
  • CouchDB 允许多个副本各自接受对同一文档的修改,复制时发现冲突会保留所有冲突版本,按确定性规则挑一个作为胜出版本,其余留给应用读出来自己合并。

同一个系统,配置决定分类

系统与配置分区时少数派一侧的写少数派一侧的读恢复后
etcd失败 / 超时linearizable 读失败;serializable 读返回本地旧值旧 leader 看到更高 term 退回 follower,不丢已确认的写
MongoDB,w: "majority"旧 primary step down 后失败读 primary 失败;secondaryPreferred 可读到旧值没被多数派确认的写进入 rollback;多数派确认过的写不会被回滚
MongoDB,w: 1step down 之前的几秒里仍可能成功同上这几秒成功的写可能被回滚,客户端却已经收到成功
Cassandra,QUORUM 读写UnavailableExceptionUnavailableExceptionhinted handoff、read repair、anti-entropy repair 补齐
Cassandra,ONE 读写成功成功,可能是旧值按写入时间戳 last-write-wins 合并,时钟偏差会让新值被旧值覆盖
PostgreSQL 同步 standbyprimary 提交挂起standby 可读,可能滞后网络恢复后提交继续;或降低同步要求并接受风险

Abadi 2012 年的 PACELC 论文也是这个思路:MongoDB 默认配置在他的分类里是 PA/EC,而不是简单的 CP。

算一遍:AZ-c 断开的 4 分钟

继续开头的积分系统。假设 AZ-c 承接 1/3 的流量,高峰每秒 300 次积分兑换,也就是 AZ-c 每秒约 100 次。

兑换走 CP(MongoDB w: "majority",读 primary)。 如果 primary 恰好在 AZ-c,它发现看不到多数派后 step down;AZ-a/b 选出新 primary,中位时间 12 秒以内。这十几秒全站兑换失败,约 300 × 12 = 3600 次请求需要重试;之后 AZ-a/b 恢复服务,AZ-c 的应用服务器连不上 primary,它承接的约 100 × 228 ≈ 22,800 次兑换在剩下的 228 秒里全部失败。对策是负载均衡的健康检查把 AZ-c 的应用摘掉,把用户送到 AZ-a/b,而不是指望数据库在 AZ-c 里「可用」。

如果兑换错误地用了 w: 1。 primary 在 AZ-c、还没 step down 的那几秒里,它照样确认兑换;这些写没复制出去,AZ-a/b 选出的新 primary 看不到。恢复后旧 primary 回滚这几秒的写,用户手里已经拿到的券对应的扣分记录消失了。

签到走 AP。 两侧都照常写入签到,AZ-c 里的用户签到成功、积分显示 +5。网络恢复后同一用户同一天可能有两条签到记录,业务层按 (user_id, date) 去重,只加一次分。用户在这 4 分钟内看到的积分可能和另一侧不一致,恢复后收敛。

一个系统,两类操作,两种选择。分区期间的损失(22,800 次兑换失败)是可以提前算出来并写进故障预案的。

常见误解与翻车

误解实际会发生什么正确说法
「我们用 CA 数据库,所以不用考虑分区」加了副本后分区照样发生,行为取决于同步 / 异步配置单机没有 CAP 问题;多副本就必须说分区时的行为
「MongoDB 是 CP,所以不会丢写」w: 1 的写在 primary step down 前可能被确认后回滚CP 要靠 w: "majority" 这类配置实现
「Cassandra 是 AP,所以只能最终一致」QUORUM 读写满足 W + R > RF,分区时少数派报错Consistency level 按请求选,同一集群两种行为都有
「选了 CP 就高可用不了」多数派一侧照常服务,只有少数派一侧不可用CP 损失的是少数派一侧的可用性,健康检查能把用户导走
把 CAP 的 A 当成 SLACAP 的 A 没有时间上限,也不关心响应多慢延迟问题用 PACELC 或 SLO 讨论
忽略缓存和只读副本数据库 CP,前面挂了 60 秒 TTL 的缓存,用户照样读到旧值一致性要按整条读路径算

面试时这样回答

  1. 先改写问题。 「分区时被切开的那一侧,这类请求是拒绝还是用本地数据应答。」不要说「我选 CA」。
  2. 按数据分类。 钱、库存、唯一性约束走 CP;计数、点赞、签到、feed 走 AP,并说出恢复后的合并规则(去重键、取最大值、按时间戳)。
  3. 点名机制和配置。 CP 靠多数派:Raft、w: "majority"、Cassandra QUORUM;AP 靠低 consistency level 加 hinted handoff 和 read repair。
  4. 算分区期间的代价。 少数派一侧失败多少请求、选举要多久、用户被健康检查导走需要几秒。
  5. 追问防守。 被问「为什么不全用强一致」,答延迟和少数派不可用;被问「PostgreSQL 算哪类」,答单机不适用、加复制后看同步还是异步;被问正常时的取舍,引到 PACELC。

相关章节:PACELC Theorem、Consistency 模式、Database Replication、Availability 模式、多区域架构。

一手证据