分布式理论02:CAP / BASE
从订单、库存、缓存和读写延迟这些项目场景出发,理解 CAP / BASE 真正在提醒我们做什么取舍,以及最终一致性为什么必须有收敛机制。
CAP / BASE 解决什么问题
CAP 最容易被背成一句话:一致性、可用性、分区容错性三者不可兼得。问题是,很多人背完以后还是不知道项目里该怎么选。
更接近工程现场的说法是:当服务之间的网络不可靠、节点可能暂时无法通信时,系统要优先保证”所有人看到同一个结果”,还是优先保证”请求尽量还能被处理”。
这里的 P 不是一个可以随便放弃的选项。只要系统跨进程、跨机器、跨机房,就必须承认网络分区和通信异常会发生。真正需要取舍的是 C 和 A。
比如订单服务写入订单后,要通知库存、积分、搜索索引和消息中心。某一刻消息服务不可用,订单服务是直接失败,还是先让订单成功、后续补发消息?这不是定理题,而是业务后果题。
C 和 A 不是抽象名词
一致性不是”数据库一定立刻一样”这么窄。它要回答的是:一次操作完成后,相关读写方能不能看到符合业务承诺的结果。
可用性也不是”永远成功”。它更像是:系统在局部异常时,能不能给调用方一个可接受的响应,而不是一直卡住、超时或整体不可用。
在 Java 后端项目里,常见取舍大概是这样:
| 数据/链路 | 倾向 | 原因 |
|---|---|---|
| 支付成功状态 | 更偏一致 | 不能随便显示未支付 |
| 库存扣减 | 更偏一致 | 宁可失败也不要超卖 |
| 订单列表 | 至少读己之写 | 用户提交后要能看到 |
| 积分到账 | 最终一致 | 可以延迟,但要能补偿 |
| 搜索索引 | 最终一致 | 重点是最终能追上主数据 |
| 浏览计数 | 允许短暂不准 | 甚至允许少量丢失 |
所以不要问”这个系统选 CP 还是 AP”。一个系统里不同链路、不同数据,可能有不同取舍。
BASE:怎么降级承诺
BASE 经常被理解成”反正最终一致就行”。这很危险。
BASE 的核心不是放弃正确性,而是把强一致的即时承诺降级成可恢复、可收敛的承诺:
| 特性 | 含义 |
|---|---|
| 基本可用(Basically Available) | 核心流程还能走,非核心能力可以降级 |
| 软状态(Soft State) | 中间状态允许短暂不一致 |
| 最终一致(Eventually Consistent) | 系统要通过重试、补偿、对账等机制回到正确结果 |
比如下单成功后积分没有立即到账,可以接受。但必须有消息重试、失败表、补偿任务和告警。用户第二天还没拿到积分,就不是”最终一致”,而是丢数据。
最终一致不是”不用管”,而是”当下不强行同步,但事后必须收敛”。
一个项目里的判断方式
假设现在要设计”订单支付成功后更新会员等级”。支付是核心链路,会员等级是权益链路。
一种强同步做法是:支付回调里直接调用会员服务,会员更新失败就让支付回调失败。这会让会员服务的不稳定影响支付链路,风险很大。
更稳的做法通常是:
- 支付服务先确认支付状态
- 写入本地事件或发送可靠消息
- 会员服务消费事件更新等级
- 消费失败进入重试或补偿表
- 对账任务定期扫描支付成功但权益未更新的数据
这就是一个偏 BASE 的设计。它没有否认一致性,只是把”支付和会员等级同时完成”的承诺,改成了”支付先正确完成,会员等级在可观测、可补偿的机制里追上”。
读路径的一致性也要设计
很多团队讨论 CAP 时只盯写入,其实线上更常见的问题是读路径不一致。
比如用户提交订单后马上进入订单列表页。如果订单写在主库,列表查的是从库,主从复制延迟 500ms,用户就可能看到”订单不存在”。从数据库角度看,这是正常复制延迟;从用户体验看,这是刚提交就丢了。
这里有几种处理方式:
| 方案 | 做法 | 适用场景 |
|---|---|---|
| 强制读主 | 提交后的短窗口内,订单列表读主库 | 核心数据、量不大时 |
| 读己之写 | 给用户会话打标,自己的新数据优先读主 | 用户体验敏感场景 |
| 结果页兜底 | 提交成功页直接展示本次订单快照 | 提交后马上展示结果 |
| 异步提示 | 列表延迟可接受时,明确展示”处理中” | 后台任务、非实时数据 |
这些方案没有哪个永远正确。读主会增加主库压力;读己之写要维护路由标记;展示快照要注意后续状态变化;异步提示要业务能接受。CAP / BASE 真正落地时,就是在这些具体读写路径上做取舍。
缓存也是同一个问题。数据库更新成功后,缓存删除失败怎么办?先删缓存还是先写数据库?延迟双删有没有必要?这些都不是纯粹的缓存技巧,而是在决定”短时间读到旧值的后果能不能接受”。
如果是商品详情页的浏览量,旧一点没关系;如果是用户余额,旧值就很危险。所以一致性不要按组件分,而要按数据后果分。
一致性承诺分级
项目里可以把每个数据项写进一张简单表里:
| 数据项 | 写入方 | 读取方 | 可接受延迟 | 兜底方式 |
|---|---|---|---|---|
| 支付状态 | 支付服务 | 订单/用户 | 不接受 | 对账 + 状态机 |
| 订单列表 | 订单服务 | 当前用户 | 秒级内读己之写 | 短期读主 |
| 积分 | 会员服务 | 用户中心 | 分钟级 | 重试 + 补偿 |
| 搜索索引 | 搜索服务 | 搜索页 | 分钟级 | 重建索引 |
| 统计报表 | 数仓任务 | 后台运营 | 小时级 | 批处理校验 |
这张表比”我们系统追求最终一致”更有用。它能让开发、测试、产品、运维对同一件事达成共识:哪里不能错,哪里可以慢,哪里必须能补。
如果没有这个分级,最终一致很容易变成事故后的解释话术。
常见误区
误区一:把 CAP 当成选型标签
比如说 Redis 是 AP、ZooKeeper 是 CP,然后就结束了。组件倾向只能提供背景,业务链路怎么承诺一致性,仍然要自己设计。
误区二:只看写,不看读
很多不一致问题出现在读路径:主库写完后读从库、数据库更新后读缓存、订单写完后搜索页查不到。读路径的承诺要单独设计。
误区三:把最终一致交给 MQ 自动解决
MQ 只负责传递消息,不能自动保证业务幂等、状态流转正确、补偿任务有效。