Administrator
发布于 2021-08-02 / 2932 阅读
58

熔断降级与隔离:Resilience4j 实战

现象:优惠券服务挂了,我们的订单服务跟着挂了

7 月 28 号晚上 8 点多,告警炸了。不是优惠券服务告警,是订单服务告警:Tomcat 线程池打满、健康探针失败、K8s 开始杀 Pod。

[P1] order-service / http_server_requests_seconds P99 > 5s (当前 8.4s)
[P1] order-service / tomcat_threads_busy 200/200
[P1] order-service / 下单成功率 61.3% (基线 99.8%)

根因链其实很简单:优惠券服务因为一次慢 SQL 全面超时(RT 从 30 ms 涨到 6 秒以上),订单服务每下一单要调一次优惠券核销接口,调用是同步阻塞的,200 个 Tomcat 线程全卡在等那个 HTTP 响应上。等 8 秒超时返回后,用户早跑了,前端重试,流量翻倍,雪上加霜。

问题不在于优惠券服务会挂(哪个服务都会挂),而在于我们没有任何隔离手段,一个下游抖动直接传导成自己的全站不可用

选型:Resilience4j 而不是 Hystrix

Hystrix 2018 年就宣布停止开发了,不用考虑。剩下两个:阿里的 Sentinel 和 Resilience4j。

我们最后选了 Resilience4j 1.7.0,理由是:当时团队对 Spring Boot 的原生集成更熟,希望用注解就能解决;Sentinel 那套控制台 + 数据源推送的运维成本,在我们这种规模(十几个服务)上有点重。如果我有重来的机会,可能会再想想,后面会写对比。

依赖:

<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot2</artifactId>
    <version>1.7.0</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

Spring Boot 2.5.x 用的是 Resilience4j 1.7.x,2.4.x 对应 1.6.x,配对错了会有 NoSuchMethodError

熔断器:三个状态和它的参数

先说状态机,这是核心。Resilience4j 的 CircuitBreaker 有三个状态:

  • CLOSED:正常放行,同时统计失败率。
  • OPEN:失败率超阈值,全部请求直接拒绝(不调用下游),抛 CallNotPermittedException
  • HALF_OPEN:OPEN 等了一段时间后进入,放少量请求试探,成功则回 CLOSED,失败则回 OPEN。

配置:

resilience4j:
  circuitbreaker:
    configs:
      default:
        slidingWindowType: COUNT_BASED        # 或者 TIME_BASED
        slidingWindowSize: 100                # 统计最近 100 次调用
        minimumNumberOfCalls: 20              # 至少 20 次才开始计算失败率
        failureRateThreshold: 50              # 失败率 >= 50% 就打开
        slowCallRateThreshold: 60             # 慢调用比例阈值
        slowCallDurationThreshold: 2s         # 超过 2 秒算慢调用
        waitDurationInOpenState: 30s          # OPEN 持续 30 秒后进 HALF_OPEN
        permittedNumberOfCallsInHalfOpenState: 10
        automaticTransitionFromOpenToHalfOpenEnabled: true
        registerHealthIndicator: true         # 暴露到 /actuator/health
        recordExceptions:
          - java.io.IOException
          - java.util.concurrent.TimeoutException
          - org.springframework.web.client.ResourceAccessException
        ignoreExceptions:
          - com.xxx.order.exception.BizException   # 业务异常不算失败
    instances:
      couponService:
        baseConfig: default
        failureRateThreshold: 40              # 优惠券可以更快熔断

几个我调过的点:

  • minimumNumberOfCalls 一定要设。不设的话前几次调用失败(比如刚启动、连接还没热)就直接把熔断器打开了,误伤严重。
  • slowCallRateThreshold + slowCallDurationThreshold 比单纯统计异常更有用。那次故障里优惠券接口没抛异常,就是慢,只配异常统计的话熔断器根本不会开。
  • ignoreExceptions 里放业务异常。参数校验失败、"优惠券已使用"这种是正常业务流,不该触发熔断。
  • automaticTransitionFromOpenToHalfOpenEnabled: true 打开后不需要请求触发就自动转 HALF_OPEN,否则没有流量时熔断器会一直卡在 OPEN。

代码里用注解:

@CircuitBreaker(name = "couponService", fallbackMethod = "writeOffFallback")
public WriteOffResult writeOff(Long couponId, Long userId) {
    return couponClient.writeOff(couponId, userId);
}

// 降级方法:签名要一致 + 一个 Throwable 参数
private WriteOffResult writeOffFallback(Long couponId, Long userId, Throwable t) {
    log.warn("coupon writeOff degraded, couponId={}, reason={}",
             couponId, t.toString());
    // 核销降级:记录待核销,走异步补偿
    pendingWriteOffRepository.save(new PendingWriteOff(couponId, userId));
    return WriteOffResult.degraded();
}

facade 降级方法的签名必须和原方法一致再追加一个 Throwable,写错了启动不报错但熔断时会抛 NoSuchMethodException,非常坑。一定要写单测覆盖降级路径。

隔板隔离:限制并发数

光有熔断还不够。熔断器打开之前的那几十秒,请求还是在打下游、还是在占线程。Bulkhead 用来限制同时进行的数量:

resilience4j:
  bulkhead:
    configs:
      default:
        maxConcurrentCalls: 25           # 最多 25 个并发
        maxWaitDuration: 100ms           # 超了最多等 100ms,等不到就拒绝
    instances:
      couponService:
        maxConcurrentCalls: 20

  thread-pool-bulkhead:
    instances:
      couponService:
        coreThreadPoolSize: 10
        maxThreadPoolSize: 20
        queueCapacity: 50
        keepAliveDuration: 20s

两种隔板:bulkhead 是信号量实现,在当前线程里计数,不切换线程,开销小,适合同步调用;thread-pool-bulkhead 是独立线程池,能真正隔离线程资源,但会多一次线程切换,而且 ThreadLocal 传不过去(我们的链路追踪 traceId 就丢过,最后用了 ContextPropagator)。

我给优惠券调用用的是信号量版:核心诉求是"最多 20 个并发打过去,多出来的快速失败走降级",不需要独立线程池那种彻底隔离。

效果很明显,加了隔板之后就算下游 RT 6 秒,我们这边最多也只有 20 个线程被占用,其余 180 个线程还能服务不依赖优惠券的请求(比如订单查询、物流查询)。

限流

限流用的是令牌桶思路:

resilience4j:
  ratelimiter:
    instances:
      smsService:
        limitForPeriod: 50                 # 一个周期内 50 个许可
        limitRefreshPeriod: 1s             # 周期 1 秒
        timeoutDuration: 0                 # 拿不到许可立即拒绝,不等待

短信发送那个接口加了限流,因为运营商侧对我们有 100 QPS 的硬性限制,超了会封通道。这是 Resilience4j 里我用得最顺手的一块。

和 Sentinel 的对比

做完这套之后,隔壁组上了 Sentinel 1.8.2,我们交换过一次看法。

维度Resilience4j 1.7Sentinel 1.8
控制台无,靠 Micrometer + Grafana 看指标有,规则可在控制台实时改
规则配置yml 里写死或代码里改,改动要发版支持 Nacos/Apollo 动态数据源,秒级生效
限流维度令牌桶,QPSQPS、并发线程数、系统 Load、热点参数、集群限流
熔断策略失败率、慢调用率慢调用比例、异常比例、异常数
隔离手段信号量、线程池信号量(并发线程数限流)
接入方式注解 + 函数式 API,侵入低注解 + 适配器(Feign、Dubbo、Gateway、WebFlux)
网关支持Spring Cloud Gateway 适配成熟
体积纯 Java 库,很轻(只依赖 Vavr)要部署控制台 + 客户端

简单说:要控制台、要动态规则、要在网关做流量控制,用 Sentinel;只要一个轻量库做方法级防护,用 Resilience4j。我们后来在网关层补了 Sentinel,服务内部还是 Resilience4j,两者不冲突。

改造后的效果

9 月初优惠券服务又抖了一次(这次是 Redis 主从切换),有了隔离和熔断:

指标7.28 故障时9.03 故障时
下单成功率61.3%99.4%
订单服务 P998400 ms186 ms
Tomcat 繁忙线程200/20023/200
熔断触发时间故障后 12 秒
影响面订单服务全站不可用部分订单优惠券异步核销,延迟约 40 秒

熔断在第 12 秒打开(20 次调用里有 9 次慢调用,超过 40% 阈值),之后请求直接走降级落到待核销表,由定时任务在优惠券服务恢复后补偿。用户侧感知是"优惠券稍后到账",比"下单失败"好太多了。

小结

  • 同步调用外部服务必须配超时 + 熔断 + 隔板,缺一个都可能在下游故障时拖垮自己。
  • minimumNumberOfCallsslowCallDurationThreshold 是必须调的参数。只统计异常不统计慢调用的话,超时类故障熔断器永远不会开。
  • 降级方法签名要加 Throwable 参数,写错不报错但运行时才崩,务必写单测。
  • 信号量隔板开销小够用;线程池隔板会丢 ThreadLocal,链路追踪要额外处理。
  • 指标全部走 Micrometer 暴露到 /actuator/prometheus,我们配了 resilience4j_circuitbreaker_state 的告警,熔断器一打开就通知。
  • 网关层用 Sentinel 做全局流量控制,服务内用 Resilience4j 做方法级防护,组合起来效果最好。

参考