本文系统梳理分布式系统的核心设计模式:从分布式的本质挑战(网络分区/部分失败/时钟偏斜),到隔离、重试、熔断等弹性模式,再到 Event Sourcing、CQRS、Outbox 等数据一致性模式。读完能在工程中识别问题并套用合适的模式,而不是每次重新发明轮子。
相关文章:分布式一致性 · 分布式事务 · 分布式协调与选举 · 金融级分布式系统实践
目录
| 章节 | 说明 |
|---|---|
| 分布式系统的本质挑战 | 网络分区/部分失败/时钟偏斜的根本原因 |
| 弹性模式:隔离与限流 | Bulkhead 舱壁、令牌桶/漏桶 |
| 弹性模式:重试与熔断 | Retry + Backoff + Jitter、Circuit Breaker 三状态机 |
| 数据一致性模式 | Outbox、Event Sourcing、CQRS |
| Saga 补偿模式 | 编排式 vs 协调式,与 TCC 对比 |
| Sidecar 与 Leader Election | 基础设施解耦模式 |
| 模式选型速查 | 场景 → 推荐模式 |
分布式系统的本质挑战
分布式系统的根本难点不是性能,而是部分失败(Partial Failure)——单机要么全挂要么全好,分布式系统可以只有一部分坏掉,而你不知道哪部分坏了。
三大核心挑战
| 挑战 | 含义 | 为什么难处理 |
|---|---|---|
| 网络分区(Network Partition) | 节点间消息丢失或高延迟,节点各自认为对方挂了 | 无法区分"对方挂了"和"网络断了" |
| 部分失败(Partial Failure) | 任意节点在任意时刻可能崩溃,且崩溃前可能已执行了部分操作 | 操作可能执行了但响应未返回(幂等性问题的根源) |
| 时钟偏斜(Clock Skew) | 不同机器的物理时钟不同步,无法用时间戳判断事件先后 | 导致"谁先写"问题,是并发冲突的根源 |
为什么单机方案在分布式下失效
单机:事务要么全成功要么全失败(ACID 保证)
分布式:
- 请求发出后网络断了,不知道对方有没有收到
- 对方收到了,执行到一半机器宕机
- 对方执行完了,但响应在回来路上丢了
→ 结果:操作可能执行了 0 次、1 次或多次
核心结论:分布式系统的设计模式,本质上都是在应对"不确定性"——通过幂等性(多次执行等价于一次)、补偿(撤销已执行的操作)和最终一致性(允许短暂不一致但最终收敛)来处理这种不确定性。
弹性模式:隔离与限流
Bulkhead 舱壁隔离
背景:船舱设计了多个隔水舱,某个舱进水不会导致整艘船沉没。分布式系统中,一个下游服务变慢会导致调用它的线程池耗尽,进而拖垮整个服务(级联故障)。
两种实现方式:
| 方式 | 原理 | 适用场景 |
|---|---|---|
| 线程池隔离 | 每个下游服务使用独立线程池,池满时直接拒绝而不影响其他服务 | 需要严格隔离,调用耗时较长 |
| 信号量隔离 | 用计数器限制并发数,超限直接拒绝,不新建线程 | 调用耗时短,不希望线程切换开销 |
线程池隔离示意:
┌─────────────────────────────────────┐
│ 应用服务 │
│ ┌──────────┐ ┌──────────┐ │
│ │ 服务A池 │ │ 服务B池 │ ... │
│ │ 10线程 │ │ 10线程 │ │
│ └────┬─────┘ └────┬─────┘ │
└───────┼─────────────┼───────────────┘
↓ ↓
服务A 服务B(变慢)
关键点:服务 B 变慢导致其线程池耗尽,但服务 A 的线程池完全不受影响。没有隔离时,服务 B 变慢会逐渐占满所有线程,最终整个应用无响应。
限流:令牌桶 vs 漏桶
| 算法 | 原理 | 特点 |
|---|---|---|
| 漏桶(Leaky Bucket) | 请求放入固定容量的桶,以固定速率流出;桶满则丢弃 | 严格匀速输出,平滑流量;无法应对突发 |
| 令牌桶(Token Bucket) | 以固定速率向桶中放令牌,每个请求消耗一个令牌;桶空则拒绝 | 允许一定程度的突发(桶中积累的令牌) |
选型原则:
- 对发送方限速(防止自己发太快)→ 漏桶
- 对接收方保护(允许突发但有上限)→ 令牌桶
- 需要动态调整处理速率 → 令牌桶(调整放令牌速率即可)
弹性模式:重试与熔断
Retry + Backoff + Jitter
为什么需要重试:网络抖动、服务短暂不可用是常态,大多数失败是暂时性的。
为什么需要 Backoff(退避):立即重试只会加重已经过载的服务。
为什么需要 Jitter(抖动):所有客户端同时退避相同时间后同时重试,会产生"惊群效应"(Thundering Herd)。
朴素重试(错误):
t=0 失败 → t=0.1s 重试 → t=0.1s 重试 → ...(持续冲击)
指数退避(改进):
t=0 失败 → t=1s 重试 → t=2s 重试 → t=4s 重试 ...
指数退避 + Jitter(正确):
t=0 失败 → t=1±0.5s 重试 → t=2±1s 重试 → t=4±2s 重试
(各客户端的重试时间随机散开,避免惊群)
实现要点:
- 只对幂等操作重试(GET、PUT),非幂等操作(POST)需要先确保幂等性
- 设置最大重试次数,避免无限重试
- 区分可重试错误(超时、5xx)和不可重试错误(4xx 客户端错误)
Circuit Breaker 熔断器
背景:重试会让已经过载的服务雪上加霜。当下游持续失败时,应该"断开电路",快速失败而不是继续等待。
三状态机:
flowchart LR
CLOSED["关闭(正常)<br/>请求正常通过<br/>统计失败率"] -->|失败率超阈值| OPEN["打开(熔断)<br/>直接拒绝所有请求<br/>快速失败"]
OPEN -->|等待冷却时间| HALF["半开(探测)<br/>放行少量请求<br/>探测服务是否恢复"]
HALF -->|探测请求成功| CLOSED
HALF -->|探测请求失败| OPEN
style CLOSED fill:#cfc,stroke:#060
style OPEN fill:#fcc,stroke:#c00
style HALF fill:#ffc,stroke:#660
| 状态 | 行为 | 转换条件 |
|---|---|---|
| Closed(关闭) | 正常通过,统计失败率 | 失败率超过阈值 → Open |
| Open(打开) | 直接拒绝,快速失败 | 冷却时间到 → Half-Open |
| Half-Open(半开) | 放行少量探测请求 | 成功 → Closed;失败 → Open |
关键参数:
- 失败率阈值(如 50%)
- 统计时间窗口(如 60s 内的请求)
- 冷却时间(Open 状态持续多久)
- 半开时的探测请求数
Circuit Breaker vs Retry 的关系:两者配合使用。Retry 应对偶发性失败;Circuit Breaker 应对持续性失败,防止 Retry 放大问题。熔断器打开时,Retry 应该直接停止。
数据一致性模式
Outbox Pattern(本地消息表)
问题:服务需要同时完成数据库写入和消息发布,这是两个不同的系统,无法用单一事务保证原子性。
错误做法:
1. 写数据库 ✓
2. 发消息 ✗(网络故障)
→ 数据库有数据,但下游没收到消息
Outbox 解法:将消息写入同一个数据库的 outbox 表,与业务操作在同一事务中完成。再由独立的投递器异步发送。
sequenceDiagram
participant S as 业务服务
participant DB as 数据库
participant P as 消息投递器
participant MQ as 消息队列
S->>DB: BEGIN TRANSACTION
S->>DB: 写业务数据
S->>DB: 写 outbox 表(status=PENDING)
S->>DB: COMMIT(原子保证)
loop 轮询 outbox
P->>DB: 查询 PENDING 消息
P->>MQ: 发送消息
P->>DB: 更新 status=SENT
end
关键点:
- 业务数据和消息记录在同一事务中,天然原子
- 消息投递失败可重试(消息仍在 outbox 表中)
- 消费方必须做幂等处理(消息可能重复投递)
- 适合已有关系型数据库的场景,不引入新的基础设施
Event Sourcing 事件溯源
核心思想:不存储"当前状态",而是存储导致状态变化的事件序列。当前状态通过重放事件序列计算得出。
三个核心概念:
| 概念 | 含义 | 类比 |
|---|---|---|
| Command(命令) | 外部发出的指令,可能被拒绝 | "我想转账 100 元" |
| Event(事件) | 命令被验证通过后产生的事实,一定会执行 | "已转账 100 元(transferred)" |
| State(状态) | 所有历史事件累积的结果 | 账户余额 = 所有存取款事件之和 |
数学本质:
Sn = f(Sn-1, en) → 当前状态 = 函数(前一状态, 当前事件)
Sn = S0 + e1 + e2 + ... + en → 状态 = 初始状态 + 所有事件之和
事件溯源的核心约束(不可违反):
- 自动机执行不能有随机性(保证可重现)
- 事件必须有全序(顺序不同结果不同)
- 事件只追加不修改(immutable)
快照(Snapshot)优化:
无快照:恢复状态需重放所有历史事件(可能有百万条)
有快照:从最近快照 + 快照后的增量事件恢复(只需重放少量事件)
快照频率 → 决定恢复时间(不是事件总数)
金融系统:日切(每日 0 点)天然是打快照的时机
适用场景:
- 需要完整审计日志(金融、医疗、合规)
- 需要时光机(回溯历史任意时刻的状态)
- 业务规则复杂,命令→事件的转换有意义
不适用场景:
- 简单 CRUD,没有复杂业务规则
- 对查询延迟极度敏感(需要额外的读模型)
CQRS(读写分离)
背景:Event Sourcing 的写模型(事件队列 + 状态机)不适合复杂查询。CQRS 将写模型和读模型彻底分离,各自优化。
flowchart LR
C[Client] -->|Command| W[写模型<br/>事件队列 + 状态机]
C -->|Query| R[读模型<br/>投影视图]
W -->|复制事件| R
style W fill:#ffc,stroke:#660
style R fill:#cfc,stroke:#060
读模型(投影):
- 消费写模型的事件,维护专门优化查询的数据结构
- 可以有多个不同的读模型(如:一个用于列表查询,一个用于全文检索)
- 读模型是只读的,数据来自事件流
一致性读:普通读模型可能有延迟,需要强一致时,将查询作为一个空命令走写模型的共识路径,利用线性一致性保证读到最新状态。
Saga 补偿模式
Saga 将一个跨服务的长事务拆分为一系列本地事务,每个本地事务有对应的补偿事务(逆操作)。
两种协调模式
| 模式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 编排式(Choreography) | 每个服务完成后发布事件,下一个服务监听并响应 | 无中心协调者,松耦合 | 流程分散,难以追踪整体状态 |
| 指挥式(Orchestration) | 中央 Saga Orchestrator 按顺序调用各服务 | 流程集中,易于监控和调试 | 引入中心化协调者 |
sequenceDiagram
participant O as Saga Orchestrator
participant S1 as 订单服务
participant S2 as 库存服务
participant S3 as 支付服务
O->>S1: 创建订单
S1-->>O: 成功
O->>S2: 扣减库存
S2-->>O: 成功
O->>S3: 扣款
S3-->>O: 失败(余额不足)
Note over O: 触发补偿
O->>S2: 恢复库存(补偿)
O->>S1: 取消订单(补偿)
Saga vs TCC 对比
| 维度 | TCC | Saga |
|---|---|---|
| 隔离性 | 较好(Try 阶段预留资源) | 较差(中间状态对外可见) |
| 业务侵入 | 高(3 个接口) | 中(补偿接口) |
| 适用时长 | 短事务(秒级) | 长事务(分钟/小时级) |
| 典型场景 | 支付扣款 | 旅行预订(机票+酒店+租车) |
Saga 的"脏读"问题:Saga 执行过程中,其他事务可能读到中间状态(如库存已扣但支付未完成)。解决方案:
- 业务层面标记"预留中"状态
- 读操作过滤掉"进行中"的记录
- 接受最终一致性,不追求强隔离
Sidecar 与 Leader Election
Sidecar 模式
思想:将服务的基础设施关注点(日志收集、服务发现、流量控制、mTLS)从业务代码中剥离,部署为同 Pod 的辅助容器(Sidecar)。
┌─────────────────────────────┐
│ Pod │
│ ┌──────────┐ ┌──────────┐ │
│ │ 业务 │ │ Sidecar │ │
│ │ 容器 │◄─►│ (Envoy) │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────┘
好处:
- 业务代码无需关心服务网格、追踪、限流等基础设施
- 基础设施升级不需要重新发布业务服务
- 多语言服务共享同一套基础设施能力
Leader Election 模式
场景:集群中需要一个主节点来协调工作(定时任务只执行一次、写操作只在主节点执行)。
实现方式:
| 方式 | 原理 | 特点 |
|---|---|---|
| 基于 ZooKeeper | 创建临时节点,成功者为 Leader | 可靠,节点宕机自动释放锁 |
| 基于 etcd/Raft | 利用共识算法天然选主 | 强一致,生产推荐 |
| 基于 Redis | SET NX + 过期时间 | 简单,但有极小概率的锁丢失风险 |
注意事项:
- Leader 宕机后,新 Leader 选出前系统暂不可用(这是 CP 系统的代价)
- 防止脑裂(Split Brain):新 Leader 必须等旧 Leader 的租约到期
- 客户端需要处理"Leader 切换期间的请求失败"(重试 + 重新发现 Leader)
模式选型速查
| 问题场景 | 推荐模式 | 核心原因 |
|---|---|---|
| 下游服务变慢拖垮整体 | Bulkhead 舱壁隔离 | 故障隔离,防止级联 |
| 下游持续失败,重试无效 | Circuit Breaker 熔断 | 快速失败,给下游恢复时间 |
| 偶发网络抖动导致失败 | Retry + Backoff + Jitter | 等待抖动后重试 |
| 事务 + 消息发布原子性 | Outbox Pattern | 利用同一数据库事务 |
| 需要完整审计/时光机 | Event Sourcing | 事件不可变,可重放 |
| 写模型查询性能差 | CQRS | 读写模型各自优化 |
| 跨服务长事务 | Saga(补偿) | 最终一致性,无分布式锁 |
| 跨服务短事务,需较强隔离 | TCC | 预留资源,业务层强一致 |
| 基础设施与业务解耦 | Sidecar | 关注点分离 |
| 集群需要唯一协调者 | Leader Election | 共识算法保证唯一性 |
参考资料
- 《分布式金融架构课》— 任杰(极客时间)
- 《分布式技术原理与算法解析》— 聂鹏程(极客时间)
- Martin Fowler — Event Sourcing
- Martin Fowler — CQRS
- Chris Richardson — Saga Pattern
- Netflix Tech Blog — Circuit Breaker
评论 (0)