惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

C
Cisco Blogs
爱范儿
爱范儿
有赞技术团队
有赞技术团队
博客园 - 【当耐特】
Jina AI
Jina AI
Project Zero
Project Zero
宝玉的分享
宝玉的分享
Martin Fowler
Martin Fowler
WordPress大学
WordPress大学
Simon Willison's Weblog
Simon Willison's Weblog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
T
Tenable Blog
F
Fortinet All Blogs
大猫的无限游戏
大猫的无限游戏
Last Week in AI
Last Week in AI
月光博客
月光博客
雷峰网
雷峰网
G
Google Developers Blog
V
V2EX
T
Tor Project blog
罗磊的独立博客
Schneier on Security
Schneier on Security
Know Your Adversary
Know Your Adversary
W
WeLiveSecurity
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
P
Privacy International News Feed
S
Securelist
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
P
Proofpoint News Feed
Blog — PlanetScale
Blog — PlanetScale
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
小众软件
小众软件
Scott Helme
Scott Helme
I
Intezer
T
Threat Research - Cisco Blogs
The GitHub Blog
The GitHub Blog
N
Netflix TechBlog - Medium
C
CERT Recently Published Vulnerability Notes
Security Archives - TechRepublic
Security Archives - TechRepublic
酷 壳 – CoolShell
酷 壳 – CoolShell
L
LINUX DO - 最新话题
N
News | PayPal Newsroom
L
Lohrmann on Cybersecurity
T
Troy Hunt's Blog
Google DeepMind News
Google DeepMind News
P
Proofpoint News Feed
人人都是产品经理
人人都是产品经理
Latest news
Latest news
AWS News Blog
AWS News Blog
Apple Machine Learning Research
Apple Machine Learning Research

土法炼钢兴趣小组的算法知识备份

国密算法与国密 TLS 系列索引 【系统架构设计】架构质量属性:不只是"高可用高性能" 【系统架构设计百科】告警策略:如何避免"狼来了" 【系统架构设计】CQRS:读写分离的架构哲学 【系统架构设计】空间架构:极端扩展场景的解法 【系统架构设计】微服务架构深度审视:优势、代价与适用边界 【系统架构设计】扩展性原理:水平、垂直与对角扩展 【系统架构设计】无状态设计:扩展的第一步也是最难的一步 【系统架构设计】缓存架构:从本地到分布式的多级缓存体系 【系统架构设计】管道与过滤器:Unix 哲学的架构表达 【系统架构设计】复杂性管理:架构的核心战场 【系统架构设计】消息队列架构:异步解耦的设计与陷阱 【系统架构设计】CDN 架构:全球加速的设计原理 【系统架构设计】连接池设计:被忽视的性能杀手 【系统架构设计】高可用设计模式:冗余、故障转移与仲裁 【系统架构设计】容量规划:从拍脑袋到数据驱动 【系统架构设计】数据库扩展:分库分表的工程实践与替代方案 【系统架构设计】SLO 工程:可靠性的量化管理 【系统架构设计】性能建模:用数学思维分析系统瓶颈 【系统架构设计】混沌工程:主动验证系统的韧性 【系统架构设计】零拷贝与内存映射:数据搬运的极致优化 【系统架构设计】线程模型:从 thread-per-request 到协程 【系统架构设计】容灾架构:多活与灾备设计 【系统架构设计】数据库性能模式:索引、查询与连接管理 【系统架构设计】数据建模:从关系范式到文档模型的真实权衡 【系统架构设计】吞吐量优化:批处理、流水线与并发模型 【系统架构设计】流处理架构:从批处理到实时的范式迁移 【系统架构设计】搜索引擎架构:倒排索引之上的系统设计 【系统架构设计】时序数据架构:监控与 IoT 的存储设计 【系统架构设计】数据迁移与版本化:在线不停机的数据演进 【系统架构设计】数据湖与数据仓库:分析架构的演进路线 【系统架构设计】API 网关设计:入口层的职责边界 【系统架构设计】应用层数据一致性模式:在正确性与性能之间走钢丝 【系统架构设计】多模数据库选型:Polyglot Persistence 的工程实践 【系统架构设计】服务发现与注册:动态拓扑的基础设施 【系统架构设计】配置管理架构:从配置文件到配置中心 【系统架构设计】全链路压测:大规模系统的性能验证 【系统架构设计】幂等性设计:分布式环境下的安全重试 【系统架构设计】契约测试与 Schema 演进:服务间的信任协议 【系统架构设计】长连接与推送架构:WebSocket、SSE 与 MQTT 【系统架构设计】延迟分析:从 P50 到 P999 的全链路追踪 【系统架构设计百科】DDD 战术模式:聚合、实体与值对象 【系统架构设计百科】防腐层与开放主机服务:系统集成的 DDD 方案 【系统架构设计百科】领域事件与事件风暴:从业务到架构的桥梁 【系统架构设计百科】CQRS + Event Sourcing 完整实战:从领域建模到部署 【系统架构设计百科】DDD 与微服务:用领域模型划分服务边界 【系统架构设计】DDD 战略设计:限界上下文与上下文映射 【系统架构设计百科】认证架构:从 Session 到 JWT 到 OIDC 【系统架构设计】API 设计哲学:REST vs GraphQL vs gRPC 的真实权衡 排序算法专题:从 TimSort 到并行排序 【密码学百科】国密算法体系:SM2/SM3/SM4/SM9 全景解读 【密码学百科】承诺方案:Pedersen 承诺、向量承诺与多项式承诺 【密码学百科】不经意传输与隐私信息检索:OT、OT 扩展与 PIR 【密码学百科】门限密码学:门限签名、门限解密与分布式密钥生成 完美哈希:从理论到 gperf 实践 【密码学百科】安全多方计算:从 Yao 的混淆电路到实用 MPC 【密码学百科】同态加密:从 Paillier 到全同态加密(FHE) 【密码学百科】零知识证明系统:zk-SNARKs、zk-STARKs 与 Bulletproofs 【密码学百科】概率论与密码分析:生日攻击、差分分析与线性分析 【密码学百科】计算复杂性与归约:密码安全性证明的基石 【密码学百科】秘密共享:Shamir 方案、VSS 与安全多方计算入口 【密码学百科】椭圆曲线代数:Weierstrass 方程、点群运算与曲线选择 【密码学百科】离散对数与配对密码学:从 DLP 到 BLS 签名 【密码学百科】格密码数学基础:SVP、LWE 与格基约化 【密码学百科】抽象代数:群、环、域的密码学视角 【密码学百科】有限域算术:GF(2^n) 运算与在 AES/ECC 中的应用 【密码学百科】数论进阶:二次剩余、椭圆曲线上的 Weil 配对 【密码学百科】密码学简史:从凯撒密码到量子时代 【密码学百科】威胁模型与安全目标:CIA 三要素之外 【密码学百科】Kerckhoffs 原则与现代密码设计哲学 【密码学百科】随机性:密码学的基石 【密码学百科】信息论入门:熵、完美保密与 Shannon 定理 【密码学百科】分组密码原理:Feistel 网络与 SPN 结构 【密码学百科】AES 逐步拆解:SubBytes 到 MixColumns 的数学 【密码学百科】分组密码工作模式全览:ECB/CBC/CTR/OFB/CFB 【密码学百科】流密码:RC4 的兴衰与 ChaCha20 的崛起 【密码学百科】密码学哈希函数:MD5→SHA-2→SHA-3 的进化之路 【密码学百科】MAC 与 HMAC:消息认证的正确姿势 【密码学百科】认证加密(AEAD):GCM、ChaCha20-Poly1305 与 OCB 【密码学百科】密钥派生函数:HKDF、PBKDF2、Argon2 与密码存储 【密码学百科】公钥密码的数论基础:模运算、群、原根 【密码学百科】RSA 从原理到攻击:教科书 RSA 为什么不安全 【密码学百科】Diffie-Hellman 密钥交换与离散对数问题 【密码学百科】椭圆曲线密码学(ECC):从几何直觉到点群运算 【密码学百科】数字签名:ECDSA、EdDSA 与 Schnorr 签名 【密码学百科】现代密钥交换:X25519、ECDHE 与前向保密 【密码学百科】混合加密与 KEM/DEM 范式:ECIES 与 HPKE 【密码学百科】填充方案:PKCS#1 v1.5、OAEP 与 PSS 【密码学百科】TLS 协议全解析:从握手到 0-RTT 【密码学百科】PKI 与数字证书:信任链的构建与崩塌 【密码学百科】密码认证协议:从 SRP 到 OPAQUE 【密码学百科】零知识证明入门:如何证明你知道而不泄露 【密码学百科】安全信道构造:Noise 协议框架与 Signal 协议 【密码学百科】密钥管理工程:HSM、KMS 与密钥生命周期 【密码学百科】侧信道攻击:从时序攻击到功耗分析 【密码学百科】密码学实现陷阱:三层漏洞分类、审计工具链与系统性预防 密码敏捷性:如何设计可升级的密码系统 【密码学百科】OpenSSL/BoringSSL 架构剖析:ENGINE、Provider 与 FIPS 模块 【Linux 网络子系统深度拆解】内核网络调优方法论:从基准测试到生产验证 排序基准测试:用数据说话
【系统架构设计】弹性设计模式:熔断器、舱壁与超时
Liao Tonglang · 2026-04-13 · via 土法炼钢兴趣小组的算法知识备份

分布式系统中,服务之间通过网络调用产生依赖关系。网络调用不可靠——延迟抖动、连接超时、服务不可用随时可能发生。当一个下游服务出现故障时,上游服务如果不加任何保护措施,最直觉的反应是”重试”。但重试本身如果设计不当,非但不能解决问题,反而会把一个局部故障放大为全局雪崩。

弹性设计模式(Resilience Patterns)就是为了解决这一类问题而总结出的一组工程实践。熔断器(Circuit Breaker)在故障达到阈值时切断调用链路,避免无效请求持续消耗资源;舱壁(Bulkhead)将资源隔离为多个独立的隔舱,防止一个慢依赖拖垮整个系统;超时(Timeout)为每次调用设置时间上限,避免线程长期阻塞;指数退避(Exponential Backoff)与抖动(Jitter)在重试间隔上引入随机性,避免大量客户端同时重试造成二次冲击。

这些模式不是互相替代的关系,而是需要组合使用。组合的方式、顺序、参数配置都会直接影响效果。本文将从一次真实的重试风暴事故出发,逐一拆解每个模式的原理、实现和工程经验。


一、一次重试引发的雪崩

重试放大效应

考虑一个三层调用链:服务 A 调用服务 B,服务 B 调用服务 C。每个服务在调用下游时配置了 3 次重试(含首次调用共 3 次尝试)。

当服务 C 出现故障(比如响应超时)时,调用量会发生如下放大:

  • 服务 B 对 C 的每次调用会重试 3 次
  • 服务 A 对 B 的每次调用也会重试 3 次
  • 因此,A 发出 1 次请求,最终 C 收到的请求数为 3 x 3 = 9 次

如果调用链有 n 层,每层重试 r 次,则末端服务收到的请求放大倍数为 r^n。三层调用、每层重试 3 次,放大倍数为 27。四层调用、每层重试 3 次,放大倍数为 81。

graph LR
    A[服务 A] -->|请求 1| B[服务 B]
    A -->|重试 1| B
    A -->|重试 2| B
    B -->|请求 1| C[服务 C]
    B -->|重试 1| C
    B -->|重试 2| C

    style C fill:#f96,stroke:#333
    subgraph 放大效应
        direction TB
        N1["A 发出 1 次请求"]
        N2["B 收到 3 次请求"]
        N3["C 收到 9 次请求"]
        N1 --> N2 --> N3
    end

这种指数级放大效应被称为重试风暴(Retry Storm)。它的危险在于:正常情况下系统运行良好,一旦下游出现短暂抖动,重试机制反而把抖动变成了雪崩。

雷群效应

重试风暴的另一个变体是雷群效应(Thundering Herd)。当服务 C 短暂不可用后恢复时,所有积压的重试请求会在同一时刻涌向 C,造成瞬间流量尖峰。如果 C 的容量刚好只能承受正常流量,这个尖峰会再次把 C 打挂,形成反复震荡。

一个典型的场景:缓存服务器重启后,所有请求穿透到数据库,数据库过载后超时,上游开始重试,重试进一步放大了对数据库的压力,数据库恢复后瞬间承受了数倍于正常的流量,再次崩溃。这个循环可以持续很长时间,直到人工介入。

重试的正确使用条件

重试不是不能用,而是需要满足以下条件:

  1. 操作是幂等的。 非幂等操作(如扣款、发送消息)重试可能导致重复执行。
  2. 故障是暂时的。 如果下游是持续性故障(如配置错误、硬件损坏),重试只会浪费资源。
  3. 重试次数有上限。 无限重试会持续消耗资源。
  4. 重试间隔有退避策略。 固定间隔重试在高并发下会形成雷群效应。
  5. 只在调用链的最外层重试。 中间层的重试会产生指数放大效应。

违反其中任何一条都可能导致问题。而熔断器、舱壁、超时等模式正是为了在违反这些条件时提供安全网。


二、熔断器:状态机设计

核心思想

熔断器的灵感来自电气工程中的断路器:当电路中的电流超过安全阈值时,断路器自动断开电路,防止过载损坏设备。在软件系统中,熔断器监控对下游服务的调用结果,当失败率达到阈值时自动切断调用,避免持续向已经不可用的服务发送请求。

熔断器解决的核心问题是:当下游服务已经不可用时,继续向它发送请求是没有意义的,这些请求只会消耗上游的线程、连接等资源,同时给下游增加额外的恢复压力。不如快速失败(Fail Fast),让调用方立刻得到错误响应,去执行降级逻辑。

三态状态机

熔断器的核心是一个包含三个状态的有限状态机:

关闭(Closed): 正常工作状态。所有请求都被放行到下游服务。熔断器在后台记录调用的成功和失败,计算失败率。当失败率超过设定的阈值时,状态从关闭转移到打开。

打开(Open): 熔断状态。所有请求被直接拒绝,不再发送到下游服务。调用方会立即收到一个错误响应(通常是特定的异常类型,如 CallNotPermittedException),可以据此执行降级逻辑。打开状态会持续一段时间(等待时间窗口),到期后状态自动转移到半开。

半开(Half-Open): 探测状态。熔断器允许有限数量的请求通过,用于试探下游服务是否已经恢复。如果这些探测请求的成功率达到阈值,状态转移回关闭;如果失败率仍然很高,状态退回到打开,重新开始等待。

stateDiagram-v2
    [*] --> Closed
    Closed --> Open : 失败率 >= 阈值
    Open --> HalfOpen : 等待时间到期
    HalfOpen --> Closed : 探测成功率 >= 阈值
    HalfOpen --> Open : 探测失败率 >= 阈值

    Closed : 放行所有请求
    Closed : 记录成功/失败
    Open : 拒绝所有请求
    Open : 直接返回错误
    HalfOpen : 允许有限请求通过
    HalfOpen : 评估恢复情况

状态转移细节

关闭到打开的转移条件。 不是简单地看”最近几次调用是否失败”,而是基于滑动窗口(Sliding Window)内的统计数据。滑动窗口可以基于调用次数(比如最近 100 次调用)或时间(比如最近 60 秒内的调用)。当窗口内的失败率超过阈值(比如 50%),并且调用总数达到最小样本量(比如至少 10 次调用)时,才触发状态转移。

最小样本量的设置很重要:如果窗口内只有 2 次调用,其中 1 次失败,失败率是 50%,但这并不能说明服务不可用——样本量太小,统计结果不可靠。

打开到半开的转移条件。 纯粹基于时间。打开状态持续一个固定时间窗口(比如 60 秒)后,自动转移到半开。这个时间窗口的长度需要根据下游服务的预期恢复时间来设定。

半开到关闭或打开的转移条件。 半开状态下,熔断器允许固定数量的请求通过(比如 10 个)。如果这些请求中的成功率达到阈值(比如成功率 >= 50%),状态转移到关闭;否则退回到打开。

Java 伪代码实现

以下是一个简化的熔断器实现,展示核心状态机逻辑:

public class SimpleCircuitBreaker {
    
    private enum State { CLOSED, OPEN, HALF_OPEN }
    
    private volatile State state = State.CLOSED;
    private final AtomicInteger failureCount = new AtomicInteger(0);
    private final AtomicInteger successCount = new AtomicInteger(0);
    private final AtomicInteger callCount = new AtomicInteger(0);
    private volatile long openedAt;
    
    private final int failureRateThreshold;    // 失败率阈值,百分比
    private final int minimumNumberOfCalls;     // 最小调用次数
    private final long waitDurationInOpenState; // 打开状态等待时间,毫秒
    private final int permittedNumberInHalfOpen; // 半开状态允许的调用数
    
    public SimpleCircuitBreaker(int failureRateThreshold,
                                 int minimumNumberOfCalls,
                                 long waitDurationInOpenState,
                                 int permittedNumberInHalfOpen) {
        this.failureRateThreshold = failureRateThreshold;
        this.minimumNumberOfCalls = minimumNumberOfCalls;
        this.waitDurationInOpenState = waitDurationInOpenState;
        this.permittedNumberInHalfOpen = permittedNumberInHalfOpen;
    }
    
    public <T> T execute(Supplier<T> action, Supplier<T> fallback) {
        if (!acquirePermission()) {
            return fallback.get();
        }
        try {
            T result = action.get();
            onSuccess();
            return result;
        } catch (Exception e) {
            onFailure();
            return fallback.get();
        }
    }
    
    private synchronized boolean acquirePermission() {
        switch (state) {
            case CLOSED:
                return true;
            case OPEN:
                if (System.currentTimeMillis() - openedAt >= waitDurationInOpenState) {
                    transitionToHalfOpen();
                    return true;
                }
                return false;
            case HALF_OPEN:
                return callCount.get() < permittedNumberInHalfOpen;
            default:
                return false;
        }
    }
    
    private synchronized void onSuccess() {
        successCount.incrementAndGet();
        callCount.incrementAndGet();
        if (state == State.HALF_OPEN && callCount.get() >= permittedNumberInHalfOpen) {
            evaluateHalfOpen();
        }
    }
    
    private synchronized void onFailure() {
        failureCount.incrementAndGet();
        callCount.incrementAndGet();
        if (state == State.CLOSED) {
            evaluateClosed();
        } else if (state == State.HALF_OPEN && callCount.get() >= permittedNumberInHalfOpen) {
            evaluateHalfOpen();
        }
    }
    
    private void evaluateClosed() {
        if (callCount.get() >= minimumNumberOfCalls) {
            int failureRate = failureCount.get() * 100 / callCount.get();
            if (failureRate >= failureRateThreshold) {
                transitionToOpen();
            }
        }
    }
    
    private void evaluateHalfOpen() {
        int failureRate = failureCount.get() * 100 / callCount.get();
        if (failureRate < failureRateThreshold) {
            transitionToClosed();
        } else {
            transitionToOpen();
        }
    }
    
    private void transitionToOpen() {
        state = State.OPEN;
        openedAt = System.currentTimeMillis();
        resetCounters();
    }
    
    private void transitionToHalfOpen() {
        state = State.HALF_OPEN;
        resetCounters();
    }
    
    private void transitionToClosed() {
        state = State.CLOSED;
        resetCounters();
    }
    
    private void resetCounters() {
        failureCount.set(0);
        successCount.set(0);
        callCount.set(0);
    }
}

这个实现是简化版本。生产级别的熔断器(如 Resilience4j)使用环形缓冲区(Ring Bit Buffer)来实现滑动窗口,内存效率更高,并发控制也更精细。


三、熔断器参数调优

关键参数

熔断器的效果取决于参数配置。配置不当会导致两类问题:误熔断(本来可用的服务被切断)和漏熔断(服务已经不可用但没有触发熔断)。以下是核心参数及其调优思路。

失败率阈值(Failure Rate Threshold)。 滑动窗口内触发熔断的失败率百分比。典型值 50%。设得太低(比如 10%),正常的网络抖动就会触发误熔断;设得太高(比如 90%),服务已经严重异常了才熔断,保护效果差。

慢调用率阈值(Slow Call Rate Threshold)。 除了失败率,还可以基于慢调用的比例触发熔断。当响应时间超过设定的阈值(比如 2 秒)的调用比例超过阈值(比如 80%)时,也触发熔断。这对于识别”没有报错但响应极慢”的降级场景很有用。

滑动窗口类型(Sliding Window Type)。 基于计数(Count-Based)或基于时间(Time-Based)。

  • 基于计数:窗口大小是调用次数(比如最近 100 次调用)。优点是统计结果稳定,不受流量波动影响。缺点是在低流量时,窗口可能覆盖很长的时间跨度,反应迟钝。
  • 基于时间:窗口大小是时间长度(比如最近 60 秒)。优点是反应及时。缺点是低流量时窗口内的样本量可能不足,统计不可靠。

滑动窗口大小(Sliding Window Size)。 基于计数时是调用次数(如 100),基于时间时是秒数(如 60)。窗口越大,统计越稳定但反应越慢;窗口越小,反应越快但越容易误判。

最小调用次数(Minimum Number of Calls)。 在评估失败率之前,窗口内至少需要的调用次数。用于防止样本量不足时的误判。典型值 10~20。

打开状态等待时间(Wait Duration in Open State)。 熔断器保持打开状态的时间,到期后转移到半开状态。典型值 30~60 秒。设得太短,下游还没恢复就开始探测,会反复触发熔断;设得太长,下游已经恢复了但上游还在熔断,不必要地延长了故障影响时间。

半开状态允许的调用数(Permitted Number of Calls in Half-Open State)。 半开状态下放行多少个请求来探测下游是否恢复。典型值 5~10。设得太少(如 1),一次偶发失败就会退回打开状态;设得太多,如果下游确实没有恢复,这些探测请求都会失败,白白消耗资源。

参数配置示例

以下是一个 Resilience4j 的熔断器配置示例:

resilience4j:
  circuitbreaker:
    instances:
      paymentService:
        sliding-window-type: COUNT_BASED
        sliding-window-size: 100
        minimum-number-of-calls: 20
        failure-rate-threshold: 50
        slow-call-rate-threshold: 80
        slow-call-duration-threshold: 2s
        wait-duration-in-open-state: 30s
        permitted-number-of-calls-in-half-open-state: 10
        record-exceptions:
          - java.io.IOException
          - java.util.concurrent.TimeoutException
          - org.springframework.web.client.HttpServerErrorException
        ignore-exceptions:
          - com.example.BusinessException

参数调优策略

第一,区分需要记录的异常和需要忽略的异常。 业务异常(如参数校验不通过、业务规则不满足)不应该被熔断器计为失败,只有基础设施异常(如网络超时、连接拒绝、服务端 5xx 错误)才应该被记录。否则正常的业务拒绝也会推高失败率,导致误熔断。

第二,根据下游服务的 SLA 设定慢调用阈值。 如果下游承诺 P99 延迟在 500ms 以内,慢调用阈值可以设为 1s(给一定余量)。如果 80% 的请求都超过 1s,说明服务已经严重降级。

第三,根据流量模式选择滑动窗口类型。 高频服务(QPS > 100)适合使用基于计数的窗口,因为样本量充足,统计稳定。低频服务(QPS < 10)适合使用基于时间的窗口,避免窗口跨度过长。

第四,灰度调优。 先用宽松的参数(高阈值、大窗口)上线,通过监控观察熔断触发的频率和原因,逐步收紧。避免一上来就用激进参数导致频繁误熔断。

监控指标

生产环境中必须监控以下指标:

  • 熔断器状态变化事件(从关闭到打开、从半开到关闭等),配合告警
  • 滑动窗口内的失败率和慢调用率
  • 被熔断器拒绝的请求数(not permitted calls)
  • 熔断器保持打开状态的持续时间
CircuitBreaker circuitBreaker = CircuitBreaker.ofDefaults("paymentService");

circuitBreaker.getEventPublisher()
    .onStateTransition(event -> {
        log.warn("熔断器状态变化: {} -> {}, 服务: {}",
            event.getStateTransition().getFromState(),
            event.getStateTransition().getToState(),
            event.getCircuitBreakerName());
        // 发送告警
        alertService.sendAlert(
            "CircuitBreaker state changed to " 
            + event.getStateTransition().getToState());
    })
    .onCallNotPermitted(event -> {
        metrics.increment("circuit_breaker.rejected",
            "service", event.getCircuitBreakerName());
    });

四、舱壁模式:资源隔离

问题背景

假设一个服务依赖三个下游服务:支付服务、库存服务、用户服务。三个服务共享同一个线程池(比如 Tomcat 的默认线程池,通常 200 个线程)。当支付服务出现故障响应极慢时,调用支付服务的请求会长时间占用线程不释放。如果支付相关的请求量很大,很快 200 个线程就会被全部耗尽,此时即使库存服务和用户服务完全正常,也无法处理任何请求——因为没有可用线程了。

一个慢依赖拖垮了所有功能。这就是缺乏资源隔离的后果。

舱壁的灵感

舱壁模式的灵感来自船舶设计。远洋船舶的船体被隔板分成多个独立的舱室(Bulkhead)。当一个舱室进水时,隔板阻止水蔓延到其他舱室,船不会沉没。类比到软件系统:为每个下游依赖分配独立的资源池,一个依赖的故障只消耗它自己的资源配额,不影响其他依赖的调用。

线程池隔离

线程池隔离(Thread Pool Isolation)是最直观的舱壁实现方式。为每个下游依赖创建一个独立的线程池:

// Resilience4j 线程池舱壁配置
ThreadPoolBulkhead paymentBulkhead = ThreadPoolBulkhead.of(
    "paymentService",
    ThreadPoolBulkheadConfig.custom()
        .maxThreadPoolSize(20)        // 最大线程数
        .coreThreadPoolSize(10)       // 核心线程数
        .queueCapacity(50)            // 等待队列容量
        .keepAliveDuration(Duration.ofMillis(100))
        .writableStackTraceEnabled(true)
        .build()
);

ThreadPoolBulkhead inventoryBulkhead = ThreadPoolBulkhead.of(
    "inventoryService",
    ThreadPoolBulkheadConfig.custom()
        .maxThreadPoolSize(15)
        .coreThreadPoolSize(8)
        .queueCapacity(30)
        .build()
);

// 使用舱壁执行调用
CompletableFuture<PaymentResult> result = Bulkhead
    .decorateCompletionStage(paymentBulkhead, 
        () -> paymentClient.charge(order))
    .get()
    .toCompletableFuture();

线程池隔离的优点:

  • 隔离效果好,不同依赖的调用完全在不同线程中执行
  • 可以设置超时(通过线程池的 Future.get(timeout))
  • 线程池满时可以快速失败,不会阻塞调用方的线程

线程池隔离的缺点:

  • 线程上下文切换有开销,高 QPS 场景下这个开销不可忽略
  • 线程数量有限,总线程数受操作系统限制
  • 需要管理更多的线程池,配置和监控的复杂度增加
  • ThreadLocal 信息在线程切换时会丢失,需要额外处理(如 TransmittableThreadLocal)

信号量隔离

信号量隔离(Semaphore Isolation)是更轻量的实现方式。它不创建新的线程池,而是用信号量限制对每个下游依赖的并发调用数量。调用仍然在调用方的线程中执行,只是通过信号量控制并发度:

// Resilience4j 信号量舱壁配置
Bulkhead paymentBulkhead = Bulkhead.of(
    "paymentService",
    BulkheadConfig.custom()
        .maxConcurrentCalls(20)            // 最大并发调用数
        .maxWaitDuration(Duration.ofMillis(500))  // 最大等待时间
        .writableStackTraceEnabled(true)
        .build()
);

// 装饰函数调用
Supplier<PaymentResult> decoratedSupplier = Bulkhead
    .decorateSupplier(paymentBulkhead, () -> paymentClient.charge(order));

Try<PaymentResult> result = Try.ofSupplier(decoratedSupplier)
    .recover(BulkheadFullException.class, throwable -> {
        log.warn("支付服务舱壁已满,执行降级逻辑");
        return PaymentResult.degraded();
    });

信号量隔离的优点:

  • 无线程上下文切换开销,性能更好
  • 不需要额外的线程池,资源消耗更少
  • ThreadLocal 信息自然传递,不需要特殊处理

信号量隔离的缺点:

  • 不能主动中断慢调用(因为没有独立线程,无法从外部中断)
  • 如果下游服务响应极慢,信号量被占满后,虽然新请求会被拒绝,但已经在执行的请求仍然占用调用方的线程

线程池 vs 信号量的选择

对比维度 线程池隔离 信号量隔离
线程切换开销 有,需要上下文切换 无,在调用方线程执行
超时控制 支持,通过 Future.get(timeout) 不支持主动中断
ThreadLocal 传递 需要额外处理 自然传递
适用场景 下游可能长时间无响应 下游有自身超时机制
资源消耗 较高,需要维护独立线程池 较低,只需要一个计数器
典型 QPS 中低频(< 1000) 高频(> 1000)

实践建议:如果下游服务有可靠的超时机制(比如设置了 HTTP 客户端的 readTimeout),优先使用信号量隔离。如果下游服务可能出现”无响应”(既不成功也不失败,线程一直挂着)的情况,使用线程池隔离以便超时后主动回收线程。

线程池大小计算

线程池大小不是拍脑袋决定的。一个常用的估算公式来自 Brian Goetz 的 Java Concurrency in Practice

线程数 = CPU 核心数 * 目标 CPU 利用率 * (1 + 等待时间 / 计算时间)

对于 I/O 密集型的远程调用,等待时间远大于计算时间。假设一个调用的平均响应时间是 200ms,其中 CPU 计算时间约 5ms,等待时间约 195ms:

线程数 = 4 * 1.0 * (1 + 195/5) = 4 * 40 = 160

但这只是理论上限。实际还需要考虑:

  • 目标 QPS:如果目标 QPS 是 500,平均响应时间 200ms,需要的并发线程数 = 500 * 0.2 = 100
  • 故障时的线程数:如果下游超时时间设为 2s,故障时每个线程被占用 2s,需要的线程数 = 500 * 2 = 1000,远超正常情况

因此,舱壁的线程池大小应该按照正常情况配置,而不是按故障情况配置。故障情况下超出线程池的请求应该快速失败,这正是舱壁的保护作用。


五、超时设计:级联超时预算

为什么需要超时

没有超时的网络调用是危险的。TCP 默认的超时时间可以长达数分钟(取决于操作系统的 TCP 重传参数),在此期间线程一直被占用。如果大量请求都在等待一个无响应的下游服务,系统很快就会耗尽线程资源。

超时是资源保护的基本手段。每次远程调用都必须设置超时,没有例外。

连接超时与读取超时

HTTP 客户端通常区分两种超时:

连接超时(Connection Timeout): 建立 TCP 连接的最大等待时间。如果在这个时间内无法完成三次握手,认为目标不可达。典型值 1~3 秒。连接超时应该设得较短,因为正常情况下 TCP 连接建立只需要几十毫秒(同机房)到几百毫秒(跨地域)。

读取超时(Read Timeout): 连接建立后,等待服务端返回数据的最大时间。典型值根据业务而定,从几百毫秒到几十秒不等。读取超时应该根据下游服务的 SLA 来设定。

// OkHttp 客户端超时配置
OkHttpClient client = new OkHttpClient.Builder()
    .connectTimeout(2, TimeUnit.SECONDS)      // 连接超时 2 秒
    .readTimeout(5, TimeUnit.SECONDS)          // 读取超时 5 秒
    .writeTimeout(5, TimeUnit.SECONDS)         // 写入超时 5 秒
    .callTimeout(10, TimeUnit.SECONDS)         // 整体调用超时 10 秒
    .build();
// Spring RestTemplate 超时配置
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(2000);   // 2 秒
factory.setReadTimeout(5000);      // 5 秒

RestTemplate restTemplate = new RestTemplate(factory);

级联超时预算

在微服务架构中,一个用户请求通常会经过多个服务。如果对外承诺的 SLA 是 3 秒,而请求需要依次调用服务 A、B、C,那么 A、B、C 的超时时间之和必须小于 3 秒。

这就是级联超时预算(Cascading Timeout Budget)的概念。设计原则是从外向内分配超时预算:

用户请求 SLA: 3000ms

网关处理开销:     100ms
服务 A 超时预算:   900ms
服务 B 超时预算:   900ms
服务 C 超时预算:   900ms
预留缓冲:         200ms
总计:            3000ms

如果三个服务是并行调用的,那么总超时是单个服务中最长的那个,而不是三个相加。但如果是串行调用,就必须严格分配超时预算。

超时传播

更精细的做法是通过请求头传播剩余超时预算。gRPC 原生支持这个机制(Deadline Propagation):

// gRPC 客户端设置 deadline
ManagedChannel channel = ManagedChannelBuilder
    .forAddress("payment-service", 50051)
    .usePlaintext()
    .build();

PaymentServiceGrpc.PaymentServiceBlockingStub stub = 
    PaymentServiceGrpc.newBlockingStub(channel)
        .withDeadlineAfter(3, TimeUnit.SECONDS);  // 3 秒 deadline

// 在 gRPC 服务端获取剩余 deadline
@Override
public void processPayment(PaymentRequest request, 
                            StreamObserver<PaymentResponse> observer) {
    // 获取剩余超时时间
    long remainingMs = Context.current().getDeadline()
        .timeRemaining(TimeUnit.MILLISECONDS);
    
    if (remainingMs <= 0) {
        observer.onError(Status.DEADLINE_EXCEEDED
            .withDescription("请求到达时 deadline 已过期")
            .asRuntimeException());
        return;
    }
    
    // 用剩余时间减去本地处理开销作为下游调用的 deadline
    long downstreamDeadline = remainingMs - 100; // 预留 100ms 本地开销
    
    InventoryServiceGrpc.InventoryServiceBlockingStub inventoryStub =
        InventoryServiceGrpc.newBlockingStub(inventoryChannel)
            .withDeadlineAfter(downstreamDeadline, TimeUnit.MILLISECONDS);
    
    // 调用下游服务
    InventoryResponse inventoryResponse = inventoryStub.checkStock(
        InventoryRequest.newBuilder()
            .setProductId(request.getProductId())
            .build());
}

在 HTTP 场景下,可以通过自定义请求头实现类似的机制:

// 设置剩余超时预算到请求头
public class TimeoutPropagationInterceptor implements ClientHttpRequestInterceptor {
    
    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body,
            ClientHttpRequestExecution execution) throws IOException {
        
        Long deadlineMs = TimeoutContext.getDeadlineMs();
        if (deadlineMs != null) {
            long remainingMs = deadlineMs - System.currentTimeMillis();
            if (remainingMs <= 0) {
                throw new TimeoutException("请求 deadline 已过期");
            }
            request.getHeaders().set("X-Request-Timeout-Ms", 
                String.valueOf(remainingMs));
        }
        
        return execution.execute(request, body);
    }
}

超时与重试的交互

超时和重试的配置需要协调。如果单次调用超时 5 秒,最多重试 3 次,那么总超时最长可能是 15 秒。这 15 秒必须在级联超时预算的范围内。

一个常见的错误是只设置了单次调用的超时,却没有设置包含重试在内的总超时。Resilience4j 的 TimeLimiter 可以包裹重试逻辑,确保总超时不超过预算:

TimeLimiter timeLimiter = TimeLimiter.of(
    TimeLimiterConfig.custom()
        .timeoutDuration(Duration.ofSeconds(5))  // 总超时 5 秒
        .cancelRunningFuture(true)               // 超时后取消执行中的任务
        .build()
);

六、指数退避与抖动的数学原理

固定间隔重试的问题

当多个客户端同时因为下游故障而失败,并且都在固定间隔后重试时,重试请求会在同一时刻到达——这就是前文提到的雷群效应。固定间隔重试无法解决这个问题,因为所有客户端的重试时刻是同步的。

指数退避

指数退避(Exponential Backoff)让重试间隔随着重试次数指数增长:

delay = base * 2^attempt

其中 base 是基础延迟,attempt 是当前重试次数(从 0 开始)。例如 base = 1 秒:

  • 第 1 次重试:1 * 2^0 = 1 秒
  • 第 2 次重试:1 * 2^1 = 2 秒
  • 第 3 次重试:1 * 2^2 = 4 秒
  • 第 4 次重试:1 * 2^3 = 8 秒

指数退避减少了对下游的压力(间隔越来越长),但仍然没有解决雷群问题——如果 1000 个客户端在同一秒失败,它们会在第 1 秒同时重试,第 2 秒同时重试,第 4 秒同时重试,只是间隔变长了,但每次重试的时间点仍然是同步的。

引入抖动

抖动(Jitter)在退避间隔上引入随机性,让不同客户端的重试时间点错开。AWS 在 2015 年的一篇博文中详细分析了三种抖动策略:

完全抖动(Full Jitter):

delay = random(0, base * 2^attempt)

延迟在 0 到退避值之间均匀随机。这种方式的分散效果最好,但可能产生很短的延迟(接近 0),导致部分客户端几乎立刻重试。

等量抖动(Equal Jitter):

delay = base * 2^attempt / 2 + random(0, base * 2^attempt / 2)

先取退避值的一半作为固定部分,再加上 0 到一半之间的随机值。保证了最小延迟不低于退避值的一半,同时有随机分散效果。

去相关抖动(Decorrelated Jitter):

delay = random(base, previous_delay * 3)

每次重试的延迟基于上一次延迟计算,而不是基于重试次数。这种方式的分散效果介于完全抖动和等量抖动之间。

带抖动的指数退避公式

综合以上,生产中常用的公式是:

delay = min(base * 2^attempt + random_jitter, max_delay)

其中 max_delay 是延迟的上限(比如 60 秒),防止退避时间过长。random_jitter 是一个在 [0, base) 范围内的随机值。

Java 实现

public class ExponentialBackoffWithJitter {

    private final long baseDelayMs;
    private final long maxDelayMs;
    private final Random random = new ThreadLocalRandom.current();

    public ExponentialBackoffWithJitter(long baseDelayMs, long maxDelayMs) {
        this.baseDelayMs = baseDelayMs;
        this.maxDelayMs = maxDelayMs;
    }

    /**
     * 完全抖动策略
     */
    public long fullJitter(int attempt) {
        long exponentialDelay = baseDelayMs * (1L << attempt);
        return ThreadLocalRandom.current().nextLong(0, 
            Math.min(exponentialDelay, maxDelayMs) + 1);
    }

    /**
     * 等量抖动策略
     */
    public long equalJitter(int attempt) {
        long exponentialDelay = Math.min(baseDelayMs * (1L << attempt), maxDelayMs);
        long half = exponentialDelay / 2;
        return half + ThreadLocalRandom.current().nextLong(0, half + 1);
    }

    /**
     * 去相关抖动策略
     */
    public long decorrelatedJitter(long previousDelay) {
        long nextDelay = ThreadLocalRandom.current().nextLong(
            baseDelayMs, previousDelay * 3 + 1);
        return Math.min(nextDelay, maxDelayMs);
    }
}

Resilience4j 中的退避配置

RetryConfig retryConfig = RetryConfig.custom()
    .maxAttempts(4)
    .intervalFunction(IntervalFunction.ofExponentialRandomBackoff(
        1000,    // initialIntervalMillis: 基础延迟 1 秒
        2.0,     // multiplier: 退避因子
        0.5      // randomizationFactor: 抖动因子(0.5 表示 ±50%)
    ))
    .retryOnException(e -> e instanceof IOException)
    .build();

Retry retry = Retry.of("paymentRetry", retryConfig);

上面的配置中,randomizationFactor 为 0.5 意味着实际延迟在 [delay * 0.5, delay * 1.5] 范围内随机。例如第 2 次重试的理论延迟是 2 秒,实际延迟在 1~3 秒之间。

三种抖动策略的对比

策略 公式 最小延迟 分散效果 适用场景
完全抖动 random(0, base*2^n) 0 最好 大量客户端并发重试
等量抖动 base2^n/2 + random(0, base2^n/2) base*2^n/2 较好 需要保证最小退避间隔
去相关抖动 random(base, prev*3) base 中等 单客户端多次重试

七、Resilience4j 架构解析

设计理念

Resilience4j 是一个轻量级的 Java 弹性库,受 Netflix Hystrix 的启发但进行了现代化重新设计。与 Hystrix 的核心区别:

  • Resilience4j 基于 Java 8 的函数式编程风格,使用装饰器模式(Decorator Pattern)而非命令模式(Command Pattern)
  • 不强制使用线程池隔离(Hystrix 默认使用线程池),可以选择信号量隔离
  • 模块化设计,按需引入(Hystrix 是一个大包)
  • 原生支持响应式编程(Project Reactor、RxJava)

核心模块

Resilience4j 由以下核心模块组成:

模块 功能 典型场景
CircuitBreaker 熔断器 下游服务不可用时快速失败
Bulkhead 舱壁(信号量/线程池) 隔离不同下游依赖的资源
RateLimiter 限流 控制对下游服务的调用频率
Retry 重试 暂时性故障自动重试
TimeLimiter 超时限制 异步调用的超时控制
Cache 缓存 响应结果缓存

装饰器模式

Resilience4j 的核心设计是装饰器模式。每个弹性模块都是一个装饰器,可以包裹一个函数式接口(Supplier、Function、Runnable 等),在原有逻辑上附加弹性保护:

// 定义原始调用
Supplier<String> supplier = () -> paymentService.process(order);

// 逐层包裹装饰器
Supplier<String> decoratedSupplier = Decorators.ofSupplier(supplier)
    .withBulkhead(bulkhead)         // 最内层:舱壁隔离
    .withCircuitBreaker(circuitBreaker)  // 中间层:熔断保护
    .withRetry(retry)               // 最外层:重试
    .withFallback(Arrays.asList(
        CallNotPermittedException.class,
        BulkheadFullException.class),
        throwable -> "降级响应")     // 降级逻辑
    .decorate();

// 执行
Try<String> result = Try.ofSupplier(decoratedSupplier);

CircuitBreaker 的内部实现

Resilience4j 的 CircuitBreaker 使用环形位缓冲区(Ring Bit Buffer)实现滑动窗口。每个调用的结果只用 1 个 bit 存储(成功=0,失败=1),100 次调用只需要约 12.5 字节内存。

基于计数的滑动窗口使用一个固定大小的环形数组。新的调用结果覆盖最旧的记录,自然实现了窗口滑动效果。

基于时间的滑动窗口使用一个局部聚合的环形数组。每个元素存储一个时间片(比如 1 秒)内的聚合结果(调用次数、失败次数、总响应时间),避免了存储每个调用结果的内存开销。

// 基于计数的滑动窗口 CircuitBreaker
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
    .slidingWindowType(SlidingWindowType.COUNT_BASED)
    .slidingWindowSize(100)
    .minimumNumberOfCalls(20)
    .failureRateThreshold(50)
    .slowCallRateThreshold(80)
    .slowCallDurationThreshold(Duration.ofSeconds(2))
    .waitDurationInOpenState(Duration.ofSeconds(30))
    .permittedNumberOfCallsInHalfOpenState(10)
    .automaticTransitionFromOpenToHalfOpenEnabled(true)
    .build();

CircuitBreaker circuitBreaker = CircuitBreaker.of("payment", config);

与 Spring Boot 集成

// Spring Boot 配置类
@Configuration
public class ResilienceConfig {

    @Bean
    public CircuitBreakerRegistry circuitBreakerRegistry() {
        CircuitBreakerConfig defaultConfig = CircuitBreakerConfig.custom()
            .slidingWindowSize(100)
            .failureRateThreshold(50)
            .waitDurationInOpenState(Duration.ofSeconds(30))
            .permittedNumberOfCallsInHalfOpenState(10)
            .build();
        
        return CircuitBreakerRegistry.of(defaultConfig);
    }
}

// Service 层使用注解
@Service
public class PaymentServiceClient {

    @CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
    @Bulkhead(name = "paymentService", type = Bulkhead.Type.SEMAPHORE)
    @Retry(name = "paymentService")
    public PaymentResult processPayment(Order order) {
        return restTemplate.postForObject(
            "http://payment-service/api/payments",
            order, PaymentResult.class);
    }

    private PaymentResult paymentFallback(Order order, Throwable throwable) {
        log.warn("支付服务调用失败,执行降级: {}", throwable.getMessage());
        return PaymentResult.builder()
            .status(PaymentStatus.PENDING)
            .message("支付处理中,请稍后查询结果")
            .build();
    }
}

Actuator 监控端点

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,circuitbreakers,bulkheads,ratelimiters,retries
  health:
    circuitbreakers:
      enabled: true

通过 /actuator/circuitbreakers 端点可以查看所有熔断器的实时状态:

{
  "circuitBreakers": {
    "paymentService": {
      "failureRate": "25.0%",
      "slowCallRate": "10.0%",
      "failureRateThreshold": "50.0%",
      "slowCallRateThreshold": "80.0%",
      "bufferedCalls": 100,
      "failedCalls": 25,
      "slowCalls": 10,
      "notPermittedCalls": 0,
      "state": "CLOSED"
    }
  }
}

八、Sentinel 架构解析

定位与设计理念

Sentinel 是阿里巴巴开源的流量治理组件,定位比 Resilience4j 更宽泛。Resilience4j 聚焦于”保护调用方”(客户端视角),Sentinel 同时覆盖”保护服务自身”(服务端视角)和”保护调用方”两个方向。

Sentinel 的三大核心功能:

  1. 流量控制(Flow Control): 控制入站请求的速率,防止服务被过量请求压垮
  2. 熔断降级(Circuit Breaking): 对不稳定的下游依赖进行熔断,快速失败
  3. 系统自适应保护(System Adaptive Protection): 根据系统负载(CPU、线程数、入口 QPS)自动调整流量

架构概览

Sentinel 的处理流程基于责任链模式(Chain of Responsibility)。每个资源(Resource)对应一条处理链,链上包含多个处理槽(Slot):

Entry → NodeSelectorSlot → ClusterBuilderSlot → StatisticSlot 
      → FlowSlot → DegradeSlot → SystemSlot → ...
  • NodeSelectorSlot: 构建调用树,记录资源间的调用关系
  • ClusterBuilderSlot: 构建集群维度的统计节点
  • StatisticSlot: 实时统计(QPS、响应时间、异常数等),使用滑动窗口
  • FlowSlot: 流量控制规则判断
  • DegradeSlot: 熔断降级规则判断
  • SystemSlot: 系统自适应保护规则判断

滑动窗口统计

Sentinel 使用 LeapArray(跳跃数组)实现滑动窗口统计。核心数据结构是一个环形数组,每个元素代表一个时间窗口(Window Bucket),默认窗口大小 500ms,样本数 2(即总窗口 1s 分成 2 个 500ms 的桶)。

流量控制配置

// 流量控制规则
FlowRule rule = new FlowRule();
rule.setResource("paymentService");
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);  // 按 QPS 限流
rule.setCount(100);                           // 阈值 100 QPS
rule.setControlBehavior(
    RuleConstant.CONTROL_BEHAVIOR_WARM_UP);   // 预热模式
rule.setWarmUpPeriodSec(10);                  // 预热时间 10 秒

FlowRuleManager.loadRules(Collections.singletonList(rule));

Sentinel 的流量控制支持三种模式:

  • 直接拒绝(Reject): 超过阈值的请求直接抛出 FlowException
  • 排队等待(Rate Limiter / Queue): 匀速排队,基于漏桶算法
  • 预热(Warm Up): 基于令牌桶的预热模式,启动时阈值较低,逐步升高到设定值

熔断降级配置

// 熔断降级规则
DegradeRule rule = new DegradeRule();
rule.setResource("inventoryService");
rule.setGrade(CircuitBreakerStrategy.ERROR_RATIO.getType()); // 按错误率熔断
rule.setCount(0.5);              // 错误率阈值 50%
rule.setMinRequestAmount(20);    // 最小请求数
rule.setStatIntervalMs(30000);   // 统计窗口 30 秒
rule.setTimeWindow(60);          // 熔断持续时间 60 秒

DegradeRuleManager.loadRules(Collections.singletonList(rule));

Sentinel 的熔断策略支持三种:

  • 慢调用比例(Slow Request Ratio): 响应时间超过阈值的请求比例超过设定值时触发熔断
  • 异常比例(Error Ratio): 错误率超过设定值时触发熔断
  • 异常数(Error Count): 错误数超过设定值时触发熔断

系统自适应保护

这是 Sentinel 区别于 Resilience4j 的独特功能。系统自适应保护从全局视角出发,根据系统当前的运行状态动态调整入口流量:

// 系统自适应保护规则
SystemRule systemRule = new SystemRule();
systemRule.setHighestSystemLoad(3.0);       // 系统 load1 阈值
systemRule.setHighestCpuUsage(0.8);         // CPU 使用率阈值
systemRule.setAvgRt(200);                   // 所有入口的平均响应时间阈值
systemRule.setMaxThread(300);               // 入口并发线程数阈值
systemRule.setQps(1000);                    // 所有入口的总 QPS 阈值

SystemRuleManager.loadRules(Collections.singletonList(systemRule));

其底层原理基于 TCP BBR 拥塞控制算法的思想:通过监控系统的 load、CPU 使用率、入口 QPS 和平均响应时间,动态计算系统能承受的最大 QPS,并自动进行限流。

Sentinel vs Resilience4j

对比维度 Resilience4j Sentinel
定位 客户端弹性保护库 流量治理组件
运行模式 进程内嵌入 进程内嵌入 + 控制台
流量控制 有 RateLimiter,功能较简单 丰富(直接拒绝、排队、预热)
熔断降级 功能完善,状态机清晰 功能完善
系统自适应保护 不支持 支持
动态规则 需要自行实现 内置控制台,支持动态推送
编程模型 装饰器模式,函数式 注解 + SPI,面向切面
指标存储 进程内存 进程内存 + 可对接外部
依赖 极少(仅 Vavr) 较少
生态 Spring Cloud Circuit Breaker Spring Cloud Alibaba
适用场景 轻量级、函数式编程 需要控制台和动态规则管理

选型建议:如果项目已经使用 Spring Cloud Alibaba 生态,Sentinel 是自然选择。如果项目追求轻量和简洁,或者使用响应式编程(WebFlux),Resilience4j 更合适。两者不是互斥的——Sentinel 负责入口流量控制和系统保护,Resilience4j 负责出口调用的熔断和重试,可以同时使用。


九、模式组合:装饰器顺序的陷阱

为什么顺序很重要

弹性模式通常需要组合使用。一个典型的调用可能同时需要重试、熔断和舱壁保护。但装饰器的组合顺序直接影响行为,顺序不当会导致反直觉的结果。

考虑两种组合顺序:

顺序 A:Retry(CircuitBreaker(Bulkhead(fn)))

Retry → CircuitBreaker → Bulkhead → 实际调用

执行逻辑: 1. Retry 捕获异常并决定是否重试 2. CircuitBreaker 判断是否放行请求 3. Bulkhead 控制并发度 4. 执行实际调用

这个顺序的语义是:当调用失败时,Retry 会重试;如果 CircuitBreaker 打开了,Retry 会收到 CallNotPermittedException,此时根据 Retry 的配置,可以选择不再重试(正确行为)。

顺序 B:CircuitBreaker(Retry(Bulkhead(fn)))

CircuitBreaker → Retry → Bulkhead → 实际调用

执行逻辑: 1. CircuitBreaker 判断是否放行 2. Retry 捕获异常并重试(包括在 Bulkhead 内部重试) 3. Bulkhead 控制并发度 4. 执行实际调用

这个顺序的问题是:所有重试的失败都会被 CircuitBreaker 计为一次调用的失败。如果重试全部失败,CircuitBreaker 只记录了”一次调用失败”。这意味着 CircuitBreaker 看到的失败率被低估了,可能延迟触发熔断。

推荐的组合顺序

// 推荐顺序:外层到内层
// Retry → CircuitBreaker → RateLimiter → TimeLimiter → Bulkhead → fn

Supplier<String> decoratedSupplier = Decorators.ofSupplier(supplier)
    .withBulkhead(bulkhead)             // 最内层
    .withTimeLimiter(timeLimiter, scheduledExecutorService)
    .withRateLimiter(rateLimiter)
    .withCircuitBreaker(circuitBreaker)
    .withRetry(retry)                   // 最外层
    .decorate();

这个顺序的逻辑:

  1. Retry 在最外层: 能够感知到所有内层装饰器的异常(包括 CircuitBreaker 的 CallNotPermittedException、Bulkhead 的 BulkheadFullException),可以统一决定是否重试以及是否需要对特定异常不重试。

  2. CircuitBreaker 在 Retry 内层: 每次实际调用(不论是首次还是重试)都经过 CircuitBreaker,CircuitBreaker 能够准确统计失败率。

  3. Bulkhead 在最内层: 每次调用(包括重试)都受到并发度限制。如果把 Bulkhead 放在 Retry 外层,重试请求不会额外占用 Bulkhead 的并发位,可能导致实际并发数超过预期。

组合的反模式

反模式一:Retry 在 CircuitBreaker 内层

// 错误:CircuitBreaker(Retry(fn))
// 结果:CircuitBreaker 不知道发生了多少次失败(被 Retry 掩盖了)

Retry 在内层会先进行所有重试尝试,如果最终成功了,CircuitBreaker 看到的是一次成功。只有重试全部失败后,CircuitBreaker 才记录一次失败。这会导致 CircuitBreaker 严重低估真实的失败率。

反模式二:对 CallNotPermittedException 进行重试

// 错误:对熔断异常进行重试
RetryConfig config = RetryConfig.custom()
    .retryExceptions(Exception.class)  // 对所有异常重试,包括熔断异常
    .build();

当 CircuitBreaker 打开时,继续重试没有意义——下游已经不可用了。正确的做法是在 Retry 的配置中排除熔断相关的异常:

RetryConfig config = RetryConfig.custom()
    .maxAttempts(3)
    .retryOnException(e -> !(e instanceof CallNotPermittedException))
    .retryOnException(e -> !(e instanceof BulkheadFullException))
    .retryExceptions(IOException.class, TimeoutException.class)
    .build();

反模式三:无 Fallback 的组合

// 缺少降级逻辑——异常直接抛给调用方
Supplier<String> decorated = Decorators.ofSupplier(supplier)
    .withCircuitBreaker(circuitBreaker)
    .withRetry(retry)
    .decorate();

// 推荐:始终提供降级逻辑
Supplier<String> decorated = Decorators.ofSupplier(supplier)
    .withCircuitBreaker(circuitBreaker)
    .withRetry(retry)
    .withFallback(List.of(
            CallNotPermittedException.class,
            BulkheadFullException.class,
            IOException.class),
        throwable -> {
            log.warn("所有保护机制已触发,执行最终降级: {}", 
                throwable.getMessage());
            return defaultResponse();
        })
    .decorate();

Spring Cloud 注解的顺序

使用 Spring Cloud 注解时,装饰器的应用顺序由注解的处理优先级决定。Resilience4j Spring Boot Starter 的默认优先级是:

Retry → CircuitBreaker → RateLimiter → TimeLimiter → Bulkhead

这个顺序可以通过配置调整:

resilience4j:
  # 数值越小优先级越高(越外层)
  retry:
    retry-aspect-order: 2      # 最外层
  circuitbreaker:
    circuit-breaker-aspect-order: 1
  bulkhead:
    bulkhead-aspect-order: 0   # 最内层

十、工程案例:一次重试风暴事故复盘

背景

某电商平台在一次大促活动期间发生了持续约 45 分钟的全站故障。故障的根本原因不是流量超过了系统容量,而是一个下游服务的短暂抖动通过重试机制被放大,最终导致整个调用链雪崩。

系统架构

系统的核心调用链路为:

API 网关 → 订单服务 → 支付服务 → 银行通道服务
                    → 库存服务 → 仓储服务
                    → 风控服务 → 特征服务

所有服务都部署在 Kubernetes 集群上,每个服务有 10~50 个 Pod。服务间通信使用 HTTP,超时和重试配置如下:

调用关系 连接超时 读取超时 重试次数
网关 → 订单 2s 10s 0
订单 → 支付 2s 5s 3
订单 → 库存 2s 3s 3
订单 → 风控 2s 2s 2
支付 → 银行 2s 8s 3
库存 → 仓储 2s 3s 2
风控 → 特征 2s 1s 2

事故时间线

T+0(14:32:15)
银行通道服务因为对接的某家银行接口超时(银行侧维护),部分支付请求响应变慢,从正常的 200ms 升高到 5~8 秒。

T+2min(14:34)
支付服务对银行通道的调用开始大量超时(读取超时 8 秒)。由于配置了 3 次重试,每个支付请求实际产生 3 次对银行通道的调用。银行通道服务的入站 QPS 从 500 升高到 1500。

T+5min(14:37)
银行通道服务的线程池(200 个线程)被耗尽,开始拒绝连接。支付服务的重试全部失败,但因为重试本身消耗时间(3 次 * 8 秒超时 = 24 秒),支付服务的线程也开始积压。

T+8min(14:40)
订单服务对支付服务的调用开始超时(读取超时 5 秒)。订单服务也配置了 3 次重试。此时出现了双层重试放大:订单对支付重试 3 次,每次支付又对银行通道重试 3 次,单个订单请求最终产生 9 次银行通道调用。

同时,订单服务的线程被大量占用(每个请求最长占用 3 * 5 = 15 秒),订单服务开始响应缓慢。

T+12min(14:44)
订单服务的线程池耗尽。不仅支付相关的请求受影响,库存查询和风控查询也无法处理——因为它们共享同一个线程池(没有舱壁隔离)。

API 网关开始返回大量 502/504 错误。用户感知到下单功能完全不可用。

T+15min(14:47)
运维团队收到告警,开始排查。初步判断是银行通道问题,但发现银行通道服务虽然延迟高,但并没有完全不可用——是重试放大效应把它彻底打垮的。

T+25min(14:57)
运维团队通过配置中心关闭了支付服务和订单服务的重试。银行通道服务的入站 QPS 从 4500 降回到 500。银行通道服务开始恢复。

T+35min(15:07)
各服务的线程池逐步恢复,积压请求开始处理。但由于线程池恢复后瞬间承受了大量积压请求(雷群效应),又出现了一次短暂的抖动。

T+45min(15:17)
系统完全恢复正常。

根因分析

  1. 多层重试放大: 订单→支付→银行通道三层调用,每层 3 次重试,最坏情况下放大 27 倍。
  2. 缺少熔断器: 银行通道已经不可用了,但支付服务和订单服务仍在持续重试,没有快速失败机制。
  3. 缺少舱壁隔离: 支付服务的问题通过共享线程池扩散到了库存和风控功能。
  4. 重试配置不合理: 读取超时 8 秒 * 重试 3 次 = 单个请求最长占用线程 24 秒。
  5. 缺少总超时控制: 没有级联超时预算,各层的超时独立设置且总和远超用户可接受的等待时间。

修复措施

事故后的改进方案:

// 1. 引入 Resilience4j 熔断器
CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom()
    .slidingWindowSize(50)
    .failureRateThreshold(50)
    .slowCallRateThreshold(80)
    .slowCallDurationThreshold(Duration.ofSeconds(2))
    .waitDurationInOpenState(Duration.ofSeconds(30))
    .permittedNumberOfCallsInHalfOpenState(5)
    .build();

// 2. 引入舱壁隔离
BulkheadConfig bhConfig = BulkheadConfig.custom()
    .maxConcurrentCalls(30)  // 支付服务最多占用 30 个并发
    .maxWaitDuration(Duration.ofMillis(200))
    .build();

// 3. 只在最外层重试,中间层不重试
RetryConfig retryConfig = RetryConfig.custom()
    .maxAttempts(2)
    .intervalFunction(IntervalFunction.ofExponentialRandomBackoff(
        500, 2.0, 0.5))
    .retryOnException(e -> !(e instanceof CallNotPermittedException))
    .build();

// 4. 总超时控制
TimeLimiterConfig tlConfig = TimeLimiterConfig.custom()
    .timeoutDuration(Duration.ofSeconds(5))
    .cancelRunningFuture(true)
    .build();
# 5. 重新设计超时预算
# 用户请求 SLA: 3 秒
# 网关处理: 100ms
# 订单服务处理: 200ms
# 支付调用超时: 1200ms(含 1 次重试,每次 600ms)
# 库存调用超时: 800ms
# 风控调用超时: 500ms
# 预留缓冲: 200ms
resilience4j:
  circuitbreaker:
    instances:
      bankChannelService:
        sliding-window-size: 50
        failure-rate-threshold: 50
        wait-duration-in-open-state: 30s
      paymentService:
        sliding-window-size: 100
        failure-rate-threshold: 50
        wait-duration-in-open-state: 30s
  bulkhead:
    instances:
      paymentService:
        max-concurrent-calls: 30
        max-wait-duration: 200ms
      inventoryService:
        max-concurrent-calls: 20
        max-wait-duration: 200ms
      riskControlService:
        max-concurrent-calls: 15
        max-wait-duration: 200ms
  retry:
    instances:
      paymentService:
        max-attempts: 2
        wait-duration: 500ms
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 2
        retry-exceptions:
          - java.io.IOException
          - java.util.concurrent.TimeoutException
        ignore-exceptions:
          - io.github.resilience4j.circuitbreaker.CallNotPermittedException
          - io.github.resilience4j.bulkhead.BulkheadFullException

改进效果

改进后进行了故障注入测试。模拟银行通道服务 100% 超时的场景:

  • 熔断器在 10 秒内触发,支付服务对银行通道的调用被切断
  • 支付服务的 Fallback 逻辑返回”支付处理中”,引导用户稍后查询
  • 库存服务和风控服务完全不受影响(舱壁隔离有效)
  • 订单服务可以正常处理不涉及该银行通道的订单
  • 30 秒后熔断器进入半开状态,自动探测银行通道是否恢复

故障影响范围从”全站不可用 45 分钟”缩小为”部分银行支付不可用 10 秒”。


十一、弹性模式选型对比

模式对比表

模式 解决的问题 工作原理 适用条件 不适用场景 关键参数
熔断器(Circuit Breaker) 持续向不可用服务发送请求 失败率超阈值时切断调用 下游可能长时间不可用 偶发性单次失败 失败率阈值、窗口大小、等待时间
舱壁(Bulkhead) 一个慢依赖耗尽所有资源 为每个依赖分配独立资源池 多个下游依赖共存 只有单一下游依赖 线程数/并发数、队列大小
超时(Timeout) 线程被无响应的调用长期占用 设置调用时间上限 所有远程调用 无(所有调用都应设超时) 连接超时、读取超时
重试(Retry) 暂时性故障 失败后自动重新尝试 操作幂等且故障暂时 非幂等操作、持续性故障 最大次数、退避策略
指数退避(Backoff) 重试请求同时到达(雷群) 重试间隔指数增长 配合重试使用 对延迟敏感的场景 基础延迟、最大延迟
抖动(Jitter) 退避后仍然同步重试 在退避间隔上加随机偏移 大量客户端并发重试 单客户端场景(效果不明显) 抖动范围
限流(Rate Limiting) 入站流量超过服务处理能力 控制请求速率 入口流量控制 出口调用保护(应用熔断器) QPS 阈值、时间窗口
降级(Fallback) 主路径不可用 提供备选响应 有合理的备选方案 没有可接受的降级方案 降级策略

模式组合建议

根据不同场景,推荐以下组合方案:

基础方案(适用于大多数服务): - 超时 + 熔断器 + 降级 - 所有远程调用设置超时,加上熔断器防止持续调用不可用的服务,降级提供备选响应

标准方案(适用于有多个下游依赖的服务): - 超时 + 舱壁 + 熔断器 + 降级 - 在基础方案上加入舱壁隔离,防止一个依赖的故障影响其他依赖

完整方案(适用于核心链路): - 超时 + 舱壁 + 熔断器 + 重试(带指数退避和抖动) + 降级 - 完整的保护栈,适用于对可用性要求极高的核心链路

入口保护方案(适用于服务端自身保护): - 限流 + 系统自适应保护 + 降级 - 使用 Sentinel 等工具实现,保护服务自身不被过量请求压垮

常见误区

误区一:以为熔断器可以替代超时。 熔断器是在”发现下游不可用后”进行保护,在发现之前的那些请求仍然需要超时机制来限制等待时间。两者的作用层面不同。

误区二:以为重试可以解决所有故障。 重试只能解决暂时性故障。对于持续性故障,重试只会放大问题。必须配合熔断器使用。

误区三:舱壁的线程池/并发数设得很大。 舱壁设得过大等于没有舱壁。正确的做法是按照正常流量需求设定,故障时超出配额的请求快速失败。

误区四:忽略监控和告警。 弹性模式的效果需要通过监控来验证。如果熔断器频繁触发但没人知道,说明下游服务的稳定性存在系统性问题。


十二、总结

弹性设计模式不是”加了就安全”的银弹。每个模式都有自己的适用范围和配置陷阱。

熔断器的核心价值在于快速失败——在下游已经不可用时,避免继续浪费资源。但它需要合理的滑动窗口配置和异常分类,否则会误判。

舱壁的核心价值在于故障隔离——防止一个依赖的问题扩散到整个系统。线程池隔离和信号量隔离各有优劣,选择取决于下游服务的特征。

超时是最基础也最容易被忽略的保护。每次远程调用都必须设置超时,并且超时预算需要在整个调用链中统一规划。

重试必须与指数退避和抖动配合使用,且只在调用链的最外层配置,避免多层重试的指数放大效应。

这些模式的组合顺序很重要。推荐的装饰器顺序是 Retry → CircuitBreaker → RateLimiter → TimeLimiter → Bulkhead → 实际调用。错误的顺序会导致统计失真、保护失效或资源浪费。

最后,弹性设计模式只是完整系统弹性工程的一部分。与之配合的还有高可用设计模式中的冗余和故障转移机制,以及后续“过载保护”(待写)中的限流和降级策略。这些模式组合在一起,才能构建出真正具有弹性的分布式系统。


上一篇:【系统架构设计】高可用设计模式:冗余、故障转移与仲裁

下一篇:系统架构设计|过载保护:限流、降级与反压(待写)


参考资料

书籍

  • Michael T. Nygard, Release It! Design and Deploy Production-Ready Software, 2nd Edition, Pragmatic Bookshelf, 2018. 稳定性模式和反模式的权威参考,断路器(Circuit Breaker)、舱壁(Bulkhead)、超时(Timeout)等模式的概念最早在本书中被系统化总结。
  • Robert S. Hanmer, Patterns for Fault Tolerant Software, Wiley, 2007. 容错软件的设计模式集合,涵盖了错误检测、错误恢复、错误缓解三大类模式。
  • Sam Newman, Building Microservices: Designing Fine-Grained Systems, 2nd Edition, O’Reilly, 2021. 微服务架构中的弹性设计章节详细讨论了服务间通信中的超时、重试、熔断和舱壁模式。
  • Brian Goetz, Java Concurrency in Practice, Addison-Wesley, 2006. 线程池大小估算公式和并发编程最佳实践的经典参考。

论文与技术报告

  • Marc Brooker, “Timeouts, retries, and backoff with jitter”, AWS Architecture Blog, 2015. 指数退避与三种抖动策略(完全抖动、等量抖动、去相关抖动)的详细分析和性能对比。
  • Microsoft Azure Architecture Center, “Circuit Breaker pattern”, docs.microsoft.com. 熔断器模式的设计指南和实现建议。
  • Netflix, “Fault Tolerance in a High Volume, Distributed System”, Netflix Tech Blog, 2012. Netflix 在 Hystrix 之前关于故障容忍设计的经验总结。

开源项目文档

  • Resilience4j 官方文档, resilience4j.readme.io. Resilience4j 各模块的完整使用指南和配置参考。
  • Sentinel 官方文档, sentinelguard.io. Sentinel 的架构设计、使用指南和最佳实践。
  • Spring Cloud Circuit Breaker 官方文档, spring.io/projects/spring-cloud-circuitbreaker. Spring Cloud 对 Resilience4j 和 Sentinel 的集成指南。
  • Polly (.NET), github.com/App-vNext/Polly. .NET 生态中的弹性库,设计理念与 Resilience4j 类似,跨语言对比时有参考价值。

博客与在线资源

  • Martin Fowler, “CircuitBreaker”, martinfowler.com, 2014. 熔断器模式的经典介绍文章,简洁清晰地描述了三态状态机。
  • AWS, “Reliability Pillar - AWS Well-Architected Framework”, docs.aws.amazon.com. AWS 关于可靠性设计的最佳实践指南,包含重试策略、超时设计、熔断器使用的实践建议。
  • Google Cloud, “Retry pattern”, cloud.google.com/architecture. Google Cloud 关于重试模式的设计指南,包含幂等性、退避策略等内容。

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · architecture

【系统架构设计】混沌工程:主动验证系统的韧性

混沌工程不是随机破坏——它是一套严谨的实验方法论。本文从混沌工程的五条原则出发,拆解 Netflix 从 Chaos Monkey 到 Chaos Kong 的演进历程,对比 LitmusChaos、ChaosBlade、Chaos Mesh 等工具的架构差异,讲清楚故障注入的分类学和 GameDay 演练的落地流程。

2026-04-13 · architecture

【系统架构设计】Netflix 架构全景:混沌工程的诞生地

Netflix 在 2008 年经历了一次长达三天的数据库故障,导致 DVD 寄送业务全面瘫痪。这次事故促使团队做出了一个关键决策:放弃自建数据中心,全面迁移到亚马逊云服务(Amazon Web Services,AWS)。这一决策不仅重塑了 Netflix 的技术栈,还催生了混沌工程(Chaos Engineerin…

2026-04-22 · architecture / fintech

【金融科技工程】09 支付网关设计:路由、限流、补单、异步通知、签名与防重放

从业务系统到支付宝、微信、银联、Visa、Stripe、Airwallex,中间这一层"支付网关"承担了路由、限流、熔断、补单、签名、异步通知分发等几乎所有脏活累活。本文系统整理一个自研或半自研支付网关的工程设计,包括数据模型、双状态机、路由策略、密钥管理与可观测性。

2026-04-13 · architecture

【系统架构设计】什么是软件架构:从代码结构到系统决策

软件架构的定义至少有三种主流理解,每种指向不同的关注点。本文对比 Booch、Fowler、Johnson 三家定义,分析架构决策的不可逆性特征,拆解 Krutchten 4+1 视图模型,回答一个核心问题:架构和设计的边界到底在哪里。