Naixxfl
← 全部笔记

三层重试,一次抖动打到下游就是九次

微服务Resilience4j熔断超时

见过不少这样的配置:每个服务调下游都写着超时 3 秒、重试 3 次。每一条单独看都很负责,摞在一起是一份灾难配方。

先算一道乘法

调用链 A → B → C,每层重试 3 次。

C 抖了一下,第一次失败。B 重试,两次都没成;A 看到 B 失败,也重试,于是 B 又各自重试三次。最后真正落到 C 身上的是 3 × 3 = 9 次请求。再加一层就是 27 次。

C 本来只是一下子没喘上气,被上游的一片好意按死了。

所以整条链上只该有一层做重试。放在最靠近故障的那一层,或者干脆只在入口做一次,团队内部说定就行。最怕的是每层都觉得"我这层加上更保险"。

一个调用其实有三个超时

这是我见过最多的误配,写配置的人以为自己配了超时,实际上漏了最关键的那个。

以 RestTemplate 配 Apache HttpClient 为例:

var factory = new HttpComponentsClientHttpRequestFactory();
factory.setConnectTimeout(1000);            // 建 TCP 连接
factory.setReadTimeout(2000);               // 等响应数据
factory.setConnectionRequestTimeout(500);   // 从连接池里拿一条连接

第三个是 connectionRequestTimeout,等的是连接池分配一条空闲连接。它在老版本里默认是 -1,无限等。

平时它毫无存在感,因为池里总有空闲连接。下游一变慢,连接全被占住,新请求就卡在这一步 —— 卡的时长不受 readTimeout 约束,因为请求根本还没发出去。你配的 2 秒超时形同虚设,线程在那儿无限期排队。

雪崩往往就是从这里开始的。排查超时问题时,先确认这三个值都配了,而不是只配了看得见的那两个。

OkHttp 那边对应的是 callTimeout,它管的是整个调用的总时长,包含拿连接、重定向、重试在内,配上它比逐项配省心。

超时是一份从上往下分的预算

网关对外承诺 3 秒,那么:

  • 服务 A 调 B 最多给 2.5 秒,留半秒给自己处理和序列化
  • B 调 C 单次给 800 毫秒,允许重试一次,退避 200 毫秒
  • B 这段总耗时 800 × 2 + 200 = 1800 毫秒,还在 A 给的 2.5 秒额度内

配反了会怎样?下游超时比上游长。上游早就放弃、给用户返回 504 了,下游还在吭哧吭哧算,连接池和线程一样没释放。用户看到的是失败,服务器资源一点没省下来。

检查方法很粗暴:把每层的「单次超时 ×(重试次数 + 1)+ 退避总和」算出来,和上游给的额度比一下。超了就改。

Resilience4j 的包装顺序有讲究

Spring Cloud CircuitBreaker 默认这么套,从外到内:

Retry( CircuitBreaker( RateLimiter( TimeLimiter( Bulkhead( 真正的调用 )))))

第一次看到我以为顺序随便排,后来才想明白熔断器为什么必须在重试里面。

假设反过来,熔断在外、重试在内。那么一组「重试三次全失败」在熔断器眼里只算一次失败。真实情况是下游被打了三下,统计里只记了一笔。失败率被严重稀释,阈值永远摸不到,熔断器挂在那儿跟装饰品一样。

放在里面,每一次尝试都单独计入统计,它才看得见下游被打成什么样。

但默认顺序带来一个坑

Retry 在最外面,意味着熔断器打开之后抛出的 CallNotPermittedException 会被 Retry 接住。而 Resilience4j 的 Retry 默认 retryExceptions 为空,语义是「任何异常都重试」。

结果就是:熔断器已经开了,明明白白在说"别打了",重试还在对着它一次次撞,撞完三次才把异常交出去。熔断本来是为了让调用方快速失败,这下变成了每次调用都要多等两个退避周期。

得显式把它排除掉:

resilience4j:
  retry:
    instances:
      orderService:
        maxAttempts: 2
        ignoreExceptions:
          - io.github.resilience4j.circuitbreaker.CallNotPermittedException

TimeLimiter 挡不住阻塞调用

还有一个反直觉的:TimeLimiter 作用在 CompletionStage 上,超时后它调 Future.cancel(true)。

对经典阻塞 IO 来说,cancel(true) 底下是 Thread.interrupt(),而一个卡在 socket 读上的线程不响应中断。于是 TimeLimiter 向上层报了超时,那个线程还在底下继续等,占着连接池里的位置直到 readTimeout 到期。

所以 TimeLimiter 不能替代 HTTP 客户端自己的超时,它只保证调用方不会等太久。底下那条连接什么时候放开,仍然由上面那三个超时决定。两层都得配。

阈值别只看"错了多少次"

resilience4j:
  circuitbreaker:
    instances:
      orderService:
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 50
        minimumNumberOfCalls: 20        # 样本不够就别下结论
        failureRateThreshold: 50
        slowCallRateThreshold: 50
        slowCallDurationThreshold: 1s
        waitDurationInOpenState: 10s
        permittedNumberOfCallsInHalfOpenState: 5
        recordExceptions:
          - java.io.IOException
          - java.util.concurrent.TimeoutException
        ignoreExceptions:
          - com.example.BizException

minimumNumberOfCalls 这项千万别省。不设的话,服务刚启动头几个请求正好失败,熔断器立刻就开 —— 那明明只是冷启动,连接池还没热。

recordExceptions 和 ignoreExceptions 这对同样不能省。默认所有异常都算失败,于是下游返回的"参数校验不通过""订单不存在"这类业务异常也被计进失败率。下游好端端的,熔断器却被一批 400 给顶开了。业务异常必须排除,熔断器只该对基础设施故障负责。

还有一个我一开始完全没重视的:慢调用率通常比错误率先响。

雪崩刚起头的时候,下游往往还在正常返回 200,只是从 50 毫秒变成 3 秒。错误率是 0,只盯错误率的熔断器毫无反应,而上游的线程池已经在被慢慢填满。等到真开始报错,那基本已经是第二现场了。

slowCallRateThreshold 就是干这个的。"慢"是比"错"更早的信号。

重试的前提是幂等

GET、DELETE、PUT 语义上天然幂等,重试无所谓。POST 不是。想重试就得带幂等键,服务端拿唯一索引挡住重复。

有一种情况特别容易漏:请求其实已经到了下游、也执行成功了,只是响应在回来的路上丢了。调用方看到的是超时,它并不知道对面已经做完。

超时不等于没执行。 这句话配上重试,就是重复下单和重复扣款的由来。

别在事务里重试

@Transactional
@Retry(name = "orderService")   // 这两个叠在一起是个陷阱
public void submit(Order o) { ... }

事务从方法进入就持有了一条数据库连接,重试的退避是在方法内部等的,那条连接整个退避期间都不会还回去。

算一下:退避 200 毫秒、重试 3 次,一个请求就要额外占住连接 600 毫秒。连接池 10 条,并发上到 17 就把池子抽干了,而这时数据库本身一点压力都没有。

正确的位置是把重试放到事务外面 —— 让每次尝试各自开一个短事务,失败了连接立刻归还。注解顺序控制不了这件事,得靠拆方法:外层方法带 @Retry,内层方法带 @Transactional,并且确保是跨 bean 调用,不然代理不生效。

退避记得加抖动

resilience4j:
  retry:
    instances:
      orderService:
        maxAttempts: 2
        waitDuration: 200ms
        enableExponentialBackoff: true
        exponentialBackoffMultiplier: 2
        enableRandomizedWait: true
        randomizedWaitFactor: 0.5

不加抖动的话,被打回来的那一批请求会在同一毫秒一起重试。下游刚缓过一口气,又被一个整整齐齐的洪峰按回去。随机抖动就是把这个洪峰摊平。

怎么确认它真的在工作

配完最怕的是自我感觉良好。Resilience4j 的状态是可以直接读出来的:

GET /actuator/circuitbreakers          # 各个实例当前是 CLOSED / OPEN / HALF_OPEN
GET /actuator/circuitbreakerevents     # 最近的状态迁移和失败记录
GET /actuator/metrics/resilience4j.circuitbreaker.calls

接到 Prometheus 之后,值得单独盯的是 resilience4j_circuitbreaker_state 和慢调用率。如果一个熔断器上线半年从没进过 OPEN,先别急着高兴,多半是阈值配得根本够不着。

最后给自己留四句检查:我的预算是多少、单次超时多少、允许重试几次、失败了熔断器看不看得见。四个答案对不上号的话,配置写得再花也只是自我安慰。