这问题我被人问过无数次:新系统评审,架构师指着架构图问“你这个存储到底是CP还是AP”,会议室里一片安静,有人小声接了句“都支持吧”。其实这个回答也不算错,但“支持”和“你在分区时保哪个”是两件完全不同的事。CAP定理的麻烦之处在于,它听起来只是三个字母的排列组合,好像背下来就行,可真到选型时,90%的纠结都不在这三个字母本身,而在你愿不愿意接受分区发生时的那个代价。
这篇速查手册想做的事很简单:帮你把CAP这层窗户纸捅破,先讲清楚C、A、P三个字母到底在说什么,再给出一张主流中间件的归属速查表,最后给一套能直接拿去用的选型决策步骤。适合正在做分布式系统设计、准备系统设计面试、或者在为订单、库存、用户画像这类核心业务选存储的人收藏,下次被问到CAP,不用再支支吾吾。
1. 先搞清楚CAP到底在说什么:三选二其实是个伪命题
CAP定理的原始表述来自Eric Brewer在2000年的分布式系统大会上的一个猜想,两年后被证明。整个定理严格说起来只有一句话:在一个分布式系统中,当网络分区发生时,你必须在一致性和可用性之间做一个选择,不可能两者兼得。
网络分区这个词听着抽象,实际翻译过来就是:分布式系统里的节点之间是靠网络通信的,而网络这个基础设施根本不保证永远通。机房光缆被挖断、交换机故障、云厂商某个可用区瘫痪,都可能导致一部分节点联系不上另一部分节点。只要系统分布在两台机器以上,分区就是客观存在的物理现实,它不是你可以在设计时忽略的东西。
1.1 C、A、P分别是什么,别被教科书绕晕
一致性(Consistency):指的是所有节点在同一时刻看到的数据是相同的。用户写入了一个新值,之后无论从哪个节点读,都必须是这个新值,不能出现有的节点还是旧值的情况。注意这里说的是“同一时刻”,这是强一致,不是“过一会儿就一致了”。
可用性(Availability):指的是系统在接到请求时,必须保证能够在合理时间内给出响应,而且这个响应不能是“系统出错,请稍后重试”这种错误。注意,CAP里的可用性和平常说的“高可用”不是一回事。高可用通常指的是系统整体不宕机,而CAP里的A侧重的是“每个请求都能收到一个非错误的回复”。
分区容错性(Partition tolerance):指的是系统在网络分区发生的情况下,依然能够继续运行、继续对外提供服务。
1.2 为什么说“三选二”是伪命题
绝大多数文章为了好记,把CAP理解成“三者只能选两个”,于是有了“CP系统”“AP系统”的说法。这个理解不算全错,但不准确的地方在于它没有强调一个前提:分区是必须容忍的。你先别管C和A,P这个东西不是你要不要选,而是它一直存在,你必须接受它,不接受也得接受。
所以CAP真正的逻辑是:网络必然会发生分区。分区一旦发生,系统就分裂成了两个孤岛,此时你只有两条路可以走:
一条路是保C放弃A。比如客户端向某个节点写数据,这个节点发现没法把数据同步给其他节点,于是拒绝写入请求,告诉客户端“我现在写不了”。系统保持着一致,但有一部分请求得不到有效响应,这就是CP。
另一条路是保A放弃C。节点收到写入请求后,直接存下来,哪怕知道数据暂时同步不到其他节点,也先返回成功再说。系统始终能响应,但此时你在不同节点上读到的数据可能不一致,这就是AP。
分区不发生的时候呢?说实话,无分区时所有节点通信正常,C和A可以同时满足,这也是为什么很多人平时根本察觉不到问题,直到某次机房故障才暴露出系统的真实取舍。
1.3 最常见的一个误区:拿“最终一致性”往C上套
我见过不少人在设计文档里写“我们选择最终一致性方案,所以严格来说是CP系统”,这个表述对最终一致性体系的整体目标是有误解的。实际上,最终一致性是AP系统在分区之后,为了修复数据分裂而采用的一种补偿机制。选AP意味着你承认分区的瞬间数据可能不一致,之后想尽办法把它织补回一致的状态,这个“织补过程”是最终一致性的价值。它不能反过来证明你是个CP系统。
打个比方,CAP讨论的是“两个人异地记账时,电话突然断了怎么办”。CP方案是:电话断了我就拒绝记账,直到恢复通话并核对清楚;AP方案是:电话断了我也照样先记着,回头再拿各自记的账本碰一次,合并冲突。最终一致性是“回头碰账本”的那套流程,不是第三种记账理论。
2. 一张表看懂主流中间件:哪些天生CP,哪些天生AP
到了选型这一步,真正有用的不是再背一遍定理,而是把心里那几个常用组件一张表摊开,看清楚它们在网络分区时到底默认保哪个。我按自己这些年生产环境的经验,把主流中间件的归属、一致性语义、分区时的表现整理成了一张速查表,先说结论,再逐个解释。
| 中间件 | 默认倾向 | 分区时的行为 | 典型场景 |
|---|---|---|---|
| ZooKeeper / etcd | CP | 少数派拒绝读写,多数派继续服务 | 分布式锁、选主、元数据存储 |
| HBase | CP | RegionServer分区后,少数侧不可用 | 海量结构化数据、需强一致的在线服务 |
| Cassandra / DynamoDB | AP | 所有节点继续接受读写,冲突后合并 | 订单历史、消息、IoT、用户画像 |
| Redis Cluster | 偏AP | 多数侧可用,少数侧拒绝写入 | 缓存、会话、高并发读多写少 |
| Kafka | 特殊CP | ISR机制,分区后少数侧不可用 | 消息队列、事件流、日志收集 |
| Elasticsearch | 偏AP | 可用性优先,副本同步放宽 | 搜索、日志检索、OLAP分析 |
2.1 ZooKeeper和etcd:CP的正统代表
ZooKeeper和etcd的底层都基于Raft共识算法,核心思路是多数派写成功才算写成功。只要多数节点还活着,系统就能继续对外服务;少数派节点如果断网了,它会拒绝客户端请求,因为此时接受写入必然导致数据分叉,后面很难收场。
使用这两者的场景,基本都是一旦数据错了就全盘错的元数据节点:分布式锁的状态、选主结果、配置中心里的配置项、服务注册发现列表。这些数据平时读多写少,但对一致性要求极高,宁可让个别客户端请求失败重试,也不能让两个节点抢到同一把锁。
经验之谈:etcd因为API更现代、运维更简单,已经逐渐成为Kubernetes等云原生体系的事实标准;ZooKeeper在很多老系统里仍然是刚需。选它们之前先想清楚,你的业务能不能接受“分区时有一部分请求报错”,如果能,CP这条路就是通的。
2.2 Cassandra和DynamoDB:AP阵营的代表
Cassandra采用Dynamo风格的gossip协议和最终一致性模型,设计哲学是“任何时候都能写”。它的每个节点都是对等的,没有绝对的leader,客户端可以往任意节点写入,节点的数据通过异步机制相互传播。分区发生时,孤立的节点照样接受请求,等分区恢复后再通过版本号、时间戳等手段合并冲突。
这类系统的典型场景是那些即使数据暂时不一致也不会造成大问题的业务:订单历史(多一笔少一笔可以后补)、消息记录(重复消费可以幂等处理)、用户画像标签(晚几秒更新无所谓)。Cassandra在写入上的水平扩展能力非常猛,适合海量数据写入压力极大的场景。
使用AP系统最怕的不是分区,而是业务侧把“最终一致”错当成“反正不用管”。事实上,AP系统需要你在应用层设计好冲突解决策略、幂等机制和补偿流程,数据脏了得有办法清理,这是选AP的真正门槛。
2.3 Redis Cluster:你以为它保C,其实它保的是A
Redis Cluster在CAP里是个很有意思的存在。从Redis 3.0引入Cluster模式开始,它的设计目标是高可用和水平扩展。节点之间使用异步复制,主节点写入后返回客户端成功,同时异步把数据复制给从节点。
当主从节点间发生网络分区时,Redis Cluster的策略是:如果某个分片的主节点和它的从节点失去联系,从节点所在的多数派会选举新的主节点;而旧主节点如果发现自己已经在少数派一侧,会停止接受写入请求。这样既保证了多数派侧始终可用,又避免了两个主节点同时写导致的数据分叉。严格说,在主从切换的那一瞬间,旧主节点还没有来得及同步的写入数据会丢失,所以它算不上强一致,更接近CP与AP之间折中,但整体上偏AP。
用Redis做缓存时你根本不用纠结这些问题,缓存的核心就是允许过期、允许回源。但如果你打算把Redis当作数据库存订单、存余额,就一定要意识到它主从切换丢数据的可能性,务必设计好补偿机制,或者直接用RedLock之类额外处理锁安全问题。
2.4 Kafka:用ISR机制在CP和AP之间走钢丝
Kafka默认给每个分区配置多个副本,但只有ISR(同步副本集合)里的副本才被允许参与leader选举。生产者发送消息时,如果设置了acks=all,消息要写入所有副本才算成功,这时Kafka的CP性质非常强,分区leader和ISR中的剩余副本失联时,这个分区不再接受写入。
但实际生产环境里很少有人真的每条消息都等到所有副本同步完,通常你会设置acks=1或者acks=all但开启min.insync.replicas。这样Kafka出现了延迟接受写入的空间:leader接收消息后返回成功,但部分副本还没同步完,此时如果leader宕机,消息就有丢失风险。所以Kafka的定位更像“在一致性和可用性之间可调”,你需要根据消息的重要程度去调整副本配置。
用Kafka最典型的实践是:核心支付消息要求严格不丢,acks=all、min.insync.replicas=2,宁可吞吐低一点;而日志流、监控指标这类数据,acks=1甚至0都行,丢一点无伤大雅。
2.5 Elasticsearch:可用性优先,但会给你视觉上的“一致”
Elasticsearch在分区时的表现偏向AP。它有副本机制,但写入时默认只要主分片写成功就返回,副本写入是异步的。当主节点和副本节点之间发生网络分区时,为了保证搜索和写入可用,Elasticsearch倾向于让主分片继续接受写入,副本分片的数据滞后并不会阻止请求处理。
这导致一个搜索结果可能看起来永远读得到最新数据——因为你只访问到了主分片。但从副本分片路由过去的请求就会读到旧数据,在搜索这种场景下几乎没人较真,可如果你用Elasticsearch存订单做对账,这个“看起来一致”的假象就会坑人。经验教训是:Elasticsearch适合做检索、分析、日志这类对实时一致性不敏感的业务,不适合做主存储源,它更应该是业务数据库的下游索引,数据由数据库异步同步过来。
3. 从理论到决策:分区前怎么设计,分区后怎么办
很多人在设计系统时默认“网络分区是小概率事件”,于是干脆不去设置降级策略,直到某天真的出现分区,才发现系统在故障时的行为根本不在掌控之中。我从事故复盘的角度给各位提个醒:网络分区并没有你想象中那么罕见,跨可用区部署之后,每一条链路抖动都可能是分区的诱因。云厂商的可用区之间走的是独立机房,看似可靠,实际上带宽拥塞、光缆被挖断、DNS故障,甚至发布变更误伤网络配置,都可能导致跨区通信中断。设计系统时,你必须默认“分区一定会来,只是时间问题”,才能在它来的时候不手忙脚乱。
3.1 同步复制与异步复制:CP和AP的技术底座
CP系统的底层逻辑几乎都是同步复制。以Raft算法为例,leader节点接收到写入请求后,会将日志复制给大多数follower,等follower们确认落盘,才向客户端返回成功。这个过程保证了分区发生时,只有拥有最新日志的多数派节点可以继续选举出新的leader,少数派无论写什么都无济于事。代价非常直观:延迟变高。每次写入都要经过一轮或几轮网络往返,跨可用区时尤其明显。
AP系统的底层逻辑则是异步复制加冲突检测。节点之间同步数据是“后台任务”,用户写入先落本地,异步地向其他节点传播。分区发生时,孤岛节点照常接受写入,等网络恢复后再把对方缺失的数据同步过来。代价是数据可能出现冲突,比如同一个用户的余额在两个节点上各被修改了一次。
理解这一点后,你会发现选CP还是选AP,本质上是在选择一种“复制模型”:要同步复制就别怕延迟和不可用,要异步复制就必须构建一套冲突处理机制。很多组件号称“高可用、强一致”,你往底层一看,真正能扛住分区考验的还是Raft这类共识算法,或者Paxos,其他那些靠异步同步再号称强一致的,都是在偷换概念。
3.2 分区恢复之后,才是真正考验系统的时候
CP系统在分区恢复后比较简单:少数派节点重新连回集群,自动从leader同步缺失的数据,追上进度后重新加入。棘手的是,恢复期间你要确保丢失的写入请求得到了客户端重试,否则用户虽然在分区时收到了错误,但那个写入可能已经丢了。设计CP系统时,客户端侧的自动重试和幂等操作是必不可少的配套工程。
AP系统恢复后的工作量大得多。两个分区的数据都要合并,Cassandra会用Last Write Wins之类的时间戳策略来自动覆盖,但相同主键的并发写入可能丢失其中之一;DynamoDB的冲突合并也是类似逻辑,需要你提前想清楚这个业务是否允许丢旧保新。真正确保不丢数据的做法,还是应用层的幂等设计和最终对账:把消息队列里的记录、操作日志里的原始事件都保留下来,恢复后用对账任务把差异补上。没有这套机制就盲目上AP系统,迟早会在某个深夜被对账报表上的缺口吓得睡不着。
3.3 故障半径:架构设计比单纯选CP或AP更重要
CAP的选型经常被当成一个非此即彼的决定,仿佛整个系统要么全CP,要么全AP。但真实业务的架构里,不同数据、不同链路完全可以用不同策略,关键是划分好故障半径。
我最常用的设计方法是“分模块定级”:核心资金链路(支付、余额、订单状态)用CP语义,宁可故障拒绝也不能给用户一个错误数字;非核心链路(浏览记录、推荐位、操作日志)用AP语义,允许短时间不一致、允许丢一小部分数据;至于那些本来就无所谓的统计数据,用最终一致性就够。
进一步的架构优化是“分区范围内的CP化”:不要试图让全球所有节点保持强一致,而是把强一致的范围收缩到一个小的选举组,跨组之间用异步同步。比如把某个用户的订单数据限定在同一可用区组内做Raft同步,跨可用区通过异步复制备份,这样既控制了延迟,又保持了核心数据在单区域内的强一致。故障半径越小,系统的稳定性和性能越好,这是我做架构设计时最核心的一条原则。
4. CAP之外:一致性等级、延迟预算与运维成本才是真正的胜负手
我在评审系统设计的时候经常遇到一种情况:候选人把“我们用了etcd所以是CP”写在方案里,然后觉得这个问题就算回答完了。但CAP只是给定了一个约束范围,选CP还是AP之后,系统设计仍然有一大堆决定要下。换句话说,CAP是起点,不是终点。
4.1 一致性本身是个光谱,不是只有“一致”和“不一致”
线性一致性是最强的一致性,意思是所有操作看起来像在一台单机上按某个顺序执行,任意时刻读到的都是最新写入的数据。顺序一致性和因果一致性稍弱一些,它们关注的不是实时性,而是操作顺序的合理性。再往下还有单调读一致(同一会话内不会读到更旧的数据)、会话一致性(局限于连接会话内)、最终一致性(早晚会一致但不保证时间)。你在绝大多数业务里根本不需要线性一致,比如微博热度排序、推荐流、消息未读数,会话一致性甚至最终一致性就已经足够。
所以每次选型前先问业务自己:这个数据被读错了会怎样?如果答案是“用户刷新一下就好”,那完全可以走AP链路,不必为了一个不必要的线性一致把可用性和性能都搭进去。如果答案是“涉及金额、涉及权利归属、涉及流程状态”,才需要严格考虑CP方案。
4.2 延迟预算:你愿意拿多少毫秒换一个“绝对正确”
CP系统的强一致不是免费的。Raft类的多数派提交,在最坏情况下每写一次都要经过leader到多数follower的网络往返,跨可用区场景下延迟轻松超过几十毫秒,如果follower分布在较远区域,上百毫秒也很常见。有些业务对延迟极其敏感,比如用户登录态、商品详情页缓存,多50毫秒都可能导致转化率下降,这种业务几乎不可能全量采用CP方案。
AP系统在延迟上有天然优势,写入本地即可返回,代价是后续的同步和补偿。现实中大多数公司采用的其实是“分层混合”方案:用CP系统保护最核心的状态机(订单状态、支付状态、库存扣减),用AP系统加速边缘数据(用户行为、日志、搜索索引),最后通过异步同步机制把AP数据回流到核心系统,让整体逻辑不至于乱套。这个“冷热分层”的设计模式,比在一张技术选型表里勾选CP还是AP重要得多。
4.3 运维成本:CP要防选举风暴,AP要防数据腐烂
选CP,意味着你要和一个极其敏感的leader选举机制长期共处。网络抖动可能导致follower发起leader选举,选举期间系统短暂不可用;频繁的网络抖动甚至会让系统陷入长时间的选举风暴,整个集群颤抖不止。运维CP系统需要精心配置心跳超时、选举超时、拉长毛刺容忍窗口,还要做好客户端重试退避,这些工作非常考验团队的运维能力。
选AP,运维压力不会消失,只是换了个方向。你要面对的是持续不断的读后写冲突、Cassandra里的墓碑机制、DynamoDB中的版本向量、数据修复工具的定期巡检。没有这些配套运维,随着时间推移,AP系统的数据会越来越脏,最终变成一团无法解释的烂账。
所以,不管是CP还是AP,都需要运维投入。当年我在考量一个系统该不该换存储时,最先算的不是它宣称的QPS和P99延迟,而是“现在团队有没有人长期盯数据链路”,如果没有,哪怕这个系统理论上再优秀,也要慎重。
4.4 BASE理论:最终一致性系统的落地配方
讲CAP很难绕开BASE——Basically Available(基本可用)、Soft state(软状态)、Eventually consistent(最终一致)。BASE是对AP系统落地方式的一种概括:系统整体保持可用,数据状态允许是软性的、会变化的,在时间窗口内趋于一致。落地BASE方案时,一套标准的配方是:
- 事件驱动架构:业务操作先记事件,再通过消息队列异步更新各个下游,避免直接跨系统调用。
- 幂等消费:消息可能重复投递,消费者必须根据业务主键做幂等,保证重复执行结果相同。
- 对账任务:定期扫描核心表和对账表,找出差异并自动修复。
- 补偿事务:对于跨系统的长流程操作,在某个环节失败后,通过反向操作把之前已经生效的动作回滚。
这套配方设计好了,AP系统才算是真正有兜底,而不只是“先记下来再说”。
5. 速查决策框架:六步判断你的系统该选CP还是AP
最后把前面的内容落成一套可以照着执行的方法论。我给自己总结了一个“六步选型框架”,这些年做系统审查都是先跑完这六步再动技术选型,基本不会走偏。
第一步,问业务能不能接受旧数据。拿笔在纸上列一下,当前这个数据的读请求如果偶尔读到一分钟前的快照,用户会发现吗?会造成资损吗?会影响流程状态判断吗?如果答案是“完全无感”,直接进入AP赛道,不用犹豫。
第二步,明确一致性等级。不要笼统地说“要强一致”,把一致性等级细化:是全局线性一致,还是仅要求不出现环形依赖?是会话内单调读,还是最终一致即可?这一步决定你对具体组件的选择标准。
第三步,评估故障场景和故障预算。列出你的系统可能遭遇的分区场景:单机房宕机、跨区断网、交换机故障。估算每种场景的期望概率和影响时长。再问一个问题:如果某类请求在故障高峰期拿不到正确数据,业务能不能扛住十分钟?这个时间就是你能接受的故障恢复目标。
第四步,反馈到延迟和吞吐约束。先算好当前业务的读写比例、峰值QPS、可接受的P99延迟。然后对照候选组件在CP/AP模式下的基准性能,看看是不是有哪一个直接出局。记住,理论上再完美的方案,如果延迟达标不了,也是纸上谈兵。
第五步,选择组件并做故障演练。把候选组件在测试环境里人为制造分区,观察它的实际表现:哪个节点还在响应?数据读出来是否一致?恢复后能不能自动追平?这一步一定要做,不能只信文档,我见过太多组件在文档里写“strong consistency”,真到故障演练时发现丢数据的丢数据、拒绝服务的拒绝服务。
第六步,设计降级与恢复流程。无论选了哪条路,都必须配套设计好分区期间的行为:客户端重试策略、降级开关、恢复后的数据校验、对账任务。把这一项写进系统的运行手册,确保故障发生时值班人员能照着操作。
为了让你在方案评审时能快速自检,我整理了一份经验清单,建议直接贴在你的设计文档末尾:
- 是否明确写出本系统在网络分区时优先保证C还是A?
- 是否定义了“可用”的含义?是“所有请求返回合理响应”还是“核心链路返回合理响应”?
- 是否明确了一致性等级(线性、因果、会话、最终)?
- 是否定义了数据不一致的最大容忍时间?
- 是否设计了由AP数据引发的冲突解决策略?
- 是否已制定分区故障演练预案?
- 是否考虑了客户端侧的重试与幂等?
- 是否规划了对账与补偿任务?
我自己在这套框架上吃过不少亏。早年间做过一个用户钱包系统,当时觉得“反正量不大,用MySQL主从就够了”,从来没有真正模拟过主库宕机后的行为。后来真的发生了一次机房级别故障,从库顶上来之后,一批流水对不上账,凌晨四点爬起来用脚本逐条比对,花了整整一天才把数据修干净。那次之后我给自己立了个规矩:凡是涉及钱和状态的数据,一律先按CP设计,并在上线前完成分区演练;凡是允许短期不一致的数据,一律按AP设计,并且把对账流程设计得比业务逻辑更严格。这个习惯,让我后来的几次大故障都从容了不少。
对你也是一样,CAP不是一个面试背完就忘的知识点,它是你每次做架构决策时都要面对的一张考卷。下一次有人问你这个系统是CP还是AP,回答“我们是CP”或者“我们是AP”之前,先想清楚:分区那一刻会发生什么,恢复那一刻你又要做什么。想明白了这两件事,选型就已经成功了一大半。