现象:优惠券服务挂了,我们的订单服务跟着挂了
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.7 | Sentinel 1.8 |
|---|---|---|
| 控制台 | 无,靠 Micrometer + Grafana 看指标 | 有,规则可在控制台实时改 |
| 规则配置 | yml 里写死或代码里改,改动要发版 | 支持 Nacos/Apollo 动态数据源,秒级生效 |
| 限流维度 | 令牌桶,QPS | QPS、并发线程数、系统 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% |
| 订单服务 P99 | 8400 ms | 186 ms |
| Tomcat 繁忙线程 | 200/200 | 23/200 |
| 熔断触发时间 | 无 | 故障后 12 秒 |
| 影响面 | 订单服务全站不可用 | 部分订单优惠券异步核销,延迟约 40 秒 |
熔断在第 12 秒打开(20 次调用里有 9 次慢调用,超过 40% 阈值),之后请求直接走降级落到待核销表,由定时任务在优惠券服务恢复后补偿。用户侧感知是"优惠券稍后到账",比"下单失败"好太多了。
小结
- 同步调用外部服务必须配超时 + 熔断 + 隔板,缺一个都可能在下游故障时拖垮自己。
minimumNumberOfCalls和slowCallDurationThreshold是必须调的参数。只统计异常不统计慢调用的话,超时类故障熔断器永远不会开。- 降级方法签名要加
Throwable参数,写错不报错但运行时才崩,务必写单测。 - 信号量隔板开销小够用;线程池隔板会丢
ThreadLocal,链路追踪要额外处理。 - 指标全部走 Micrometer 暴露到
/actuator/prometheus,我们配了resilience4j_circuitbreaker_state的告警,熔断器一打开就通知。 - 网关层用 Sentinel 做全局流量控制,服务内用 Resilience4j 做方法级防护,组合起来效果最好。