分布式系统中,服务之间通过网络调用产生依赖关系。网络调用不可靠——延迟抖动、连接超时、服务不可用随时可能发生。当一个下游服务出现故障时,上游服务如果不加任何保护措施,最直觉的反应是”重试”。但重试本身如果设计不当,非但不能解决问题,反而会把一个局部故障放大为全局雪崩。
弹性设计模式(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 打挂,形成反复震荡。
一个典型的场景:缓存服务器重启后,所有请求穿透到数据库,数据库过载后超时,上游开始重试,重试进一步放大了对数据库的压力,数据库恢复后瞬间承受了数倍于正常的流量,再次崩溃。这个循环可以持续很长时间,直到人工介入。
重试的正确使用条件
重试不是不能用,而是需要满足以下条件:
- 操作是幂等的。 非幂等操作(如扣款、发送消息)重试可能导致重复执行。
- 故障是暂时的。 如果下游是持续性故障(如配置错误、硬件损坏),重试只会浪费资源。
- 重试次数有上限。 无限重试会持续消耗资源。
- 重试间隔有退避策略。 固定间隔重试在高并发下会形成雷群效应。
- 只在调用链的最外层重试。 中间层的重试会产生指数放大效应。
违反其中任何一条都可能导致问题。而熔断器、舱壁、超时等模式正是为了在违反这些条件时提供安全网。
二、熔断器:状态机设计
核心思想
熔断器的灵感来自电气工程中的断路器:当电路中的电流超过安全阈值时,断路器自动断开电路,防止过载损坏设备。在软件系统中,熔断器监控对下游服务的调用结果,当失败率达到阈值时自动切断调用,避免持续向已经不可用的服务发送请求。
熔断器解决的核心问题是:当下游服务已经不可用时,继续向它发送请求是没有意义的,这些请求只会消耗上游的线程、连接等资源,同时给下游增加额外的恢复压力。不如快速失败(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 的三大核心功能:
- 流量控制(Flow Control): 控制入站请求的速率,防止服务被过量请求压垮
- 熔断降级(Circuit Breaking): 对不稳定的下游依赖进行熔断,快速失败
- 系统自适应保护(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();这个顺序的逻辑:
Retry 在最外层: 能够感知到所有内层装饰器的异常(包括 CircuitBreaker 的 CallNotPermittedException、Bulkhead 的 BulkheadFullException),可以统一决定是否重试以及是否需要对特定异常不重试。
CircuitBreaker 在 Retry 内层: 每次实际调用(不论是首次还是重试)都经过 CircuitBreaker,CircuitBreaker 能够准确统计失败率。
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)
系统完全恢复正常。
根因分析
- 多层重试放大: 订单→支付→银行通道三层调用,每层 3 次重试,最坏情况下放大 27 倍。
- 缺少熔断器: 银行通道已经不可用了,但支付服务和订单服务仍在持续重试,没有快速失败机制。
- 缺少舱壁隔离: 支付服务的问题通过共享线程池扩散到了库存和风控功能。
- 重试配置不合理: 读取超时 8 秒 * 重试 3 次 = 单个请求最长占用线程 24 秒。
- 缺少总超时控制: 没有级联超时预算,各层的超时独立设置且总和远超用户可接受的等待时间。
修复措施
事故后的改进方案:
// 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 关于重试模式的设计指南,包含幂等性、退避策略等内容。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【系统架构设计百科】混沌工程:主动验证系统的韧性
混沌工程不是随机破坏——它是一套严谨的实验方法论。本文从混沌工程的五条原则出发,拆解 Netflix 从 Chaos Monkey 到 Chaos Kong 的演进历程,对比 LitmusChaos、ChaosBlade、Chaos Mesh 等工具的架构差异,讲清楚故障注入的分类学和 GameDay 演练的落地流程。
【系统架构设计百科】Netflix 架构全景:混沌工程的诞生地
Netflix 在 2008 年经历了一次长达三天的数据库故障,导致 DVD 寄送业务全面瘫痪。这次事故促使团队做出了一个关键决策:放弃自建数据中心,全面迁移到亚马逊云服务(Amazon Web Services,AWS)。这一决策不仅重塑了 Netflix 的技术栈,还催生了混沌工程(Chaos Engineerin…
【系统架构设计百科】架构质量属性:不只是"高可用高性能"
需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。
【系统架构设计百科】告警策略:如何避免"狼来了"
大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。