已完成CAP、BASE 与一致性模型

约 30 分钟 · 阶段:分布式基石 · 能力:按接口说明 CAP 取舍与一致级别

为什么要学本章

分布式系统本质与挑战 讲了「部分失败」;本章回答:分区发生时在 C 与 A 之间选什么、为何常见 AP + 最终一致,以及「读己之写」从哪来。

学完你能
  • 为 ShopFlow 的 POST /reserve、商品详情、支付回调各说一句分区时的 CP/AP 取向
  • 区分强一致、最终一致、因果一致,并解释「最终一致 ≠ 随便脏读」
  • 说明 fencing token 防「旧主覆写」
本章先记住 3 件事
  • CAP:发生网络分区时,只能在一致性 C 与可用性 A 之间取舍;分区容忍 P 在分布式里通常不能放弃。
  • 工程现实:多数互联网业务选 AP + 最终一致(BASE),用对账、幂等与 Outbox 兜底。
  • 客户端语义:除存储层一致级别外,还要定义「用户读到自己刚写的数据」等会话级保证。

CAP 定理与工程中的真实取舍

CAP 定理(Brewer)指出:在存在网络分区时,分布式系统无法同时满足:

  • C(Consistency) — 每次读都能读到最新写入,或返回错误(线性一致视角的简化理解)。
  • A(Availability) — 每个请求都能得到非错误响应(不要求读到最新)。
  • P(Partition tolerance) — 分区发生后系统仍能继续工作(节点间消息可能丢失/延迟)。

真实部署几乎必须接受 P,于是分区发生时要在 C 与 A 间做选择——不是「三选二」的日常开关,而是按业务路径分别设计。

分区时的二选一(概念图)

发生分区 · CP 与 AP 路径
flowchart TB
  subgraph normal [无分区]
    W[写入成功] --> R[读到最新]
  end
  subgraph partition [发生分区 P]
    CP[选 CP] -->|拒绝部分读写| ERR[返回错误 / 降级]
    AP[选 AP] -->|继续服务| STALE[可能读到旧副本]
  end
  normal --> partition

CP:宁可失败也不返回不确定的旧数据;AP:宁可旧数据也要保持入口可用。

对照:CP 与 AP 在 ShopFlow 中的例子

偏 CP 的路径
  • 支付扣款:余额不足必须立即拒绝,不能「先成功后对账」
  • 库存强一致扣减(少数场景):超卖代价极高时用共识/锁
偏 AP 的路径
  • 商品详情页:可短暂展示旧库存,后台异步校准
  • 订单列表:最终一致 即可,配合状态机与对账

PACELC 延伸(了解即可)

无分区时仍要在延迟(L)与一致(C)间权衡:例如跨地域复制常降低一致级别以换更低延迟。设计评审时可问:「正常时要多一致?分区时要多可用?」

常见误区
  • 给整个系统贴一个 CP/AP 标签 — 应按接口/数据分别决策。
  • 认为选 AP 就不要一致性 — 最终一致 + 对账同样是一致性策略。
  • 忽略 P — 局域网也会分区,云多 AZ 更常见。
先想 10 秒:大促分区时,商品详情页更应该保 C 还是 A?

多数选 A(短暂旧库存 + 异步校准);支付/防超卖路径才靠近 C。勿对全站贴单一 CAP 标签。

强一致、最终一致与因果一致

「一致性」在不同语境含义不同:数据库事务、副本同步、客户端会话。下表是架构讨论中最常用的划分。

模型含义(简化)典型实现/场景
强一致 / 线性一致 所有节点如同一份拷贝,读写全局有序 etcd/ZooKeeper、部分分布式锁;代价是延迟与可用性
最终一致 停止写入后,副本会在有限时间内收敛 Cassandra、CouchDB、Kafka 消费滞后;须幂等与对账
因果一致 有因果关系的操作被所有节点按因果序看到 会话粘滞 + 版本向量;评论回复链

序列图:最终一致下的读延迟

写主库 · 读从库可能短暂旧值
sequenceDiagram
  participant App as order-service
  participant M as DB 主
  participant R as DB 从
  App->>M: UPDATE stock=9
  M-->>App: OK
  App->>R: SELECT stock
  R-->>App: stock=10 (复制滞后)
  Note over M,R: 异步复制追上后读到 9

practice 阶段读写分离时会再遇此问题;缓解:读主、版本号、或接受短暂旧读 + 业务校验。

场景决策:接口选哪种一致级别

ShopFlow 接口建议理由
POST /reserve 强一致或串行化单点 防超卖;可接受短暂不可用
GET /products/{id} 最终一致 + 缓存 读多写少,短暂旧库存可容忍
支付回调更新订单 幂等 + 至少一次投递 跨服务;靠业务键去重(Saga 章节展开)

互动:副本读一致性级别(示意)

QUORUM(2/3)

BASE 与互联网业务常见策略

BASE 是对 AP 路径的实操总结(与 ACID 相对):

  • Basically Available — 核心路径尽量有响应,非核心可降级。
  • Soft state — 中间状态允许存在(处理中、待对账)。
  • Eventually consistent — 通过复制、消息、定时任务达到一致。

ACID vs BASE(单服务 vs 跨服务)

维度ACID(单库事务)BASE(跨服务协作)
边界 一个数据库连接内 多服务、多库、消息
失败 回滚 补偿、重试、对账
ShopFlow 单服务内订单行 + 明细 下单 → 库存 → 支付链(详见 分布式数据:事务、幂等与 Saga)

代码:Transactional Outbox 预览

「写订单表 + 发 Kafka」无法靠本地事务一起提交。常见做法:Outbox 与业务写入同一事务,再由投递器异步发消息。

java
@Transactional
public void createOrder(CreateOrder cmd) {
  orderRepository.save(cmd.toOrder());
  outboxRepository.save(OutboxEvent.orderCreated(cmd.orderId()));
  // 同一 DB 事务;消息由 poller 异步投递,消费者须幂等
}

互联网常见组合拳

  • 幂等键 — 重复请求返回同一业务结果。
  • 对账任务 — 日终比对订单、支付、库存流水。
  • 状态机 — 订单 CREATED → PAID,非法跳转拒绝。
  • 降级开关 — 分区时关闭非核心功能,保护核心下单路径。

分布式锁、租约与 fencing token 概念

跨节点互斥(定时任务抢主、库存扣减串行化)常用分布式锁。实现须考虑:锁持有者崩溃、时钟漂移、旧锁未释放就获得新锁。

  • 租约(Lease) — 锁带 TTL;持有者须周期性续租,否则自动释放。
  • Fencing token — 单调递增令牌;下游存储拒绝比当前令牌更旧的写,防止「僵尸持有者」覆写新主。

流程:为何需要 fencing

旧主恢复后若无 fencing 可能覆写新主
sequenceDiagram
  participant N1 as 节点1(旧主)
  participant L as 锁服务
  participant DB as 存储
  N1->>L: 曾持有 lock#5
  Note over N1: GC 停顿后「以为」仍持有锁
  participant N2 as 节点2(新主)
  N2->>L: 获得 lock#6
  N2->>DB: write(token=6)
  N1->>DB: write(token=5) 
  Note over DB: 无 fencing 时旧写可能破坏新写

存储侧记录「已见过的最大 token」,拒绝更小 token 的写。

代码对比:锁值是否单调

不推荐 · 仅 UUID 锁值
java
// 下游无法区分「旧主迟到的写」
String lockVal = UUID.randomUUID().toString();
redis.set("lock:job", lockVal, NX, PX, 30000);
推荐 · 租约 + fencing token
java
long token = lockService.acquire("inventory-shard-1", Duration.ofSeconds(30));
inventoryRepository.decrement(skuId, qty, token);
// 仓储层: if (token < lastAppliedToken) reject;

Java 生态常见组件:Redis Redisson、ZooKeeper Curator、数据库 SELECT … FOR UPDATE(有单点边界)。能不用锁就不用:优先幂等、分区键串行、CRDT 等业务手段。

读己之写、单调读等客户端语义

即使存储标记为「最终一致」,产品仍常要求会话级体验保证(由网关粘滞、读写路由或版本号实现):

语义用户可感知的效果ShopFlow 例子
读己之写(Read-your-writes) 刚下单后刷新列表能看到自己的订单 写主库后读主或带 userId 路由到含最新会话的副本
单调读(Monotonic reads) 不会先看到新评论再看到旧评论 订单时间线只前进;用递增 version 过滤
单调写(Monotonic writes) 同一用户的写按顺序可见 同一 orderId 状态变更串行处理
写跟随(Writes-follow-reads) 改头像后评论里头像一起更新 用户资料变更广播或短期读主

配置预览:读写分离时的「读主」开关

yaml
shopflow:
  datasource:
    read-your-writes: true
    # 创建订单后的 5s 内,该 userId 查询走主库

FAQ

最终一致 = 可以随便脏读?
否。须定义收敛时间、业务补偿与客户端语义;金融类路径仍可能要求强一致。
Redis 分布式锁够吗?
简单场景可用;关键写路径建议租约 + fencing 或改用共识组件;注意主从切换时的锁安全性(Redlock 争议见延伸阅读)。

下一章讨论 架构风格:单体、SOA 与微服务,把本章取舍落到架构形态选择。

本章结论

本章回答「分区与复制时数据有多新」。下面几条是验收口径,可对照各节与 ShopFlow 接口回忆。

  • CAP 的工程问法:真实部署须接受分区容忍 P;分区发生时只能在一致性 C 与可用性 A 间取舍,且应按接口/数据集分别决策,勿给全系统贴单一 CP/AP 标签。
  • 一致模型落到接口:强一致、最终一致、因果一致含义不同;POST /reserve 防超卖宜强一致或串行化,商品详情等读多场景可接受短暂旧读。
  • BASE 是 AP 路径的实操:基本可用 + 软状态 + 最终一致;跨服务靠幂等、对账、状态机与 Outbox 等组合拳收敛。
  • 锁是最后手段:优先幂等与业务串行;必须用分布式锁时要租约续期与 fencing token,防止旧主迟到的写破坏新主。
  • 客户端语义不可省:除存储层一致级别外,还须定义读己之写等会话体验(粘滞、短期读主、版本号)。

复习与自检

过关清单(勾选会保存在本浏览器)

0 / 5 已勾选

本章速查卡
概念一句
CAP有 P 时分区要在 C/A 间选
最终一致停止写后会收敛,运行中可能旧读
BASE可用 + 软状态 + 最终一致 + 补偿
Fencing单调 token,存储拒绝旧写
读己之写用户看到自己刚提交的数据
答辩 / 面试口述题
  1. 为什么说「整个公司是 CP 系统」这句话不严谨?
  2. 下单后立刻查订单列表为空,可能是哪些一致性问题?如何修?

下一步:勾选过关清单、完成测验与 Demo;下一章 架构风格:单体、SOA 与微服务。

动手练习

为 ShopFlow 三个接口标注 CAP 取向,并完成fencing 时序与读己之写两处纸面标注(约 25 分钟)。

操作步骤

  1. 为 POST /reserve、GET /products/{id}、支付回调各写一句:分区时偏 CP 还是 AP,并引用正文 CAP 流程图一句理由。
  2. 对照 Mermaid「旧主恢复后若无 fencing 可能覆写新主」,补写:有 fencing 时,旧 token 的写请求会被存储层拒绝。
  3. 填写「读己之写 / 单调读」表:用户下单后刷新列表为空,属于哪类语义失效?写出 1 条缓解手段(会话粘滞 / 短期读主 / 版本号)。

判断练习

  1. 大促商品详情可略旧库存,但 POST /reserve 须防超卖。为何前者可偏 AP、后者偏 CP?各写一句。

    参考答案

    商品详情读多写少、短暂旧值可接受,可用 AP + 最终一致。预留库存须当场防超卖,分区时更倾向 CP/串行化或强一致路径,否则会出现双卖。

  2. 库存扣减用 Redis 锁,锁值为固定 UUID、无 fencing token。主要违反哪一节?说明「僵尸节点」风险。

    参考答案

    违反「分布式锁、租约与 fencing token」一节。租约过期后旧主仍可能持锁并写库;无递增 fencing token 时,存储无法拒绝更旧的写,新主数据可能被覆盖。

Demo:CAP 取舍与一致级别实验室

目录:demos/basics-02-consistency-cap-lab/。验收:接口卡片 3 张均已标注 CP/AP + 一句理由。

延伸学习