CAP、BASE 与一致性理论
CAP 和 BASE 是理解分布式系统的理论基础。它们不是为了背面试题,而是帮助你判断:为什么有些系统宁愿短暂读到旧数据,也要保证服务可用;为什么有些资金系统宁愿拒绝请求,也不能返回不确定结果。
CAP 是什么
CAP 指三个目标:
| 字母 | 含义 | 初学者理解 |
|---|---|---|
| C | Consistency 一致性 | 所有节点看到的数据一样,读到最新写入 |
| A | Availability 可用性 | 每个请求都能在有限时间内得到非错误响应 |
| P | Partition Tolerance 分区容错性 | 节点之间网络断开时,系统仍然能处理问题 |
最容易误解的是“CAP 三选二”。更准确的说法是:
在分布式系统里,网络分区必须考虑;当分区真的发生时,系统只能在一致性 C 和可用性 A 之间取舍。
P 不是你想不想要的问题。只要系统跨机器、跨网络,就必须面对网络分区。
C 不是“所有数据永远一样”
CAP 里的 C 更接近“线性一致性”:一次写入成功后,后续读请求应该读到这次写入的结果,系统表现得像只有一个最新副本。
小白容易把一致性理解成“数据库里最后一定一样”。这还不够。真正难点在于:
- 写请求已经返回成功了,另一个节点还能不能读到旧值。
- 网络断开时,两个分区能不能同时接受互相冲突的写。
- 请求超时时,调用方不知道下游到底有没有成功。
- 多副本异步复制时,读从库、读缓存、读 ES 可能读到历史版本。
所以 CAP 讨论的不是“最终会不会同步”,而是“分区发生时系统是否还允许用户看到或写入不确定状态”。
A 不是“永远不宕机”
CAP 里的 A 指每个到达非故障节点的请求都能在有限时间内得到响应,而且响应不是简单报错。
这和日常说的高可用不完全一样。比如系统发生网络分区时,如果少数派节点为了保护一致性拒绝写入,它在 CAP 语义下就是牺牲了 A;但从工程上看,这是有意保护数据正确性的降级。
P 不是“分库分表”
Partition 指网络分区:消息丢失、网络中断、机房隔离、路由异常、DNS 异常、防火墙策略错误、交换机故障,都可能让两个节点短时间互相不可达。
只要服务通过网络通信,就不能假设网络永远可靠。分布式系统所有“超时、重试、幂等、补偿、对账”的设计,根本原因都来自这个不可靠网络。
什么是网络分区
网络分区不是数据库分库分表,而是节点之间网络不通。
flowchart TD
A["用户请求"] --> B["服务节点 A"]
A --> C["服务节点 B"]
B --> D["数据库主节点"]
C -. "网络中断" .-> D此时节点 B 可能无法访问数据库主节点。如果它继续返回本地旧数据,可用性好但一致性差;如果它拒绝请求,一致性更安全但可用性下降。
分区发生时为什么不能同时要 C 和 A
假设库存服务有两个节点 A 和 B,网络断开后两个节点互相同步不了。
flowchart TD
A["库存副本 A<br/>库存=1"] --> B["用户 1 下单"]
C["库存副本 B<br/>库存=1"] --> D["用户 2 下单"]
A -. "网络断开,无法同步" .- C
B --> E{"A 是否允许扣减"}
D --> F{"B 是否允许扣减"}如果两个副本都为了“可用”接受扣减,就可能把同一件库存卖给两个人,一致性被破坏。
如果为了“一致”,不能确认全局最新状态的副本就必须拒绝请求或等待多数派确认,可用性下降。
这就是 CAP 的核心:不是系统设计者不够努力,而是在网络分区这个前提下,信息无法传递,系统不可能凭空知道另一个分区发生了什么。
CP 和 AP 怎么选
CP:优先一致性
分区发生时,宁愿拒绝部分请求,也不返回可能错误的数据。
适合:
- 余额扣减。
- 支付记账。
- 库存最终确认。
- 权限变更。
flowchart TD
A["发生网络分区"] --> B{"是否能确认最新数据"}
B -- "能" --> C["继续处理"]
B -- "不能" --> D["拒绝请求或等待恢复"]
D --> E["保护一致性"]CP 不是“系统一定不可用”,而是“不能确认一致性时宁愿拒绝”。ZooKeeper、etcd 这类协调组件常用多数派提交:只有能拿到多数派的分区能继续写,少数派不能写。这样可以避免两个分区各自写出冲突结果。
代价是:
- 少数派节点看起来还活着,但不能对外提供完整写能力。
- 写请求要等多数派确认,延迟更高。
- 网络抖动时可能出现请求失败、重试和短暂不可用。
AP:优先可用性
分区发生时,允许短暂不一致,后续通过同步、补偿、对账修复。
适合:
- 商品详情缓存。
- 用户昵称展示。
- 浏览量、点赞数。
- 搜索索引同步。
- 消息通知。
flowchart TD
A["发生网络分区"] --> B["先返回可用结果"]
B --> C["可能是旧数据"]
C --> D["网络恢复后同步修正"]AP 不是“数据错了也没关系”,而是“先保证服务可用,再通过版本、消息、补偿、对账把副本修正回来”。如果没有修复闭环,只是把错误藏起来,那不是 BASE,而是不可靠。
代价是:
- 用户可能短时间读到旧数据。
- 下游可能收到重复或乱序事件。
- 业务必须设计幂等、版本号、状态机、补偿任务。
- 监控必须能发现长期不一致,否则“最终一致”不会自动发生。
CP、AP 不是系统永久标签
很多系统不能简单贴一个 CP 或 AP 标签,因为不同操作可能选择不同策略。
| 系统或业务 | 常见选择 | 解释 |
|---|---|---|
| ZooKeeper 写入 | CP | 写入需要多数派确认,少数派不可写 |
| 服务注册信息读取 | AP 倾向 | 消费者通常缓存服务列表,短时间旧地址可接受 |
| MySQL 主库写订单 | CP 倾向 | 订单状态以主库事实源为准 |
| MySQL 到 ES 同步 | AP/最终一致 | ES 是搜索视图,可以延迟和重建 |
| Redis 缓存 | AP/最终一致 | 缓存可短暂旧,事实源在数据库 |
| 支付渠道回调 | 最终一致 + 对账 | 回调可能丢失,最终以渠道账单和本地流水对账 |
面试时不要只说“某某组件是 CP/AP”,更好的回答是:这个组件在什么操作上偏 CP,什么场景下为了可用接受最终一致。
商业场景例子
| 场景 | 更偏向 | 原因 |
|---|---|---|
| 支付扣款 | CP | 不能重复扣款或少扣款 |
| 订单状态最终展示 | CP + 最终一致 | 核心状态要可靠,展示可短暂延迟 |
| 商品搜索 | AP | ES 可以短暂落后 MySQL |
| 商品库存展示 | AP | 页面库存可短暂不准,下单时再强校验 |
| 秒杀库存扣减 | CP | 最终扣减不能超卖 |
| 用户头像 | AP | 短暂旧头像影响小 |
CAP 为什么影响分布式事务
分布式事务的本质是追求多个资源之间的一致性。越追求强一致,就越可能牺牲可用性和性能。
以 XA/2PC 为例:
flowchart TD
A["全局事务提交"] --> B["资源 A prepare"]
A --> C["资源 B prepare"]
B --> D{"所有资源 ready?"}
C --> D
D -- "是" --> E["统一 commit"]
D -- "否或超时" --> F["统一 rollback 或等待恢复"]当资源或协调者异常时,系统可能阻塞等待决议。这就是为了强一致付出的代价。
而可靠消息、本地消息表、Saga 更偏向 BASE 和最终一致:先让本地事务成功,再通过异步消息和补偿让其他服务最终达到一致。
BASE 是什么
BASE 是对强一致的一种工程化折中:
| 概念 | 含义 | 示例 |
|---|---|---|
| Basically Available 基本可用 | 系统出现故障时,保证核心能力可用 | 大促时关闭非核心推荐 |
| Soft State 软状态 | 系统允许中间状态存在 | 订单支付中、库存锁定中 |
| Eventually Consistent 最终一致 | 短时间不一致,最终通过同步修正 | MySQL 更新后 ES 稍后同步 |
BASE 不是“不保证一致性”,而是“不要求每一刻都强一致”。它要求系统最终能通过重试、补偿、对账恢复到正确状态。
BASE 三个词放到订单系统里理解
| BASE 概念 | 订单系统例子 | 如果没有这个设计会怎样 |
|---|---|---|
| 基本可用 | 大促时下单、支付保留,推荐、报表、非核心弹窗降级 | 非核心功能拖垮核心链路 |
| 软状态 | 订单存在 WAIT_PAY、PAYING、STOCK_LOCKED、SYNCING_ES | 只能成功/失败两态,无法表达中间过程,补偿困难 |
| 最终一致 | 支付成功后订单状态、积分、通知、ES 通过消息逐步完成 | 任一步失败都可能永久不一致 |
BASE 的重点是“允许中间状态”,所以商业项目必须把中间状态设计成可观察、可重试、可补偿,而不是只在内存里记一下。
最终一致不是放任不管
错误理解:
最终一致 = 反正最后差不多一致,失败也无所谓正确理解:
最终一致 = 允许短暂不一致,但必须有机制保证最终能修正最终一致需要:
- 消息可靠投递。
- 消费端幂等。
- 失败重试。
- 死信队列。
- 定时补偿。
- 对账任务。
- 告警和人工处理。
最终一致的完整闭环
flowchart TD
A["本地事务提交事实源"] --> B["记录事件或消息"]
B --> C["投递 MQ 或 CDC 捕获"]
C --> D["下游幂等消费"]
D --> E{"处理成功?"}
E -- "是" --> F["记录成功状态"]
E -- "否" --> G["重试"]
G --> H{"超过阈值?"}
H -- "否" --> D
H -- "是" --> I["死信或异常表"]
I --> J["补偿任务 / 对账 / 人工处理"]少了任何一环都会出问题:
| 缺少环节 | 典型后果 |
|---|---|
| 没有事件记录 | 本地事务成功但消息丢失,下游永远不知道 |
| 没有幂等 | 重试或重复消费导致重复加积分、重复扣库存 |
| 没有死信 | 问题消息无限重试,拖慢整个消费组 |
| 没有补偿 | 暂时失败变成永久不一致 |
| 没有对账 | 未知失败无法发现,只能等用户投诉 |
| 没有告警 | 异常积压很久没人处理 |
PACELC 补充理解
PACELC 是对 CAP 的补充:
如果发生分区 P,在可用性 A 和一致性 C 之间取舍;否则 E,在延迟 L 和一致性 C 之间取舍。
也就是说,即使没有网络分区,系统也经常要在低延迟和强一致之间权衡。
例子:
| 选择 | 结果 |
|---|---|
| 每次读都查主库 | 一致性更好,但延迟可能更高 |
| 读缓存或读从库 | 延迟更低,但可能读到旧数据 |
| 写入同步复制多个节点 | 数据更可靠,但写入更慢 |
| 写入一个节点后异步复制 | 写入快,但短暂不一致 |
一致性级别
| 一致性 | 说明 | 示例 |
|---|---|---|
| 强一致 | 写成功后任何读都能读到最新值 | 余额 |
| 顺序一致 | 所有节点看到的操作顺序一致 | 协调服务 |
| 单调读 | 一个用户读到新值后不会再读到旧值 | 用户订单状态 |
| 读己之写 | 自己写完后自己能立刻读到 | 修改昵称后自己看到新昵称 |
| 最终一致 | 经过一段时间后各节点一致 | 搜索索引、缓存 |
不是所有业务都需要强一致。小白最容易犯的错是把所有一致性问题都当成强一致处理,结果系统复杂、慢、难维护。
常见一致性模型怎么落地
| 一致性模型 | 商业落地做法 | 适合场景 |
|---|---|---|
| 强一致 | 只读写主库、单库事务、数据库唯一约束、条件更新 | 余额、支付流水、订单核心状态 |
| 读己之写 | 写入后本人短时间读主库,或者把新值写入会话缓存 | 用户资料、商家后台编辑商品 |
| 单调读 | 同一用户固定读同一副本,或读请求携带版本号 | 订单详情页刷新不能从已支付跳回待支付 |
| 因果一致 | 事件带上前置版本或业务状态,消费端按状态机处理 | 先创建订单,再同步订单明细,再同步 ES |
| 最终一致 | MQ、CDC、本地消息表、补偿、对账 | 搜索、积分、通知、报表、缓存 |
状态机为什么是一致性工具
状态机的作用是限制数据只能按合法路径变化。
flowchart TD
A["WAIT_PAY<br/>待支付"] --> B["PAID<br/>已支付"]
A --> C["CLOSED<br/>已关闭"]
B --> D["DELIVERING<br/>履约中"]
D --> E["FINISHED<br/>完成"]
B --> F["REFUNDING<br/>退款中"]
F --> G["REFUNDED<br/>已退款"]如果没有状态机,重复回调、乱序消息、补偿任务可能把订单从 PAID 改回 WAIT_PAY,或者把已经关闭的订单改成已支付。正确做法是每次更新都带当前状态条件:
update t_order
set status = 'PAID',
paid_at = now()
where order_no = ?
and status = 'WAIT_PAY';更新行数为 1 表示状态流转成功;更新行数为 0 表示订单已经被其他流程处理过,本次请求应该走幂等返回或查询确认。
代码 Demo:读己之写
用户修改资料后,自己应该立刻看到新资料,但其他用户稍后看到也可以接受。
一种做法是:修改后短时间内对本人读主库,其他场景读缓存。
public UserProfile getProfile(Long currentUserId, Long targetUserId) {
if (currentUserId.equals(targetUserId)) {
return userRepository.findById(targetUserId);
}
UserProfile cached = userProfileCache.get(targetUserId);
if (cached != null) {
return cached;
}
UserProfile profile = userRepository.findById(targetUserId);
userProfileCache.put(targetUserId, profile);
return profile;
}这个例子说明:一致性不一定只有“全局强一致”一种做法,可以按用户体验和业务风险设计局部强一致。
CAP 与技术选型
| 技术 | 更偏向 | 说明 |
|---|---|---|
| XA/2PC | CP | 强一致,可能阻塞 |
| TCC | CP 倾向 | 业务层资源预留,强控制 |
| Saga | BASE | 本地事务 + 补偿 |
| 本地消息表 | BASE | 异步消息最终一致 |
| MQ 事务消息 | BASE | 保证业务提交和消息发送一致 |
| Redis 缓存 | AP 倾向 | 允许短暂旧值 |
| ES 搜索索引 | AP 倾向 | MySQL 更新后异步同步 |
本章小结
CAP 告诉你:分布式系统遇到网络分区时,不可能同时完美保证一致性和可用性。BASE 告诉你:很多商业系统可以接受短暂不一致,但必须通过重试、补偿、对账保证最终修正。
理解 CAP/BASE 之后,再看分布式事务就会清楚:XA/TCC 是为了更强一致付出更高成本;本地消息表、事务消息、Saga 是为了更高可用和吞吐接受最终一致。
