面试官抛出一个经典问题:设计一个跨机房的订单系统,一致性和可用性你选哪个?你答"我都要"。对方笑而不语。

再问:为什么 ZooKeeper 一选主,整个集群就拒绝写入,而 Eureka 明明发现一半节点失联了,还照样对外返回服务列表?为什么 Redis Cluster 默认一个槽位不可用就整集群报 CLUSTERDOWN?为什么 MySQL 主从复制可以在超时后"降级"成异步?

这四个问题背后是同一个东西——CAP 定理。它大概是分布式领域被引用最多、也被误读最多的一条原则。这一篇把它从猜想讲到定理,再讲到十二年后作者本人的纠偏,最后落到真实的选型现场。

一、它从哪来

CAP 不是一开始就叫"定理",它最初是个猜想

  • 2000 年 7 月,UC Berkeley 的 Eric Brewer 在 ACM 的 PODC 会议(Principles of Distributed Computing)上做主题演讲,提出一个判断:一个分布式系统无法同时满足**一致性(Consistency)、可用性(Availability)、分区容忍(Partition tolerance)**这三条。当时没有证明,他称之为 "CAP Conjecture"。
  • 2002 年,MIT 的 Seth Gilbert 和 Nancy Lynch 把这条猜想形式化证明了,论文标题很长但意思很直白——《Brewer 的猜想与一致、可用、分区容忍的 Web 服务之可行性》。至此 CAP 从"行业直觉"变成了"可证明的结论"。
  • 2012 年,Brewer 自己写了一篇《CAP Twelve Years Later: How the "Rules" Have Changed》纠偏。他说当年那句"三选二"被简化得走了样:P 不是一道选择题,而是分布式系统的既定前提;真正的选择只在 C 与 A 之间,而且只在分区发生的那段时间里才需要做。

这三步很关键——很多人脑子里的 CAP 停留在 2000 年的"三选二",而工程上真正有用的是 2012 年的版本。

二、为什么需要它

先把三个字母的严格定义摆清楚,这是全文的基石(日常说法和学术定义差得很远):

字母 含义 严格定义
C 一致性 所有节点看到同一份数据 等价于线性一致性:一次写完成后,任何节点的读都必须看到这次写(或更新的值)
A 可用性 每个请求都能得到响应 每个非故障节点收到的请求,都必须在有限时间内返回非错误响应
P 分区容忍 网络断了系统还能转 网络中任意数量的消息丢失或延迟时,系统仍能继续运行

注意 A 的定义有多苛刻:返回 503 也算不可用。一个系统"还能访问、只是报错",在 CAP 的语境里不算保住了 A。

那为什么必须做取舍?因为分区不是选项,是物理事实。光纤会被挖断、交换机会重启、云厂商的可用区会抖动、机器会因为一次长 GC 停顿几十秒被误判为宕机、丢包本来就是网络的常态。你无法承诺"我的网络永不断"——所以 P 是前提,不是筹码。

分区一旦发生,矛盾就摆到台面上。假设两个节点 A、B 被网络割开,客户端分别往两边写同一个变量:

  • 如果两边都照常响应(保 A),那 A 节点上是 x=1、B 节点上是 x=2,两份数据不一致——丢了 C
  • 如果两边都要保证一致,那么至少有一边必须拒绝写入或拒绝读取(保 C),请求得不到正常响应——丢了 A

本质一句话:CAP 定理 = 网络分区一旦发生,一致性与可用性只能二选一;"三选二"是误读,P 是前提不是选项。

三、一张图看懂

把三个字母重新摆一次位置,就能看出"三选二"错在哪:

CAP 不是"三选二":P 是底色,真正的选项只有 C 与 A C 一致性 所有节点看到同一份数据 A 可用性 每个请求都得到响应 P 分区容忍 前提 · 不可避免 网络分区发生的那一刻,三者不可兼得 ↓ 选 C 弃 A(CP) 宁可拒绝请求,也不给错数据 少数派侧直接不可写 etcd / ZooKeeper / HBase MySQL 半同步复制 选 A 弃 C(AP) 宁可返回旧数据,也要有响应 分区两侧各自可读写 Cassandra / DynamoDB Eureka / Nacos(AP 模式) 那 CA 呢? CA 只在"没有分区可谈"时成立——单机数据库、单进程内存表 一旦跨网络部署,P 就已经被无条件勾选了

四、它有什么用

CAP 不是纸上的概念,它是数据库与中间件选型的第一性问题。四个真实场景:

1. CP 阵营:etcd / ZooKeeper —— 宁可不可用,不给错数据

etcd 用 Raft 做多数派复制。网络一断,少数派那一侧的节点拿不到多数票,直接拒绝写入:

1
2
3
4
5
6
7
8
9
10
# 正常时
$ etcdctl endpoint health
127.0.0.1:2379 is healthy: successfully committed proposal

# 与多数派失联后(少数派侧)
$ etcdctl put /config/db_host 10.0.0.5
Error: etcdserver: request timed out # 不是返回旧值,是直接失败

# 集群健康状态会明确标出失联节点
$ etcdctl endpoint status --cluster -w table

ZooKeeper 更极端:一旦进入选主阶段,整个集群会拒绝所有写入请求。这就是为什么注册中心、配置中心、分布式锁这些"绝对不能读到脏数据"的场景偏爱 ZooKeeper/etcd——它们把 C 看得比 A 重。

2. AP 阵营:Eureka —— 宁可返回过期注册表,也不让服务发现瘫痪

Eureka 有个著名的自我保护模式(self-preservation):如果 15 分钟内收到的心跳续约数低于期望值的 85%,它就不再剔除任何实例,同时对外返回可能已经过期的服务列表。

1
2
3
4
# eureka.server 侧:低于阈值就停止剔除实例
eureka.server.renewal-percent-threshold=0.85
# 客户端侧:本地缓存注册表,服务端挂了也能靠缓存继续调用
eureka.client.registry-fetch-interval-seconds=30

这是 AP 取舍的教科书样本:"服务列表里可能有个已经挂掉的实例"(丢一点 C)远比**"整个服务发现彻底不可用、所有调用方拿不到任何地址"**(丢 A)要好得多。当年 Spring Cloud 生态里 Eureka 与 ZooKeeper 之争,本质就是 AP 与 CP 之争。

3. 可调阵营:Cassandra —— 同一次请求现场选边

Cassandra 把取舍做成了可调参数。设副本数 N=3,读写各要几个副本应答由你定:

1
2
3
4
5
-- 写入要 2 个副本确认,读取也要 2 个副本确认
CONSISTENCY QUORUM; -- R + W = 2 + 2 = 4 > N = 3,保证读到最新

-- 只要 1 个副本确认,追求低延迟与高可用(可能读到旧值)
CONSISTENCY ONE;

R + W > N 时读到最新数据,等于往 CP 侧挪;设成 ONE 则完全倒向 AP。CAP 不是给系统贴的永久标签,而是可以按请求逐次选择的旋钮——这是 2012 年之后最被低估的一点。

4. 滑动档位:Redis Cluster 与 MySQL 半同步

  • Redis Clustercluster-require-full-coverage yes(默认):只要有任何一个哈希槽不可用,整集群就返回 CLUSTERDOWNCLUSTER INFO 里显示 cluster_state:fail——选 C 弃 A。把它改成 no,集群就允许部分槽位失效仍对外服务——倒向 AP
  • MySQL 半同步复制:主库提交后要等至少一个从库 ACK 才返回。如果等待超时,rpl_semi_sync_master_timeout(默认 10 秒)一到就自动降级为异步复制,先让业务跑起来,代价是从库可能落后——这是从 C 滑向 A 的实时降级。

五、反例与边界

CAP 被滥用得厉害,四条边界值得划清:

  • "三选二"是最大的误读。P 不是你能放弃的选项——只要跨网络部署,分区迟早会发生。所以真正的形态是"分区发生时,C 与 A 二选一",而不是"从三个里挑两个"。宣称自己"CA 系统"的分布式产品,要么是在分区时不诚实,要么是把 P 定义偷偷放宽了。
  • 选择是"分区期间"的临时状态,不是永久人设。绝大多数系统在没有分区时同时保 C 和 A。CAP 只在分区窗口内生效。分区结束后,AP 系统要做反熵(anti-entropy)、读修复(read repair)、冲突合并,把两侧的分歧收敛掉——选了 A 不等于永远不要 C,只是把"当下的一致"推迟成"事后的一致"
  • CAP 完全不谈延迟。它只管分区。可现实里 99.99% 的时间网络是通的,此时真正的取舍是"延迟 vs 一致性"。于是有了 PACELC:分区时(P)选 A 还是 C;否则(E, Else)选 L(延迟)还是 C。MySQL 异步复制平时就是在选 L——低延迟,但可能读到旧数据。CAP 解释不了的日常问题,PACELC 才管。
  • CP/AP 是光谱,不是二分。Cassandra 的 LOCAL_QUORUM 可以做到"分区内部保 C、跨分区保 A";很多系统在分区时的表现既不是纯 C 也不是纯 A,而是按数据分片、按请求类型分别处理。把架构决策简化成"我们是 CP 还是 AP",往往是因为还没想清楚到底哪些数据不能脏、哪些请求不能等

六、对比表与小结

维度 CP(保一致弃可用) AP(保可用弃一致)
分区时的行为 少数派侧拒绝读写 两侧各自读写,产生分歧
用户体验 部分请求失败/超时 全部有响应,可能读到旧值
典型系统 etcd、ZooKeeper、HBase、MySQL 半同步 Cassandra、DynamoDB、Eureka、Nacos(AP)
适合场景 配置中心、分布式锁、选主、账户余额 服务发现、购物车、社交动态、监控指标
分区恢复后 直接一致(本来就没分歧) 需反熵/读修复/冲突合并才能收敛
核心代价 可用性受损,故障被放大到业务 复杂度转移,一致性逻辑要业务自己扛

🐾 小结:CAP 定理的价值不在于告诉你"选两个",而在于逼你把话说清楚——当网络真的断了,你的系统到底允许哪一部分坏掉? 配置中心断了不该读到脏配置,所以它选 C;服务发现断了不该全体瘫痪,所以它选 A。真正成熟的架构不是回避这个选择,而是按数据的性质分类:哪些字段宁可不可用也要保一致,哪些字段宁可旧一点也要有响应。想清楚这件事,选型表上的每一个勾,才不是背出来的,而是算出来的。

相关阅读

  • 幂等性:让重试变得安全(分区恢复后的重试与补偿,靠的就是幂等):/posts/princ-idempotency/
  • 端到端原则:TCP 为什么把可靠性放在两端("网络不可靠"正是同一条前提):/posts/princ-end-to-end/