分布式系统本质与挑战 讲了「部分失败」;本章回答:分区发生时在 C 与 A 之间选什么、为何常见 AP + 最终一致,以及「读己之写」从哪来。
POST /reserve、商品详情、支付回调各说一句分区时的 CP/AP 取向CAP 定理(Brewer)指出:在存在网络分区时,分布式系统无法同时满足:
真实部署几乎必须接受 P,于是分区发生时要在 C 与 A 间做选择——不是「三选二」的日常开关,而是按业务路径分别设计。
flowchart TB
subgraph normal [无分区]
W[写入成功] --> R[读到最新]
end
subgraph partition [发生分区 P]
CP[选 CP] -->|拒绝部分读写| ERR[返回错误 / 降级]
AP[选 AP] -->|继续服务| STALE[可能读到旧副本]
end
normal --> partition
CP:宁可失败也不返回不确定的旧数据;AP:宁可旧数据也要保持入口可用。
无分区时仍要在延迟(L)与一致(C)间权衡:例如跨地域复制常降低一致级别以换更低延迟。设计评审时可问:「正常时要多一致?分区时要多可用?」
多数选 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 章节展开) |
BASE 是对 AP 路径的实操总结(与 ACID 相对):
| 维度 | ACID(单库事务) | BASE(跨服务协作) |
|---|---|---|
| 边界 | 一个数据库连接内 | 多服务、多库、消息 |
| 失败 | 回滚 | 补偿、重试、对账 |
| ShopFlow | 单服务内订单行 + 明细 | 下单 → 库存 → 支付链(详见 分布式数据:事务、幂等与 Saga) |
「写订单表 + 发 Kafka」无法靠本地事务一起提交。常见做法:Outbox 与业务写入同一事务,再由投递器异步发消息。
@Transactional
public void createOrder(CreateOrder cmd) {
orderRepository.save(cmd.toOrder());
outboxRepository.save(OutboxEvent.orderCreated(cmd.orderId()));
// 同一 DB 事务;消息由 poller 异步投递,消费者须幂等
}
跨节点互斥(定时任务抢主、库存扣减串行化)常用分布式锁。实现须考虑:锁持有者崩溃、时钟漂移、旧锁未释放就获得新锁。
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 的写。
// 下游无法区分「旧主迟到的写」
String lockVal = UUID.randomUUID().toString();
redis.set("lock:job", lockVal, NX, PX, 30000);
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) | 改头像后评论里头像一起更新 | 用户资料变更广播或短期读主 |
shopflow:
datasource:
read-your-writes: true
# 创建订单后的 5s 内,该 userId 查询走主库
下一章讨论 架构风格:单体、SOA 与微服务,把本章取舍落到架构形态选择。
本章回答「分区与复制时数据有多新」。下面几条是验收口径,可对照各节与 ShopFlow 接口回忆。
POST /reserve 防超卖宜强一致或串行化,商品详情等读多场景可接受短暂旧读。过关清单(勾选会保存在本浏览器)
0 / 5 已勾选
| 概念 | 一句 |
|---|---|
| CAP | 有 P 时分区要在 C/A 间选 |
| 最终一致 | 停止写后会收敛,运行中可能旧读 |
| BASE | 可用 + 软状态 + 最终一致 + 补偿 |
| Fencing | 单调 token,存储拒绝旧写 |
| 读己之写 | 用户看到自己刚提交的数据 |
下一步:勾选过关清单、完成测验与 Demo;下一章 架构风格:单体、SOA 与微服务。
为 ShopFlow 三个接口标注 CAP 取向,并完成fencing 时序与读己之写两处纸面标注(约 25 分钟)。
POST /reserve、GET /products/{id}、支付回调各写一句:分区时偏 CP 还是 AP,并引用正文 CAP 流程图一句理由。大促商品详情可略旧库存,但 POST /reserve 须防超卖。为何前者可偏 AP、后者偏 CP?各写一句。
商品详情读多写少、短暂旧值可接受,可用 AP + 最终一致。预留库存须当场防超卖,分区时更倾向 CP/串行化或强一致路径,否则会出现双卖。
库存扣减用 Redis 锁,锁值为固定 UUID、无 fencing token。主要违反哪一节?说明「僵尸节点」风险。
违反「分布式锁、租约与 fencing token」一节。租约过期后旧主仍可能持锁并写库;无递增 fencing token 时,存储无法拒绝更旧的写,新主数据可能被覆盖。
目录:demos/basics-02-consistency-cap-lab/。验收:接口卡片 3 张均已标注 CP/AP + 一句理由。