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

【系统架构设计百科】弹性设计模式:熔断器、舱壁与超时

文章导航

分类入口
architecture
标签入口
#resilience#circuit-breaker#bulkhead#timeout#retry#Resilience4j#Sentinel

目录

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

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

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


一、一次重试引发的雪崩

重试放大效应

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

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

如果调用链有 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)。

滑动窗口大小(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)适合使用基于时间的窗口,避免窗口跨度过长。

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

监控指标

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

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();

线程池隔离的优点:

线程池隔离的缺点:

信号量隔离

信号量隔离(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();
    });

信号量隔离的优点:

信号量隔离的缺点:

线程池 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

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

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


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

为什么需要超时

没有超时的网络调用是危险的。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 秒:

指数退避减少了对下游的压力(间隔越来越长),但仍然没有解决雷群问题——如果 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 由以下核心模块组成:

模块 功能 典型场景
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 → ...

滑动窗口统计

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 的流量控制支持三种模式:

熔断降级配置

// 熔断降级规则
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 的熔断策略支持三种:

系统自适应保护

这是 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% 超时的场景:

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


十一、弹性模式选型对比

模式对比表

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

模式组合建议

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

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

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

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

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

常见误区

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

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

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

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


十二、总结

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

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

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

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

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

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

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


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

下一篇:【系统架构设计百科】过载保护:限流、降级与反压


参考资料

书籍

论文与技术报告

开源项目文档

博客与在线资源

同主题继续阅读

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

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-13 · architecture

【系统架构设计百科】架构质量属性:不只是"高可用高性能"

需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。

2026-04-13 · architecture

【系统架构设计百科】告警策略:如何避免"狼来了"

大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。


By .