ElIauk

微服务治理:超时、重试、熔断组合

三个参数单独看都不对 超时、重试、熔断常被当成三个独立配置项,各写一个默认值。线上常见的形态是:接口超时 5 秒、Feign 重试 3 次、网关到服务的读超时只有 2 秒。一次慢请求被放大成 4 次调用,上游 2 秒就把连接断了,下游还在继续算,线程被这些“已经没人要的请求”占满,故障从一个接口扩散

评论
微服务治理:超时、重试、熔断组合

三个参数单独看都不对

超时、重试、熔断常被当成三个独立配置项,各写一个默认值。线上常见的形态是:接口超时 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,调用方如果没判断,就会把它当作“有货”继续下单,最后在扣减环节报错。降级结果必须能被区分,要么返回明确的不可用状态,要么抛出可识别的降级异常。

熔断也不能替代降级。熔断只负责“别再打了”,降级负责“打不了的时候业务怎么走”。两者分开设计,代码才不会互相绑架。

排查步骤

线上出现“接口突然全慢”时,按下面的顺序看,比直接翻代码快得多:

  1. 先看错误类型分布,是超时、连接拒绝,还是 500。三者根因完全不同。
  2. 看单次请求的总耗时与重试次数是否对得上,n × t 能解释大部分尖刺。
  3. 看业务线程池的活跃线程数和队列长度,队列满说明下游容量已经不够,不是超时设小了。
  4. 通过 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'
  1. 确认重试是否命中了非幂等接口。如果是写操作被重试,问题已经不在性能,而在数据一致性。

最后一条经验:超时值不要设成“比平均耗时大很多”的整数,而要设在自己能承受的 P99 附近,再配合熔断把长尾挡在门外。超时设得过大,等于把线程租给了已经注定失败的请求。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者