Resilience4j 实战指南:从熔断降级到生产级容错体系

举报
yd_236998400 发表于 2026/11/06 12:44:57 2026/11/06
【摘要】 Resilience4j 实战指南:从熔断降级到生产级容错体系一个下游接口抖了一下,整条链路跟着雪崩——这是微服务里最常见、也最贵的故障模式。本文不堆 API 文档,而是把 Resilience4j 拆成"为什么需要 → 每个模块怎么用 → 组合顺序怎么排 → 生产上怎么配"四层来讲,最后给一套可以照着落地的订单服务示例。基线:Spring Boot 3.x + 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-boot3 starter 引入)。


目录

  • 一、为什么需要容错
  • 二、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)。注意关键点:故障源是积分服务,但死掉的是订单服务、网关、甚至整个站点。故障会沿着调用链放大。

容错的本质不是"让调用成功",而是把失败限制在一个可控的范围内,做到四件事:

  1. 快速失败——下游已经挂了,别再浪费时间等它,立刻返回兜底结果(熔断)。
  2. 不放大流量——下游只是慢,别用重试把它彻底打死(限流 + 退避)。
  3. 隔离爆炸半径——积分服务挂了不影响库存/支付调用(舱壁)。
  4. 资源不泄漏——慢调用不能无限占用线程(超时)。

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

三个高频配错点:

  1. minimumNumberOfCalls ≥ slidingWindowSize → 窗口永远填不满,熔断器是个摆设。
  2. slowCallRateThreshold 留默认 100 → 只要有一个慢调用,慢调用率就是 100%?不,是"慢调用率阈值 100%"意味着永远不会因慢调用熔断。想做慢调用保护就必须显式调低。
  3. 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 / setReadTimeout
  • WebClient → Reactor Netty 的 responseTimeout
  • Feign → 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. 一个请求进入舱壁,占住 1 个并发位;
  2. 熔断器发现下游异常,内部重试 3 次,每次 500ms 退避 → 这个并发位被占用 1.5 秒;
  3. 20 个并发位很快被 20 个"在重试中"的请求占满;
  4. 舱壁开始拒绝,熔断器继续打开,雪崩照旧。

重试的退避时间被算进了并发占用里——这是配反顺序最典型的后果。

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

这段代码里有三个刻意为之的设计,值得单独说:

  1. 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
    
  2. 库存的 fallback 抛异常而不是返回兜底值:核心链路"假成功"比"真失败"危害大得多——库存没扣却告诉用户下单成功,后面要人工擦屁股。非核心才降级,核心只快速失败。

  3. 支付的 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() 是原生对象调用,绕过了代理。

解法(三选一):

  1. 拆到另一个 Bean 里(最推荐,架构上本来就更清晰);
  2. 注入自身代理:@Lazy @Autowired private UserService self; 然后 self.inner();
  3. 用 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 阈值设定方法

不要拍脑袋。按这个顺序来:

  1. 先观察:新接入的下游先跑 METRICS_ONLY 模式 1 周,拿到真实的成功率和延迟分布;
  2. 定基线:slowCallDurationThreshold = 下游 P99 × 2~3;
  3. 定阈值:failureRateThreshold = 基线失败率 + 20~30 个百分点;
  4. 定恢复:waitDurationInOpenState = 下游平均恢复时间(数据库主从切换通常 10~30s);
  5. 压测验证:用 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 发布节奏

  1. 新依赖接入 → METRICS_ONLY 观察;
  2. 阈值确认 → 切 CLOSED 正常模式;
  3. 灰度发布(1% → 10% → 50% → 100%);
  4. 每次故障复盘时回看熔断参数是否需要调整——熔断参数是活的,不是一次性配好就不动的。

十六、参数速查表

熔断器

场景 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。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。