Resilience4j 实战指南:从熔断降级到生产级容错体系
Resilience4j 实战指南:从熔断降级到生产级容错体系
一个下游接口抖了一下,整条链路跟着雪崩——这是微服务里最常见、也最贵的故障模式。
本文不堆 API 文档,而是把 Resilience4j 拆成"为什么需要 → 每个模块怎么用 → 组合顺序怎么排 → 生产上怎么配"四层来讲,最后给一套可以照着落地的订单服务示例。基线:Spring Boot 3.x + Resilience4j 2.2.x。2.x 有两个变化需要注意:移除了 Vavr 依赖(网上老教程里的
Try.ofSupplier(...)写法在 2.x 里已经不存在),Spring 集成模块拆到了resilience4j-spring6(Boot 3 用resilience4j-spring-boot3starter 引入)。
目录
- 一、为什么需要容错
- 二、Resilience4j 是什么
- 三、六大核心模块总览
- 四、五分钟上手
- 五、CircuitBreaker:熔断器
- 六、Retry:重试
- 七、Bulkhead:舱壁隔离
- 八、RateLimiter:限流器
- 九、TimeLimiter:超时控制
- 十、Cache:结果缓存
- 十一、组合与执行顺序(最容易踩坑的地方)
- 十二、Spring Boot 集成
- 十三、完整实战:订单服务
- 十四、十二个常见坑与解法
- 十五、生产环境实践清单
- 十六、参数速查表
- 十七、总结
一、为什么需要容错
先看一个真实会发生的场景。订单服务下单要依次调用三个下游:
OrderService.create()
├─ 库存服务 InventoryService.deduct() P99 300ms
├─ 支付服务 PaymentService.pay() P99 500ms
└─ 积分服务 PointsService.add() P99 200ms
某天积分服务的数据库慢查询,响应时间从 200ms 涨到 30s。
没有容错时的推演:
| 时刻 | 发生了什么 |
|---|---|
| T+0s | 积分服务变慢,Tomcat 工作线程开始堆积在等待响应 |
| T+30s | 订单服务 200 个线程全部阻塞,新请求进不来,线程池耗尽 |
| T+35s | 网关调用订单服务超时 → 网关线程也开始堆积 |
| T+40s | 上游全部超时,用户看到 502/504;监控上一条链路全线飘红 |
| T+2min | 即使积分服务恢复,订单服务仍在处理积压的僵尸请求,恢复比故障慢十倍 |
这就是级联失败(cascading failure)。注意关键点:故障源是积分服务,但死掉的是订单服务、网关、甚至整个站点。故障会沿着调用链放大。
容错的本质不是"让调用成功",而是把失败限制在一个可控的范围内,做到四件事:
- 快速失败——下游已经挂了,别再浪费时间等它,立刻返回兜底结果(熔断)。
- 不放大流量——下游只是慢,别用重试把它彻底打死(限流 + 退避)。
- 隔离爆炸半径——积分服务挂了不影响库存/支付调用(舱壁)。
- 资源不泄漏——慢调用不能无限占用线程(超时)。
Resilience4j 就是把这四件事做成可组合的库级能力。
二、Resilience4j 是什么
2.1 定位
Resilience4j 是一个轻量级的容错库(fault tolerance library),不是流量治理平台。
它的设计哲学很明确:
- 函数式:核心 API 是
Supplier/Function/CompletionStage的装饰器(Decorator),不依赖任何框架; - 无侵入:不启动线程、不扫描类路径、不修改字节码,纯 JDK 对象;
- 模块化:熔断、重试、限流、舱壁、超时、缓存各自独立成 jar,要哪个引哪个;
- 可组合:多个能力可以叠加在同一调用上,顺序可自定义;
- 面向失败设计:默认值都偏保守,鼓励你显式配置阈值。
2.0 版本的一个重要变化:移除了 Vavr 依赖,早期的 Try<T>、io.vavr.collection 全部换成 JDK 原生类型。如果你看到网上 1.x 的老教程里写 Vavr.decorateSupplier(...),在 2.x 里已经不存在了。
2.2 与 Hystrix、Sentinel 的对比
| 维度 | Resilience4j | Hystrix | Sentinel |
|---|---|---|---|
| 维护状态 | 活跃 | 2018 年起停止新功能(维护模式) | 活跃(阿里) |
| 依赖模型 | 库,纯装饰器 | 库,基于命令模式 + 线程池 | 库 + 独立控制台 |
| 线程隔离 | 可选(线程池舱壁) | 强制(或信号量) | 不做线程隔离,做并发/RT 控制 |
| 规则配置 | 代码 / 配置文件 / 注册中心 | 代码 + Archaius | 控制台动态推送、持久化 |
| 动态调整 | 需自行接入配置中心 | 弱 | 强(秒级生效) |
| 功能广度 | 熔断、重试、限流、舱壁、超时、缓存 | 熔断、舱壁、降级 | 限流、熔断、系统自适应保护、热点参数、集群流控 |
| 适用场景 | Spring Boot 微服务标准方案 | 历史项目 | 需要流量治理中台、精确 TPS 控制 |
怎么选:
- 一般 Spring Cloud 微服务 → Resilience4j。Spring Cloud Circuit Breaker 的默认实现就是它,Spring Cloud OpenFeign、Gateway 都原生集成。
- 需要运营控制台、动态规则、集群限流、热点参数控制 → Sentinel。
- 两者不冲突,可以叠加:Resilience4j 管调用级容错,Sentinel 管入口流量。
三、六大核心模块总览
| 模块 | 解决的痛点 | 核心类 | 触发异常 |
|---|---|---|---|
resilience4j-circuitbreaker |
下游持续失败还在打,越打越死 | CircuitBreaker |
CallNotPermittedException |
resilience4j-retry |
偶发失败(网络抖动、GC、主从切换) | Retry |
MaxRetriesExceededException |
resilience4j-bulkhead |
一个慢依赖占满所有线程 | Bulkhead / ThreadPoolBulkhead |
BulkheadFullException |
resilience4j-ratelimiter |
超出下游配额被封禁/计费爆炸 | RateLimiter |
RequestNotPermitted |
resilience4j-timelimiter |
慢调用无限等待 | TimeLimiter |
TimeoutException |
resilience4j-cache |
重复读热点数据 | Cache(JSR-107) |
— |
一个心智模型:
┌─────────────────────────────────────────────┐
│ 准不准过? RateLimiter (流量整形) │
│ 要不要打? CircuitBreaker(快速失败) │
│ 打几次? Retry (重试补偿) │
│ 占多少资源? Bulkhead (资源隔离) │
│ 等多久? TimeLimiter (时间边界) │
│ 读不读缓存? Cache (结果复用) │
└─────────────────────────────────────────────┘
四、五分钟上手
4.1 依赖
Spring Boot 3:
<!-- 一站式:自动配置 + 全部注解切面 -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
<!-- 注解是基于 AOP 实现的,必须显式引入 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- 指标上报(可选) -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-micrometer</artifactId>
<version>2.2.0</version>
</dependency>
Spring Boot 2.x 项目请用
resilience4j-spring-boot2,spring-boot3版本依赖 Spring Framework 6,在 Boot 2 下会因包路径org.springframework.boot.autoconfigure.AutoConfiguration.imports不兼容而静默失效——自动配置类根本没被加载,注解全都不生效。这是从 Boot 2 升到 3 时最常见的坑之一。如果你只想要库、不想让 Spring 管,那引最细粒度的模块即可,例如
resilience4j-circuitbreaker+resilience4j-retry。
4.2 最小可运行示例
@Service
@Slf4j
public class UserService {
// 模拟一个不稳定的下游
private final Random random = new Random();
@CircuitBreaker(name = "userBackend", fallbackMethod = "getUserFallback")
public String getUser(Long id) {
if (random.nextInt(10) < 6) { // 60% 失败率
throw new RuntimeException("downstream timeout");
}
return "user-" + id;
}
// fallback:方法签名 = 原方法参数 + 一个异常参数
private String getUserFallback(Long id, Throwable t) {
log.warn("fallback triggered for id={}, cause={}", id, t.toString());
return "cached-user-" + id; // 兜底数据
}
}
一行配置都不用写也能跑——Resilience4j 会用 default 配置创建实例。但生产上一定要显式配置,后面细说。
4.3 最重要的配置文件
resilience4j:
circuitbreaker:
configs:
default:
slidingWindowSize: 20
minimumNumberOfCalls: 10
failureRateThreshold: 50
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 5
configs.default 是所有未显式配置的实例继承的默认值。注意 configs 和 instances 是两个不同层级:
configs.<name>:定义一套可复用的配置模板;instances.<name>:定义一个具体实例,可用baseConfig继承模板,再覆盖个别字段。
resilience4j:
circuitbreaker:
configs:
default:
slidingWindowSize: 20
failureRateThreshold: 50
instances:
inventory:
baseConfig: default # 继承 default
slidingWindowSize: 50 # 覆盖这一项
payment:
baseConfig: default
failureRateThreshold: 40
waitDurationInOpenState: 30s
这套"模板 + 实例"机制很关键:配置要按依赖拆分(每个下游一个实例),而不是全局一套。库存服务和第三方支付的最优参数完全不同。
五、CircuitBreaker:熔断器
5.1 状态机
熔断器是 Resilience4j 里最核心也最容易配错的模块。它的本质是一个带记忆的开关:
失败率 >= 阈值
┌──────────┐ ───────────────► ┌──────────┐
│ CLOSED │ │ OPEN │
│ 正常放行 │ ◄─────────────── │ 直接拒绝 │
└──────────┘ 调用成功 └──────────┘
▲ │
│ 等待 waitDurationInOpenState
│ ▼
│ 失败率 < 阈值 ┌───────────┐
└────────────────────────── │ HALF_OPEN │
│ 试探放行 │
└───────────┘
│ 失败率 >= 阈值
└──────► OPEN
| 状态 | 行为 | 进入条件 |
|---|---|---|
CLOSED |
放行所有调用,统计成功/失败/慢调用 | 初始状态;HALF_OPEN 试探成功 |
OPEN |
直接抛 CallNotPermittedException,不发起真实调用 |
失败率或慢调用率达到阈值(且样本数够) |
HALF_OPEN |
放行 permittedNumberOfCallsInHalfOpenState 个试探调用,其余拒绝 |
OPEN 状态等待 waitDurationInOpenState 后 |
DISABLED |
永远放行,不统计 | 手动 transitionToDisabledState() |
FORCED_OPEN |
永远拒绝,不统计 | 手动 transitionToForcedOpenState() |
METRICS_ONLY |
统计但永不拒绝(灰度期观察用) | 手动切换 |
METRICS_ONLY 是个被严重低估的状态。新上线一个熔断配置时,先用它跑一周看指标,确认阈值不会误伤,再切到正常模式——这是最安全的调参方式,比压测还准。
5.2 滑动窗口:COUNT_BASED vs TIME_BASED
熔断判定基于"最近一段时间内"的调用结果,这个"最近"由滑动窗口定义:
COUNT_BASED(按次数)——最近 N 次调用。
slidingWindowSize = 10
调用序列: ✓ ✓ ✗ ✓ ✗ ✗ ✓ ✓ ✗ ✗
└──────── 窗口内 10 次 ────────┘
失败率 = 5/10 = 50%
TIME_BASED(按时间)——最近 N 秒内的调用,内部再切成若干桶。
slidingWindowSize = 60(秒)
自动切成 60 个 1 秒的桶,只统计这 60 秒内的调用
| COUNT_BASED | TIME_BASED | |
|---|---|---|
| 适合 | QPS 稳定、调用量可预期 | QPS 波动大、低流量服务 |
| 风险 | 低流量时窗口"记太久",故障恢复后仍被旧数据拖住 | 内存略大,桶数 = 窗口秒数 |
| 默认 | slidingWindowSize = 100 |
slidingWindowSize = 60 |
实践建议:低流量接口(每分钟几次)用 COUNT_BASED 且窗口别超过 20,否则窗口里凑不够 minimumNumberOfCalls,熔断永远不生效。
5.3 核心参数
| 参数 | 默认值 | 含义 | 调参建议 |
|---|---|---|---|
slidingWindowType |
COUNT_BASED |
窗口类型 | 低流量选 COUNT |
slidingWindowSize |
100 | 窗口大小(次数或秒) | 10~50 次 / 30~60 秒 |
minimumNumberOfCalls |
100 | 窗口内至少这么多次调用才判定 | 必须小于窗口大小,否则永不触发 |
failureRateThreshold |
50 | 失败率阈值(%) | 30~60,核心链路调低 |
slowCallRateThreshold |
100 | 慢调用比例阈值(%) | 建议 50~80,别留默认 100 |
slowCallDurationThreshold |
60s | 多慢算"慢" | 设为下游 P99 的 2~3 倍 |
waitDurationInOpenState |
60s | OPEN 停留时长 | 5~30s,太长恢复慢 |
permittedNumberOfCallsInHalfOpenState |
10 | 半开试探次数 | 3~10 |
automaticTransitionFromOpenToHalfOpenEnabled |
false | 是否自动转半开 | 建议 true,否则要等下一次调用才转 |
recordExceptions |
空(记录全部) | 记为失败的异常 | 只列可重试类异常 |
ignoreExceptions |
空 | 不算失败的异常 | 业务异常、参数校验异常 |
recordException |
— | 自定义判定谓词 | 需要按状态码判定时用 |
registerHealthIndicator |
false | 注册健康检查 | 建议 true |
三个高频配错点:
minimumNumberOfCalls≥slidingWindowSize→ 窗口永远填不满,熔断器是个摆设。slowCallRateThreshold留默认 100 → 只要有一个慢调用,慢调用率就是 100%?不,是"慢调用率阈值 100%"意味着永远不会因慢调用熔断。想做慢调用保护就必须显式调低。waitDurationInOpenState太长 → 下游 3 秒就恢复了,你还拒绝 60 秒的流量。
5.4 代码示例
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
.slidingWindowSize(20)
.minimumNumberOfCalls(10)
.failureRateThreshold(50)
.slowCallRateThreshold(80)
.slowCallDurationThreshold(Duration.ofMillis(800))
.waitDurationInOpenState(Duration.ofSeconds(10))
.permittedNumberOfCallsInHalfOpenState(5)
.automaticTransitionFromOpenToHalfOpenEnabled(true)
// 只把"基础设施类"异常算失败
.recordExceptions(IOException.class,
TimeoutException.class,
ResourceAccessException.class)
// 业务异常不算下游故障,不能触发熔断
.ignoreExceptions(BusinessException.class, IllegalArgumentException.class)
.build();
CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(config);
CircuitBreaker cb = registry.circuitBreaker("inventory");
// 方式 A:只装饰 Supplier(最简)
Supplier<DeductResult> decorated =
CircuitBreaker.decorateSupplier(cb, () -> inventoryClient.deduct(cmd));
DeductResult result;
try {
result = decorated.get();
} catch (CallNotPermittedException e) {
// 熔断器处于 OPEN:这里没有发起真实调用,直接走兜底
result = DeductResult.rejected();
}
// 方式 B:读取运行时统计(排障时非常有用)
CircuitBreaker.Metrics m = cb.getMetrics();
log.info("failureRate={}% slowCallRate={}% bufferedCalls={} failedCalls={}",
m.getFailureRate(), m.getSlowCallRate(),
m.getNumberOfBufferedCalls(), m.getNumberOfFailedCalls());
如果你在网上看到
Try.ofSupplier(...).recover(...)这种写法——那是 1.x 时代依赖 Vavr 的 API,2.x 已经移除了,替换成上面的标准try/catch或第 11 节的Decorators组合。
5.5 事件监听:接入监控与告警
熔断器是"沉默的"——它打开了不会主动喊。必须挂事件监听,否则线上你只知道"有降级",不知道"为什么降级"。
@PostConstruct
public void registerListener() {
circuitBreaker.getEventPublisher()
.onSuccess(e -> metrics.counter("cb.success", "name", e.getCircuitBreakerName()).increment())
.onError(e -> log.warn("CB[{}] error after {}ms: {}",
e.getCircuitBreakerName(),
e.getElapsedDuration().toMillis(),
e.getThrowable().toString()))
.onStateTransition(e -> {
var t = e.getStateTransition();
log.error("CB[{}] {} -> {}", e.getCircuitBreakerName(),
t.getFromState(), t.getToState());
if (t.getToState() == CircuitBreaker.State.OPEN) {
alert.fire("熔断器打开: " + e.getCircuitBreakerName());
}
})
.onCallNotPermitted(e -> log.debug("CB[{}] 拒绝调用", e.getCircuitBreakerName()))
.onSlowCallRateExceeded(e -> log.warn("CB[{}] 慢调用率超阈值: {}%",
e.getCircuitBreakerName(), e.getSlowCallRate()));
}
可用事件类型:SUCCESS、ERROR、IGNORED_ERROR、NOT_PERMITTED、STATE_TRANSITION、RESET。
告警必须盯的两个事件:STATE_TRANSITION → OPEN(下游真出事了)和 NOT_PERMITTED 的速率(正在流失多少请求)。
六、Retry:重试
6.1 语义
maxAttempts = 3 表示总共尝试 3 次 = 1 次初始调用 + 2 次重试。这是最容易数错的地方。
重试只解决瞬时故障:网络闪断、连接重置、主从切换的几秒不可用、GC 停顿。它对持续性故障毫无作用,反而会把下游打死——所以重试必须配退避(backoff)和次数上限。
6.2 参数
| 参数 | 默认值 | 含义 |
|---|---|---|
maxAttempts |
3 | 总尝试次数(含首次) |
waitDuration |
500ms | 固定间隔 |
intervalFunction |
— | 自定义间隔函数(指数退避等) |
retryExceptions |
空 | 需要重试的异常 |
ignoreExceptions |
空 | 明确不重试的异常 |
retryOnResult |
— | 按返回值决定是否重试 |
failAfterMaxAttempts |
false | 达上限时抛 MaxRetriesExceededException |
6.3 退避策略
固定间隔在故障时等于"定期均匀打击",最容易被下游判定为攻击。用指数退避 + 随机抖动:
RetryConfig config = RetryConfig.custom()
.maxAttempts(4)
// 初始 100ms,每次 ×2,最多 3s,加 ±30% 随机抖动
.intervalFunction(IntervalFunction.ofExponentialRandomBackoff(
Duration.ofMillis(100), // initialInterval
2.0, // multiplier
0.3, // randomizationFactor
Duration.ofSeconds(3))) // maxInterval
.retryExceptions(IOException.class, TimeoutException.class,
SocketException.class)
.ignoreExceptions(BusinessException.class, IllegalArgumentException.class)
.build();
RetryRegistry registry = RetryRegistry.of(config);
Retry retry = registry.retry("inventory");
退避的效果对比(4 次尝试):
固定 500ms: 调用 ─500─ 调用 ─500─ 调用 ─500─ 调用 总计 1.5s
指数退避: 调用 ─100─ 调用 ─200─ 调用 ─400─ 调用 总计 0.7s
指数+抖动: 调用 ─82── 调用 ─245─ 调用 ─377─ 调用 总计 0.7s(且错峰)
抖动(jitter)是必须的。没有抖动,一万个客户端会在同一毫秒同时重试,形成"重试风暴"——这是分布式系统里经典的自我 DDoS。
6.4 幂等性红线
重试的前提是幂等。 这句话说起来容易,踩坑的都是钱:
// ❌ 危险:支付接口重试,可能造成重复扣款
@Retry(name = "payment")
public PayResult pay(String orderId, BigDecimal amount) {
return paymentClient.charge(orderId, amount);
}
// ✅ 正确:幂等键 + 服务端去重
@Retry(name = "payment")
public PayResult pay(String orderId, BigDecimal amount) {
// requestId 由业务生成并持久化,服务端按 requestId 去重
return paymentClient.charge(new ChargeRequest(orderId, amount, orderId));
}
判断标准:
- 查询类(GET)→ 天然幂等,放心重试;
- 删除类(按主键 DELETE)→ 幂等,可重试;
- 创建类(POST)→ 需要业务幂等键;
- 扣减类(库存、余额)→ 需要幂等键 + 版本号/乐观锁。
非幂等接口的正确做法:不重试,改成"失败转异步对账"。
6.5 按返回值重试
有时下游不抛异常,而是返回一个"软失败"(code != 0)。这时用 retryOnResult:
RetryConfig config = RetryConfig.<ApiResponse>custom()
.maxAttempts(3)
.retryOnResult(resp -> resp.getCode() == 503 || resp.getCode() == 429)
.build();
七、Bulkhead:舱壁隔离
7.1 为什么需要它
"舱壁"这个词来自船舶设计:船体被隔成多个水密舱,一个舱进水不会沉船。
对应到服务里:积分服务慢了,不能把调用库存、支付的线程也占满。
7.2 两种实现
| SemaphoreBulkhead(信号量) | ThreadPoolBulkhead(线程池) | |
|---|---|---|
| 原理 | 限制并发调用数,超了直接拒绝 | 独立线程池执行,与调用方线程隔离 |
| 开销 | 极低(一个计数器) | 高(线程切换、上下文丢失) |
| 能否中断慢调用 | ❌ 不能(调用方线程仍被占用) | ✅ 可以(超时后丢弃任务) |
| 返回类型 | 任意 | 必须是 CompletionStage / CompletableFuture |
| 是否传递 ThreadLocal | ✅ 传递 | ❌ 不传递(链路 traceId、事务上下文会丢) |
| 适用 | 大多数场景(首选) | 需要硬隔离、需要真正中断的场景 |
默认选信号量。线程池隔离的代价被严重低估了:MDC 里的 traceId 丢了、@Transactional 不生效了、SecurityContext 拿不到了——这些坑排查起来要命。
7.3 参数
SemaphoreBulkhead:
| 参数 | 默认值 | 含义 |
|---|---|---|
maxConcurrentCalls |
25 | 最大并发数 |
maxWaitDuration |
0 | 获取许可的最长等待(0 = 立即失败) |
resilience4j:
bulkhead:
instances:
inventory:
maxConcurrentCalls: 20
maxWaitDuration: 10ms
ThreadPoolBulkhead:
| 参数 | 默认值 | 含义 |
|---|---|---|
maxThreadPoolSize |
CPU 核数 | 最大线程数 |
coreThreadPoolSize |
max - 1 | 核心线程数 |
queueCapacity |
100 | 队列容量 |
keepAliveDuration |
20ms | 空闲线程存活 |
maxWaitDuration |
0 | 队列满时的等待时长 |
resilience4j:
thread-pool-bulkhead:
instances:
payment:
maxThreadPoolSize: 8
coreThreadPoolSize: 8
queueCapacity: 32
keepAliveDuration: 20ms
7.4 容量怎么算
maxConcurrentCalls 不是拍脑袋定的,用 Little’s Law:
并发数 = QPS × 平均响应时间
下游配额是 200 QPS、P99 响应 100ms → 需要 200 × 0.1 = 20 个并发。留 30% 余量 → 设 26。
线程池版还要考虑队列:队列长度 = QPS × 可接受排队时间。队列太大会掩盖问题(请求都在排队,用户等 30 秒才失败),建议 queueCapacity 不超过 2 秒的流量。
八、RateLimiter:限流器
8.1 模型:固定刷新周期
Resilience4j 的限流器不是令牌桶,而是"固定刷新周期的原子计数器":
limitForPeriod = 5, limitRefreshPeriod = 1s
第 1 秒: [5 个许可] → 5 个请求通过,第 6 个等待或拒绝
第 2 秒: [重新填满 5 个]
它的行为特征:
- 在一个刷新周期内,许可只减不增;
- 周期边界处瞬间充满(不是匀速补充);
- 因此它是"每周期不超过 N 次"的语义,而不是"平滑 TPS"。
这意味着流量可能集中在周期头部爆发:
周期 1s,5 个许可:
t=0.00 ── 5 个请求全部通过 ── 然后 950ms 全部拒绝 ── t=1.00 恢复
对 HTTP API 的 QPS 限额这完全够用;但对必须平滑输出的场景(如对接银行的报文节流),需要自己做小周期拆分(如 limitForPeriod=1, limitRefreshPeriod=200ms 模拟 5 TPS)。
2.x 提供了 AtomicRateLimiter(默认,无锁 CAS)和 SemaphoreBasedRateLimiter(基于信号量,可结合 limitRefreshPeriod 做更细控制)。
8.2 参数
| 参数 | 默认值 | 含义 |
|---|---|---|
limitForPeriod |
50 | 每个刷新周期的许可数 |
limitRefreshPeriod |
500ns | 刷新周期 |
timeoutDuration |
5s | 拿不到许可时的最长等待(0 = 立即失败) |
resilience4j:
ratelimiter:
instances:
partnerApi:
limitForPeriod: 100
limitRefreshPeriod: 1s
timeoutDuration: 0 # 拿不到就立刻降级,别排队
timeoutDuration 的建议:入口限流设 0(快速失败,保护自己);出口限流(调用第三方)可设一个小值(如 100ms)平滑突发。设成 5 秒默认值最糟糕——请求会堆在限流器里等,最后全都超时。
8.3 RateLimiter vs Bulkhead
两个经常被搞混:
| RateLimiter | Bulkhead | |
|---|---|---|
| 限制什么 | 单位时间内的调用次数(速率) | 同时进行的调用数(并发) |
| 拒绝的时机 | 周期配额用完 | 并发数满 |
| 保护对象 | 下游的配额/计费/公平性 | 本服务的线程/内存资源 |
| 例子 | 第三方 API 每天 1 万次 | 数据库连接池 20 个 |
一句话:限流防超额,舱壁防耗尽。两者要一起用。
九、TimeLimiter:超时控制
9.1 只对异步生效
这是 Resilience4j 最反直觉的一点:TimeLimiter 只作用于异步调用(CompletableFuture / CompletionStage / Reactor 类型)。给同步方法加 @TimeLimiter 不会生效——切面拿不到可中断的控制权。
同步调用的超时,应该在 HTTP 客户端层解决:
RestTemplate→SimpleClientHttpRequestFactory.setConnectTimeout / setReadTimeoutWebClient→ Reactor Netty 的responseTimeoutFeign→feign.client.config.default.connectTimeout / readTimeout- Socket →
setSoTimeout
TimeLimiter 的价值在于:围住一段异步逻辑的总耗时,超时后可以取消正在跑的任务。
9.2 参数与用法
| 参数 | 默认值 | 含义 |
|---|---|---|
timeoutDuration |
1s | 超时时长 |
cancelRunningFuture |
true | 超时后是否中断执行中的任务 |
TimeLimiterConfig config = TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofSeconds(2))
.cancelRunningFuture(true)
.build();
TimeLimiter timeLimiter = TimeLimiter.of(config);
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(4);
CompletableFuture<DeductResult> future =
timeLimiter.executeCompletionStage(scheduler,
() -> CompletableFuture.supplyAsync(() -> inventoryClient.deduct(cmd), bizExecutor))
.toCompletableFuture();
future.whenComplete((r, ex) -> {
if (ex instanceof TimeoutException) {
log.warn("调用超时,已取消");
}
});
Spring 注解形式(方法必须返回 CompletableFuture):
@TimeLimiter(name = "inventory")
@CircuitBreaker(name = "inventory")
public CompletableFuture<DeductResult> deductAsync(DeductCommand cmd) {
return CompletableFuture.supplyAsync(() -> inventoryClient.deduct(cmd), executor);
}
注意这里的组合关系:TimeLimiter 在外、CircuitBreaker 在内,超时会被熔断器记为失败——这正是我们想要的(下游慢到超时,就应该算它不可用)。
十、Cache:结果缓存
resilience4j-cache 基于 JSR-107(JCache),需要额外的缓存实现(Caffeine、Ehcache、Hazelcast):
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-cache</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>jcache</artifactId>
<version>3.1.8</version>
</dependency>
javax.cache.Cache<String, String> cache = cacheManager.getCache("users");
Supplier<String> cached = Cache.decorateSupplier(Cache.of(cache), () -> loadFromDb(id));
说实话,Spring 项目基本用不到这个模块——spring-boot-starter-cache + @Cacheable 功能更全、生态更好。Resilience4j 的 Cache 只在"不用 Spring 的纯 Java 项目"或"需要和其它装饰器在同一处组合"时才有价值。
十一、组合与执行顺序(最容易踩坑的地方)
11.1 官方推荐顺序
Resilience4j 官方文档给出的推荐装饰顺序是:
Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( 业务方法 ) ) ) ) )
↑ 最外层 最内层 ↑
也就是说:Retry 最外层,Bulkhead 最内层。
Spring 注解的默认切面顺序也是这个(@Order 从小到大依次进入):
| 顺序 | 切面 | 语义 |
|---|---|---|
| 1 | RetryAspect |
重试整个"熔断器 + 下面的所有东西" |
| 2 | CircuitBreakerAspect |
每次真实尝试都独立统计 |
| 3 | RateLimiterAspect |
每次尝试都消耗一个许可 |
| 4 | TimeLimiterAspect |
每次尝试独立计时 |
| 5 | BulkheadAspect |
每次尝试独立占用并发位 |
11.2 为什么是这个顺序
这四个"为什么"能帮你理解一切:
① 为什么 Retry 在最外层?
如果 Retry 在熔断器内层,一次业务调用 = 1 条熔断记录,但内部偷偷打了 3 次下游——熔断器看不到真实的下游压力,它以为下游很健康(因为最终成功了),实际下游已经被重试打穿。
Retry 在外层,则每次重试都是一次独立的熔断调用记录,下游失败的真相被如实统计。
② 为什么 RateLimiter 在 TimeLimiter 外面?
限流是"准入决策",要在消耗任何实际资源之前完成。如果限流在最内层,外层的舱壁已经占好了并发位、超时已经启动了计时器,才发现"配额用完了"——白白浪费资源。
③ 为什么 TimeLimiter 在 Bulkhead 外面?
超时是整段逻辑的边界。TimeLimiter 包住 Bulkhead 意味着:“连同在舱壁里排队的时间,总共 2 秒”。反过来(超时在内层)的话,请求可能在舱壁队列里等 10 秒都没人管——舱壁队列本身就变成了黑洞。
④ 为什么 Bulkhead 最内层?
舱壁保护的是"最真实的资源占用"。它应该紧贴业务方法,只统计真正在执行的调用。放在外层会把排队、重试、限流等待的时间都算进并发里,导致并发数虚高、过早拒绝。
11.3 一个反面案例
假设你把顺序配反了:Bulkhead → RateLimiter → CircuitBreaker → Retry(Bulkhead 最外)。
后果:
- 一个请求进入舱壁,占住 1 个并发位;
- 熔断器发现下游异常,内部重试 3 次,每次 500ms 退避 → 这个并发位被占用 1.5 秒;
- 20 个并发位很快被 20 个"在重试中"的请求占满;
- 舱壁开始拒绝,熔断器继续打开,雪崩照旧。
重试的退避时间被算进了并发占用里——这是配反顺序最典型的后果。
11.4 编程式装饰器
不想用注解(需要精确控制顺序、或者想组合非 Spring Bean)时用 Decorators:
Supplier<DeductResult> decorated = Decorators
.ofSupplier(() -> inventoryClient.deduct(cmd))
// 注意:先 with 的在内层,后 with 的在外层
.withBulkhead(bulkhead) // 最内层
.withTimeLimiter(timeLimiter, scheduler)
.withRateLimiter(rateLimiter)
.withCircuitBreaker(circuitBreaker)
.withRetry(retry) // 最外层
.withFallback(
List.of(BusinessException.class, CallNotPermittedException.class),
throwable -> DeductResult.degraded(throwable))
.decorate();
DeductResult result = decorated.get();
Builder 的语义:with* 调用顺序 = 从内到外。最后 with 的是最外层。这跟直觉相反,务必记住。
如果只用 CircuitBreaker.decorateSupplier(cb, supplier) 这种单能力写法,嵌套关系就是字面上的括号:
Supplier<DeductResult> s = Retry.decorateSupplier(retry,
CircuitBreaker.decorateSupplier(cb,
RateLimiter.decorateSupplier(rl,
Bulkhead.decorateSupplier(bh,
() -> inventoryClient.deduct(cmd)))));
十二、Spring Boot 集成
12.1 依赖与 AOP
前面说过,注解依赖 AOP。没有 spring-boot-starter-aop,所有 @CircuitBreaker 都是纯装饰品——不报错、不生效,这是最难发现的一类故障。
可以用这张表自检:
| 现象 | 原因 |
|---|---|
| 注解完全不生效,没有日志也没有降级 | 缺 spring-boot-starter-aop |
启动报 NoSuchElementException / 找不到实例 |
name 拼写与 yaml 中 instances 不匹配 |
| 只有同类内部调用不生效 | AOP 自调用问题(见坑 #1) |
Boot 2 项目用了 spring-boot3 starter |
自动配置类未被加载 |
12.2 完整配置示例
resilience4j:
circuitbreaker:
configs:
default:
slidingWindowType: COUNT_BASED
slidingWindowSize: 20
minimumNumberOfCalls: 10
failureRateThreshold: 50
slowCallRateThreshold: 80
slowCallDurationThreshold: 1s
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 5
automaticTransitionFromOpenToHalfOpenEnabled: true
registerHealthIndicator: true
recordExceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
- org.springframework.web.client.ResourceAccessException
ignoreExceptions:
- com.example.order.exception.BusinessException
- java.lang.IllegalArgumentException
instances:
inventory:
baseConfig: default
slidingWindowSize: 50
payment:
baseConfig: default
failureRateThreshold: 40
waitDurationInOpenState: 30s # 支付下游恢复慢,多等一会
retry:
configs:
default:
maxAttempts: 3
waitDuration: 200ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
exponentialMaxWaitDuration: 2s
retryExceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
ignoreExceptions:
- com.example.order.exception.BusinessException
instances:
inventory:
baseConfig: default
points:
baseConfig: default
maxAttempts: 2 # 积分不重要,少重试
bulkhead:
instances:
inventory:
maxConcurrentCalls: 20
maxWaitDuration: 10ms
ratelimiter:
instances:
partnerApi:
limitForPeriod: 100
limitRefreshPeriod: 1s
timeoutDuration: 0
timelimiter:
instances:
inventory:
timeoutDuration: 2s
cancelRunningFuture: true
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,circuitbreakers,circuitbreakerevents,retries,ratelimiters,bulkheads,timelimiters
endpoint:
health:
show-details: always
health:
circuitbreakers:
enabled: true
12.3 Actuator 端点
| 端点 | 用途 |
|---|---|
GET /actuator/circuitbreakers |
所有熔断器的当前状态、失败率、慢调用率 |
GET /actuator/circuitbreakerevents |
最近事件(含状态迁移、错误详情) |
GET /actuator/retries |
重试实例与配置 |
GET /actuator/ratelimiters |
可用许可数 |
GET /actuator/bulkheads |
当前并发数、剩余容量 |
GET /actuator/timelimiters |
超时配置 |
线上排查时,/actuator/circuitbreakers 是第一站:
{
"circuitBreakers": {
"payment": {
"state": "OPEN",
"failureRate": "62.50%",
"slowCallRate": "0.00%",
"bufferedCalls": 16,
"failedCalls": 10,
"notPermittedCalls": 3421,
"bufferedMaxCalls": 20
}
}
}
notPermittedCalls: 3421 告诉你:熔断器已经替下游挡掉了 3421 次调用——它正在救你。
12.4 Micrometer 指标与告警
@Configuration
public class ResilienceMetricsConfig {
@Bean
public MeterBinder circuitBreakerMetrics(CircuitBreakerRegistry registry) {
return TaggedCircuitBreakerMetrics.ofCircuitBreakerRegistry(registry);
}
@Bean
public MeterBinder retryMetrics(RetryRegistry registry) {
return TaggedRetryMetrics.ofRetryRegistry(registry);
}
@Bean
public MeterBinder rateLimiterMetrics(RateLimiterRegistry registry) {
return TaggedRateLimiterMetrics.ofRateLimiterRegistry(registry);
}
@Bean
public MeterBinder bulkheadMetrics(BulkheadRegistry registry) {
return TaggedBulkheadMetrics.ofBulkheadRegistry(registry);
}
}
用 Spring Boot 的自动配置时,只要类路径上有 resilience4j-micrometer + micrometer-registry-prometheus,大部分指标会自动绑定,上面是手动绑定的方式(用于自定义 Tag)。
常见指标名(Prometheus 格式):
| 指标 | 含义 |
|---|---|
resilience4j_circuitbreaker_state{name,state} |
当前状态(1 = 该状态激活) |
resilience4j_circuitbreaker_calls_seconds_count{name,kind} |
调用次数,kind ∈ success/failed/not_permitted/ignored |
resilience4j_circuitbreaker_failure_rate{name} |
当前失败率 |
resilience4j_retry_calls_total{name,kind} |
重试调用统计 |
resilience4j_ratelimiter_available_permissions{name} |
剩余许可 |
resilience4j_bulkhead_available_concurrent_calls{name} |
剩余并发位 |
四条必备告警规则:
# 1. 熔断器打开
- alert: CircuitBreakerOpen
expr: resilience4j_circuitbreaker_state{state="open"} == 1
for: 1m
annotations:
summary: "熔断器 {{ $labels.name }} 已打开"
# 2. 熔断拒绝率过高(用户正在受影响)
- alert: HighNotPermittedRate
expr: rate(resilience4j_circuitbreaker_calls_seconds_count{kind="not_permitted"}[5m]) > 1
for: 5m
# 3. 重试率异常(下游不稳)
- alert: HighRetryRate
expr: |
sum by (name) (rate(resilience4j_retry_calls_total{kind="failed_with_retry"}[5m]))
/ sum by (name) (rate(resilience4j_retry_calls_total[5m])) > 0.1
for: 10m
# 4. 舱壁接近打满
- alert: BulkheadSaturated
expr: resilience4j_bulkhead_available_concurrent_calls < 2
for: 3m
12.5 健康检查
registerHealthIndicator: true 后,熔断器会出现在 /actuator/health 里:
{
"status": "UP",
"components": {
"circuitBreakers": {
"status": "UP",
"details": {
"payment": {"status": "UP", "details": {"state": "CLOSED", "failureRate": "12.5%"}}
}
}
}
}
注意:熔断器 OPEN 时状态会变成 UNKNOWN(不一定是 DOWN),这取决于 minimumNumberOfCalls 是否满足。不要把熔断器健康状态直接映射到 K8s 的 liveness probe——否则下游故障会让你自己的 Pod 被重启,越滚越糟。健康检查只用于 readiness 或人工排查。
12.6 Feign / Gateway 集成
OpenFeign:
spring:
cloud:
openfeign:
circuitbreaker:
enabled: true # 开启后 Feign 方法自动走 Spring Cloud CircuitBreaker
@FeignClient(name = "inventory-service",
fallbackFactory = InventoryFallbackFactory.class)
public interface InventoryClient {
@PostMapping("/api/inventory/deduct")
DeductResult deduct(@RequestBody DeductCommand cmd);
}
Spring Cloud Circuit Breaker 抽象层:
spring:
cloud:
circuitbreaker:
resilience4j:
enable-semaphore-default-bulkhead: true
Spring Cloud Gateway 的 CircuitBreaker 过滤器也是走这条路。
十三、完整实战:订单服务
13.1 场景
POST /orders
├─ 库存服务 inventory-service 核心,必须成功
├─ 支付服务 payment-service 核心,必须成功(且幂等)
└─ 积分服务 points-service 非核心,可降级
不同的下游有不同的容错策略——这是本节的重点,不是所有下游都该用同一套参数。
| 下游 | 熔断 | 重试 | 舱壁 | 限流 | 降级策略 |
|---|---|---|---|---|---|
| 库存 | ✅ 严格 | ✅ 指数退避 | ✅ 信号量 20 | ❌ | 失败即返回下单失败 |
| 支付 | ✅ 严格 | ✅ 仅幂等键 | ✅ 信号量 10 | ✅ 100 TPS | 转异步对账 |
| 积分 | ✅ 宽松 | ✅ 少重试 | ❌ | ❌ | 静默丢弃,记埋点 |
13.2 配置
resilience4j:
circuitbreaker:
configs:
core:
slidingWindowSize: 20
minimumNumberOfCalls: 10
failureRateThreshold: 50
slowCallRateThreshold: 80
slowCallDurationThreshold: 800ms
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 5
automaticTransitionFromOpenToHalfOpenEnabled: true
registerHealthIndicator: true
noncore:
slidingWindowSize: 10
minimumNumberOfCalls: 5
failureRateThreshold: 70 # 非核心,容错放宽
waitDurationInOpenState: 30s
registerHealthIndicator: false
instances:
inventory: { baseConfig: core }
payment: { baseConfig: core, waitDurationInOpenState: 20s }
points: { baseConfig: noncore }
retry:
configs:
default:
maxAttempts: 3
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
exponentialMaxWaitDuration: 2s
waitDuration: 200ms
retryExceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
ignoreExceptions:
- com.example.order.exception.BusinessException
instances:
inventory: { baseConfig: default }
payment: { baseConfig: default, maxAttempts: 2 }
points: { baseConfig: default, maxAttempts: 2 }
bulkhead:
instances:
inventory: { maxConcurrentCalls: 20, maxWaitDuration: 10ms }
payment: { maxConcurrentCalls: 10, maxWaitDuration: 10ms }
ratelimiter:
instances:
payment: { limitForPeriod: 100, limitRefreshPeriod: 1s, timeoutDuration: 0 }
13.3 服务代码
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderService {
private final InventoryClient inventoryClient;
private final PaymentClient paymentClient;
private final PointsClient pointsClient;
// ============ 库存:核心,失败即终止下单 ============
@Bulkhead(name = "inventory")
@CircuitBreaker(name = "inventory", fallbackMethod = "deductFallback")
@Retry(name = "inventory", fallbackMethod = "deductFallback")
public DeductResult deductInventory(String sku, int qty, String orderId) {
return inventoryClient.deduct(new DeductCommand(sku, qty, orderId));
}
private DeductResult deductFallback(String sku, int qty, String orderId, Throwable t) {
log.error("库存扣减失败 sku={} orderId={}", sku, orderId, t);
// 核心链路不降级,向上抛业务异常
throw new OrderFailedException("库存服务不可用,请稍后重试", t);
}
// ============ 支付:核心,幂等键必须带 ============
@Bulkhead(name = "payment")
@RateLimiter(name = "payment")
@CircuitBreaker(name = "payment", fallbackMethod = "payFallback")
@Retry(name = "payment", fallbackMethod = "payFallback")
public PayResult pay(String orderId, BigDecimal amount) {
// orderId 作为幂等键,服务端按它去重
return paymentClient.charge(new ChargeRequest(orderId, amount, orderId));
}
private PayResult payFallback(String orderId, BigDecimal amount, Throwable t) {
log.error("支付调用失败,转入异步对账 orderId={}", orderId, t);
reconciliationService.enqueue(orderId); // 落库,后续对账补偿
return PayResult.pending(orderId); // 对用户表现为"处理中"
}
// ============ 积分:非核心,静默降级 ============
@CircuitBreaker(name = "points", fallbackMethod = "pointsFallback")
@Retry(name = "points", fallbackMethod = "pointsFallback")
public void addPoints(String userId, int points) {
pointsClient.add(userId, points);
}
private void pointsFallback(String userId, int points, Throwable t) {
log.warn("积分发放失败,已丢弃 userId={} points={}", userId, points, t);
// 不阻塞主流程,记埋点事后补
analytics.track("points.dropped", Map.of("userId", userId, "points", points));
}
}
这段代码里有三个刻意为之的设计,值得单独说:
-
fallbackMethod同时挂在@CircuitBreaker和@Retry上:因为Retry是最外层切面,理论上只挂它就能兜住一切。但挂两处更安全——万一哪天调整了切面顺序,兜底逻辑不会突然消失。代价是熔断器打开时,Retry会对着一个已经打开的熔断器再白白重试 2 次。更精细的做法是把
CallNotPermittedException加进retry.ignoreExceptions——熔断器已经明确说"别打了",重试就是浪费:resilience4j: retry: configs: default: ignoreExceptions: - com.example.order.exception.BusinessException - io.github.resilience4j.circuitbreaker.CallNotPermittedException - io.github.resilience4j.ratelimiter.RequestNotPermitted - io.github.resilience4j.bulkhead.BulkheadFullException -
库存的 fallback 抛异常而不是返回兜底值:核心链路"假成功"比"真失败"危害大得多——库存没扣却告诉用户下单成功,后面要人工擦屁股。非核心才降级,核心只快速失败。
-
支付的 fallback 转异步对账,返回"处理中":这是支付类场景的标准解法。既不阻塞用户,也不丢失交易,靠对账任务最终一致。
13.4 用混沌测试验证
容错配置写完不算完,必须验证它在故障下真的工作。 三种验证手段:
① 单元测试(用 Mock 制造失败)
@SpringBootTest
class OrderServiceResilienceTest {
@MockBean private InventoryClient inventoryClient;
@Autowired private OrderService orderService;
@Autowired private CircuitBreakerRegistry cbRegistry;
@Test
void circuitBreakerShouldOpenAfterThreshold() {
when(inventoryClient.deduct(any())).thenThrow(new ResourceAccessException("timeout"));
// 打满窗口(minimumNumberOfCalls = 10)
for (int i = 0; i < 12; i++) {
assertThrows(Exception.class,
() -> orderService.deductInventory("sku1", 1, "order" + i));
}
CircuitBreaker cb = cbRegistry.circuitBreaker("inventory");
assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN);
// 后续调用应直接抛 CallNotPermittedException,不再打下游
assertThrows(CallNotPermittedException.class,
() -> orderService.deductInventory("sku1", 1, "order99"));
verify(inventoryClient, times(12)).deduct(any()); // 确认没有再打下游
}
@Test
void retryShouldWorkWithExponentialBackoff() {
when(inventoryClient.deduct(any()))
.thenThrow(new IOException("network"))
.thenReturn(new DeductResult(true));
DeductResult r = orderService.deductInventory("sku1", 1, "order1");
assertThat(r.isSuccess()).isTrue();
verify(inventoryClient, times(2)).deduct(any()); // 重试了 1 次
}
}
② 故障注入(测试环境)
- ChaosBlade / Chaos Mesh:直接给下游 Pod 注入延迟(
--time 3000)和异常(--exception); - ToxiProxy:在调用链里插一个代理,配置
latency 3s或timeout; - 代码开关:
if (chaosFlag.on("inventory.slow")) Thread.sleep(5000);
重点验证三个断言:熔断器是否在预期次数内打开、降级是否返回预期兜底、恢复后是否自动关闭。
③ 线上灰度
新配置先在 METRICS_ONLY 状态跑 3~7 天,观察 /actuator/circuitbreakers 的 failureRate 和 slowCallRate 实际分布,确认阈值设置合理后再切回 CLOSED(正常模式)。这比压测更贴近真实流量。
十四、十二个常见坑与解法
坑 1:同类内部调用,注解完全失效
@Service
public class UserService {
public void outer() {
this.inner(); // ❌ 不经过 Spring 代理,@CircuitBreaker 无效
}
@CircuitBreaker(name = "backend")
public void inner() { ... }
}
原因:注解基于 Spring AOP 代理,this.inner() 是原生对象调用,绕过了代理。
解法(三选一):
- 拆到另一个 Bean 里(最推荐,架构上本来就更清晰);
- 注入自身代理:
@Lazy @Autowired private UserService self;然后self.inner(); - 用
AopContext.currentProxy()(需@EnableAspectJAutoProxy(exposeProxy = true))。
坑 2:在 private / final 方法上加注解
Spring AOP 的 CGLIB 代理无法覆写 private、final、static 方法,注解静默失效。
解法:方法改成 public,类不要是 final。
坑 3:fallback 方法签名写错
// ❌ 缺少异常参数
private String fallback(Long id) { ... }
// ❌ 参数顺序不对
private String fallback(Throwable t, Long id) { ... }
// ✅ 原参数 + 一个异常参数(放在最后)
private String fallback(Long id, Throwable t) { ... }
注意:异常参数的类型决定了哪些异常会走这个 fallback。写 TimeoutException t 则只有超时走兜底,其它异常继续向上抛;写 Throwable t 则全都兜住——这会顺手把业务异常也吞掉,是最隐蔽的数据一致性 bug 来源。
一个注解只能指定一个 fallbackMethod,所以想让不同异常走不同兜底逻辑,只有两条路:在 Throwable 方法里用 instanceof 分流,或者改用编程式 withFallback(异常列表, 处理函数)。
而且 fallback 方法必须和原方法在同一个类里(切面用反射在目标类中查找)。
坑 4:给同步方法加 @TimeLimiter
@TimeLimiter(name = "backend")
public String syncCall() { ... } // ❌ 完全不生效,也不会报错
解法:改成返回 CompletableFuture,或者用 HTTP 客户端的超时配置。
坑 5:对非幂等接口重试
前面讲过。扣款、下单、扣库存的重试必须有幂等键,否则一次网络抖动 = 双倍扣款。
坑 6:@Bulkhead(type = THREADPOOL) 返回了同步类型
@Bulkhead(name = "payment", type = Bulkhead.Type.THREADPOOL)
public PayResult pay(...) { ... } // ❌ 必须返回 CompletableFuture
解法:返回 CompletableFuture<PayResult>,并接受"ThreadLocal 丢失"的代价(traceId 需要手动透传)。
坑 7:熔断器把下游的限流异常也算成失败
顺序是 CircuitBreaker(RateLimiter(...)) 时,限流器抛的 RequestNotPermitted 会被外层熔断器记为失败。于是一批本来正常的请求因为限流被拒,把熔断器推向 OPEN——双重惩罚。
解法:
CircuitBreakerConfig.custom()
.ignoreExceptions(RequestNotPermitted.class,
BulkheadFullException.class,
CallNotPermittedException.class)
坑 8:注解和编程式混用,装饰了两遍
@CircuitBreaker(name = "backend") // 已经装饰了一层
public String call() {
return Decorators.ofSupplier(this::raw)
.withCircuitBreaker(sameCircuitBreaker) // ❌ 又装饰一层
.get();
}
结果是同一调用被统计两次,熔断器提前打开。
解法:一个方法只用一种方式。
坑 9:@Retry 和 @Transactional 的顺序问题
如果 Retry 切面在事务切面外侧,每次重试都会开启一个新事务——这通常是你想要的(失败的事务已经回滚了)。但如果 Retry 在内侧,重试发生在同一个事务里,前一次失败的写入可能污染事务状态。
解法:确认切面顺序,必要时把重试逻辑上移到不带事务的编排层。
坑 10:实例名与 yaml 不一致,静默用默认值
@CircuitBreaker(name = "inventroy") // 拼写错误
如果 yaml 里没有 inventroy 实例,Resilience4j 会用默认配置创建一个新实例——不报错,但你的所有调参全部无效。
解法:启动时校验实例是否存在。
@Bean
public ApplicationRunner validateInstances(CircuitBreakerRegistry registry) {
return args -> {
List.of("inventory", "payment", "points").forEach(name -> {
if (!registry.find(name).isPresent()) {
throw new IllegalStateException("未定义熔断器实例: " + name);
}
});
};
}
坑 11:waitDurationInOpenState 设太长
下游 5 秒就恢复了,熔断器还拒绝 60 秒(默认值)的请求。5~30 秒足够,配合 automaticTransitionFromOpenToHalfOpenEnabled: true 让恢复更及时。
坑 12:把熔断器状态直接接到 liveness probe
/actuator/health 里熔断器 OPEN → K8s 判定 Pod 不健康 → 重启 Pod → 新 Pod 的熔断器是 CLOSED → 立刻又被下游打爆 → 循环重启。
解法:liveness 只检查进程自身(端口、JVM),业务依赖状态放 readiness 或独立指标。
十五、生产环境实践清单
15.1 配置管理
- [ ] 所有实例显式配置,禁止依赖默认值上线;
- [ ] 用
configs模板 +baseConfig继承,避免复制粘贴漂移; - [ ] 配置纳入 Git 与 Code Review,参数变更有记录;
- [ ] 通过配置中心(Nacos / Apollo / Spring Cloud Config)支持热更新;
- [ ] 启动时校验实例名(见坑 #10)。
15.2 命名规范
# 推荐:按"依赖目标"命名,而不是按"业务场景"
inventory # ✅ 下游是什么就叫什么
payment-gateway # ✅
orderCreateCb # ❌ 按业务命名,一个下游会出现多个实例,指标碎成渣
同一个下游在所有服务里的实例名保持一致,指标才能聚合。
15.3 阈值设定方法
不要拍脑袋。按这个顺序来:
- 先观察:新接入的下游先跑
METRICS_ONLY模式 1 周,拿到真实的成功率和延迟分布; - 定基线:
slowCallDurationThreshold= 下游 P99 × 2~3; - 定阈值:
failureRateThreshold= 基线失败率 + 20~30 个百分点; - 定恢复:
waitDurationInOpenState= 下游平均恢复时间(数据库主从切换通常 10~30s); - 压测验证:用 ToxiProxy 注入延迟,确认熔断按预期触发。
15.4 分级策略
| 依赖级别 | 熔断 | 重试 | 降级 |
|---|---|---|---|
| 核心(支付、库存) | 严格(阈值低、窗口小) | 有幂等键才重试 | 转异步/对账,不静默失败 |
| 重要(用户信息) | 标准 | 可以重试 | 返回缓存数据 |
| 非核心(积分、推荐、埋点) | 宽松或不做 | 少重试 | 静默丢弃 + 埋点 |
降级必须记录。log.warn 之外,还要打点到监控——"降级了多少次"是判断是否需要扩容/修复的核心指标。静默降级等于自欺欺人。
15.5 幂等设计
// 幂等键的三种实现
1. 业务唯一键:orderId + 操作类型 (最简单)
2. 请求级 UUID:客户端生成 requestId,服务端 Redis SETNX 去重
3. 状态机:UPDATE ... WHERE status = 'INIT'(乐观锁,天然幂等)
15.6 可观测性
- [ ] 熔断器状态迁移 → 告警(P1)
- [ ] 拒绝率 > 阈值 → 告警(P2)
- [ ] 降级次数突增 → 告警(P2)
- [ ] 重试率 > 10% → 告警(P3,说明下游不稳)
- [ ] 舱壁可用容量 < 2 → 告警(P3,说明容量不足)
- [ ] Dashboard 上每个下游一张"成功率 / P99 / 熔断状态 / 重试率"四联图
15.7 发布节奏
- 新依赖接入 →
METRICS_ONLY观察; - 阈值确认 → 切
CLOSED正常模式; - 灰度发布(1% → 10% → 50% → 100%);
- 每次故障复盘时回看熔断参数是否需要调整——熔断参数是活的,不是一次性配好就不动的。
十六、参数速查表
熔断器
| 场景 | slidingWindowSize | minimumNumberOfCalls | failureRate | waitDurationInOpen |
|---|---|---|---|---|
| 高 QPS 核心接口(1000 QPS) | 100(COUNT) | 50 | 50% | 10s |
| 中等流量(50 QPS) | 30(COUNT) | 10 | 50% | 15s |
| 低流量(5 QPS) | 10(COUNT) | 5 | 60% | 30s |
| 变流量 API | 60(TIME) | 20 | 50% | 10s |
| 第三方支付 | 20(COUNT) | 10 | 40% | 30s |
重试
| 场景 | maxAttempts | 间隔策略 |
|---|---|---|
| 内网 RPC | 2~3 | 指数退避 100ms → 1s |
| 第三方 API | 2 | 指数退避 + 抖动 200ms → 2s |
| 数据库主从切换 | 3 | 固定 500ms |
| 幂等查询 | 3~4 | 指数退避 |
| 非幂等写 | 1(不重试) | — |
舱壁
| 场景 | maxConcurrentCalls |
|---|---|
| 快速接口(P99 < 50ms),200 QPS | 20 |
| 普通接口(P99 200ms),100 QPS | 25 |
| 慢接口(P99 2s),10 QPS | 20 |
| 数据库连接池 | = 连接池大小 × 0.8 |
限流
| 场景 | limitForPeriod | limitRefreshPeriod | timeoutDuration |
|---|---|---|---|
| 入口防刷 | QPS × 1.2 | 1s | 0 |
| 第三方配额 | 配额 / 60 | 1s | 0 |
| 平滑输出 | 1 | 200ms | 100ms |
十七、总结
回看整篇文章,Resilience4j 的核心其实只有三句话:
1. 每个能力只解决一个问题,别指望一个注解搞定所有事。
熔断解决"下游持续失败",重试解决"瞬时失败",舱壁解决"资源耗尽",限流解决"超额调用",超时解决"无限等待"。用错工具比不用更危险——比如对非幂等接口重试,是在主动制造故障。
2. 顺序比参数更重要。
Retry(CircuitBreaker(RateLimiter(TimeLimiter(Bulkhead(f)))) 这个顺序不是随便定的,每一条都有明确的失败模型支撑。顺序错了,参数调得再精细也没用。
3. 容错是一个需要持续运营的系统,不是一个配置项。
上线只是开始:先用 METRICS_ONLY 拿真实数据,再定阈值;监控四个核心事件(熔断打开、拒绝率、降级次数、重试率);每次故障复盘时回看参数。没有监控的熔断,等于蒙着眼睛开车。
最后提醒一句:容错的最高境界不是"故障时还能用",而是**“故障时能说清楚发生了什么”**。熔断器打开了,你要能在 30 秒内回答三个问题——哪个下游、什么错误、影响多少用户。如果答不上来,说明可观测性还没做到位。
参考资料
以下资料按标题直接搜索即可获取:
- Resilience4j 官方文档(Read the Docs 文档站)
- Resilience4j 开源仓库(resilience4j / resilience4j)
- Spring Cloud Circuit Breaker 官方参考文档
- 《Release It!》第二版,Michael Nygard —— 熔断、舱壁、超时等稳定性模式的经典出处
- Martin Fowler 的 CircuitBreaker 模式文章
- Google SRE Book 中的 Handling Overload 一章
本文示例代码基于 Spring Boot 3.2 / Resilience4j 2.2.0 / JDK 17。
- 点赞
- 收藏
- 关注作者
评论(0)