专栏文章
专栏文章
分布式系统系列
1. 分布式系统 #01:分布式一致性 2. 分布式系统 #02:分布式事务 3. 分布式系统 #03:分布式协调与选举 4. 分布式系统 #04:分布式系统设计模式 5. 分布式系统 #05:金融级分布式系统实践

分布式系统 #04:分布式系统设计模式

发布于 2026-05-28 14:04 👁 10 次阅读
#架构#分布式#design-patterns

本文系统梳理分布式系统的核心设计模式:从分布式的本质挑战(网络分区/部分失败/时钟偏斜),到隔离、重试、熔断等弹性模式,再到 Event Sourcing、CQRS、Outbox 等数据一致性模式。读完能在工程中识别问题并套用合适的模式,而不是每次重新发明轮子。

相关文章分布式一致性 · 分布式事务 · 分布式协调与选举 · 金融级分布式系统实践


目录

章节 说明
分布式系统的本质挑战 网络分区/部分失败/时钟偏斜的根本原因
弹性模式:隔离与限流 Bulkhead 舱壁、令牌桶/漏桶
弹性模式:重试与熔断 Retry + Backoff + Jitter、Circuit Breaker 三状态机
数据一致性模式 Outbox、Event Sourcing、CQRS
Saga 补偿模式 编排式 vs 协调式,与 TCC 对比
Sidecar 与 Leader Election 基础设施解耦模式
模式选型速查 场景 → 推荐模式

分布式系统的本质挑战

分布式系统的根本难点不是性能,而是部分失败(Partial Failure)——单机要么全挂要么全好,分布式系统可以只有一部分坏掉,而你不知道哪部分坏了。

dist patterns overview

三大核心挑战

挑战 含义 为什么难处理
网络分区(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 重试
(各客户端的重试时间随机散开,避免惊群)

实现要点

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

关键参数

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

关键点

Event Sourcing 事件溯源

核心思想:不存储"当前状态",而是存储导致状态变化的事件序列。当前状态通过重放事件序列计算得出。

三个核心概念

概念 含义 类比
Command(命令) 外部发出的指令,可能被拒绝 "我想转账 100 元"
Event(事件) 命令被验证通过后产生的事实,一定会执行 "已转账 100 元(transferred)"
State(状态) 所有历史事件累积的结果 账户余额 = 所有存取款事件之和
数学本质:
Sn = f(Sn-1, en)   → 当前状态 = 函数(前一状态, 当前事件)
Sn = S0 + e1 + e2 + ... + en  → 状态 = 初始状态 + 所有事件之和

事件溯源的核心约束(不可违反):

快照(Snapshot)优化

无快照:恢复状态需重放所有历史事件(可能有百万条)
有快照:从最近快照 + 快照后的增量事件恢复(只需重放少量事件)

快照频率 → 决定恢复时间(不是事件总数)
金融系统:日切(每日 0 点)天然是打快照的时机

适用场景

不适用场景

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 + 过期时间 简单,但有极小概率的锁丢失风险

注意事项


模式选型速查

问题场景 推荐模式 核心原因
下游服务变慢拖垮整体 Bulkhead 舱壁隔离 故障隔离,防止级联
下游持续失败,重试无效 Circuit Breaker 熔断 快速失败,给下游恢复时间
偶发网络抖动导致失败 Retry + Backoff + Jitter 等待抖动后重试
事务 + 消息发布原子性 Outbox Pattern 利用同一数据库事务
需要完整审计/时光机 Event Sourcing 事件不可变,可重放
写模型查询性能差 CQRS 读写模型各自优化
跨服务长事务 Saga(补偿) 最终一致性,无分布式锁
跨服务短事务,需较强隔离 TCC 预留资源,业务层强一致
基础设施与业务解耦 Sidecar 关注点分离
集群需要唯一协调者 Leader Election 共识算法保证唯一性

参考资料

← 返回列表

评论 (0)

暂无评论,来留下第一条吧。
登录注册 后才能发表评论