三个参数单独看都不对
超时、重试、熔断常被当成三个独立配置项,各写一个默认值。线上常见的形态是:接口超时 5 秒、Feign 重试 3 次、网关到服务的读超时只有 2 秒。一次慢请求被放大成 4 次调用,上游 2 秒就把连接断了,下游还在继续算,线程被这些“已经没人要的请求”占满,故障从一个接口扩散到整个服务。
这三个参数必须放在一起算,因为它们共同决定了一次调用最坏情况下会占用多少时间、多少线程、多少下游容量。
先把时间预算算清楚
设单次超时 t、最大尝试次数 n(含首次)、退避间隔 b,调用方最坏等待时间约为 n × t + (n - 1) × b。这个值必须明显小于上游给你的超时,中间要留出序列化、网络抖动、GC 停顿的余量。很多人只盯着 t,却忘了重试会把它乘上 n。
重试还有几条硬约束:只对幂等操作重试,只对网络超时和 5xx 重试,4xx 一律不重试;退避必须带随机抖动,否则一批客户端会在同一毫秒再次涌向刚恢复的下游;重试次数配置在下游能承受的范围内,而不是“越多越保险”。
用 Resilience4j 把策略写进配置
Spring Boot 3.5 里,spring-cloud-starter-circuitbreaker-resilience4j 已经取代了老的 Hystrix 方案。策略尽量写在 application.yml,代码只保留注解。
resilience4j:
retry:
instances:
inventoryClient:
max-attempts: 3
wait-duration: 200ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
retry-exceptions:
- java.io.IOException
- org.springframework.web.client.ResourceAccessException
ignore-exceptions:
- com.example.common.BusinessException
circuitbreaker:
instances:
inventoryClient:
sliding-window-type: COUNT_BASED
sliding-window-size: 50
minimum-number-of-calls: 20
failure-rate-threshold: 50
slow-call-duration-threshold: 800ms
slow-call-rate-threshold: 60
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 5
timelimiter:
instances:
inventoryClient:
timeout-duration: 800ms
cancel-running-future: true
@TimeLimiter 要求方法返回 CompletableFuture,并且必须显式传入线程池。用默认的 ForkJoinPool.commonPool() 承载阻塞 IO,是很多线上毛刺的来源。
@Configuration
public class ClientExecutorConfig {
@Bean(destroyMethod = "shutdown")
public ExecutorService inventoryExecutor() {
return new ThreadPoolExecutor(
8, 32, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
Thread.ofPlatform().name("inventory-client-", 1).daemon(true).factory(),
new ThreadPoolExecutor.CallerRunsPolicy());
}
}
@Service
public class InventoryQueryService {
private static final Logger log = LoggerFactory.getLogger(InventoryQueryService.class);
private final InventoryClient inventoryClient;
private final ExecutorService inventoryExecutor;
public InventoryQueryService(InventoryClient inventoryClient, ExecutorService inventoryExecutor) {
this.inventoryClient = inventoryClient;
this.inventoryExecutor = inventoryExecutor;
}
@CircuitBreaker(name = "inventoryClient", fallbackMethod = "fallbackStock")
@Retry(name = "inventoryClient")
@TimeLimiter(name = "inventoryClient")
public CompletableFuture<Integer> queryStock(String skuId) {
return CompletableFuture.supplyAsync(() -> inventoryClient.getStock(skuId), inventoryExecutor);
}
private CompletableFuture<Integer> fallbackStock(String skuId, Throwable cause) {
log.warn("库存查询降级, skuId={}, reason={}", skuId, cause.toString());
return CompletableFuture.completedFuture(-1);
}
}
线程池的线程名一定要有业务含义。线程 dump 里看到 inventory-client-3 能立刻定位到调用方,看到 pool-2-thread-3 只能靠猜;Java 21 的 Thread.ofPlatform() 让命名工厂一行就能写完,不必再手写 ThreadFactory。
几个注解同时出现时,执行顺序由各自切面的 order 决定:Retry 在最外层,熔断、限流、超时依次在内。这意味着每一次重试都单独消耗一次熔断判断和一份超时预算,配置时要按“最坏情况”估算,而不是按单次调用估算。
熔断打开之后会发生什么
熔断器打开后的 10 秒内,请求直接失败,不再打下游;时间到了进入半开,放行 5 个探针请求。这里最容易踩的坑是把降级返回值设计成业务上合法的值:库存返回 -1,调用方如果没判断,就会把它当作“有货”继续下单,最后在扣减环节报错。降级结果必须能被区分,要么返回明确的不可用状态,要么抛出可识别的降级异常。
熔断也不能替代降级。熔断只负责“别再打了”,降级负责“打不了的时候业务怎么走”。两者分开设计,代码才不会互相绑架。
排查步骤
线上出现“接口突然全慢”时,按下面的顺序看,比直接翻代码快得多:
- 先看错误类型分布,是超时、连接拒绝,还是 500。三者根因完全不同。
- 看单次请求的总耗时与重试次数是否对得上,
n × t能解释大部分尖刺。 - 看业务线程池的活跃线程数和队列长度,队列满说明下游容量已经不够,不是超时设小了。
- 通过 Actuator 端点确认熔断器状态与失败率:
curl -s localhost:8080/actuator/circuitbreakers | jq '.circuitBreakers.inventoryClient'
curl -s 'localhost:8080/actuator/metrics/resilience4j.retry.calls?tag=name:inventoryClient'
curl -s 'localhost:8080/actuator/metrics/resilience4j.circuitbreaker.calls?tag=name:inventoryClient'
- 确认重试是否命中了非幂等接口。如果是写操作被重试,问题已经不在性能,而在数据一致性。
最后一条经验:超时值不要设成“比平均耗时大很多”的整数,而要设在自己能承受的 P99 附近,再配合熔断把长尾挡在门外。超时设得过大,等于把线程租给了已经注定失败的请求。
评论