Administrator
发布于 2020-01-29 / 8025 阅读
137

OpenFeign 调用超时与重试的正确配置

群里有人问:ReadTimeout 配了 5 秒,为什么 1 秒就超时

那天下午,新来的同事在企业微信群里贴了一段配置和一段报错:

ribbon:
  ConnectTimeout: 3000
  ReadTimeout: 5000
com.netflix.hystrix.exception.HystrixTimeoutException: null
	at com.netflix.hystrix.AbstractCommand$HystrixObservableTimeoutOperator$1.run(...)

"我明明配了 5 秒,日志显示 1 秒就炸了。"

这个坑我踩过,当时查了小半天。答案一句话:他配的是 Ribbon 的超时,报出来的是 Hystrix 的超时,这是两套完全独立的机制,谁先到谁生效。

一次 Feign 调用身上套了几层

业务代码
  └─ Feign 动态代理
       └─ Hystrix(如果开启,默认 1000ms 熔断)
            └─ Ribbon(负载均衡 + 重试)
                 └─ HTTP 客户端(默认 JDK HttpURLConnection,无连接池)

时间约束是从外向内的。Hystrix 的计时器在最外层,它不管你 Ribbon 在干什么,到点就中断整个 command,抛 HystrixTimeoutException。所以 ReadTimeout: 5000 根本没机会生效。

我们当时用的是 Spring Cloud Hoxton.SR1 + Spring Boot 2.2.2,Feign 是自带 Hystrix 的(Hoxton 里 feign.hystrix.enabled 默认是 false,但我们的老工程在 Edgware 时代就显式打开了)。

正确的配法:让外层比内层宽松

我给他的公式是:

Hystrix 超时 ≥ (ConnectTimeout + ReadTimeout) × (MaxAutoRetries + 1) × (MaxAutoRetriesNextServer + 1) + 冗余

具体配置,我们生产上订单服务调库存服务这一组:

feign:
  hystrix:
    enabled: true
  httpclient:
    enabled: true          # 用 Apache HttpClient,别用默认的 HttpURLConnection
    maxConnections: 200    # 总连接数
    maxConnectionsPerRoute: 50
  client:
    config:
      default:
        connectTimeout: 2000
        readTimeout: 3000
        loggerLevel: basic

ribbon:
  ConnectTimeout: 2000
  ReadTimeout: 3000
  MaxAutoRetries: 0              # 同一台实例不重试
  MaxAutoRetriesNextServer: 1    # 换一台实例重试 1 次
  OkToRetryOnAllOperations: false # 只对 GET 重试

hystrix:
  command:
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 12000   # (2+3) × 1 × 2 = 10 秒,留 2 秒冗余

注意 feign.client.config.defaultribbon.* 我都写了。Hoxton 里如果你用了 feign.client.configconnectTimeout/readTimeout,它会覆盖 Ribbon 的配置。两套并存很容易搞混,我现在的习惯是统一写在 ribbon 下,feign.client.config 里面只放日志级别,避免歧义。

如果不用 Hystrix 呢

Hoxton 之后官方推荐用 Spring Cloud Circuit Breaker,我们新服务直接关了 Hystrix:

feign:
  hystrix:
    enabled: false
  circuitbreaker:
    enabled: true     # 用 Resilience4j 或 Sentinel 的适配

关掉之后就只剩 Ribbon 一层超时,清爽很多。

重试的幂等风险,这个真的会出事

上面配置里 OkToRetryOnAllOperations: false 是最关键的一行,千万别图省事改成 true

去年 10 月我们出过一次生产事故。优惠券服务有个 POST /coupon/use 接口,当时有人为了"提高可用性"配了:

ribbon:
  OkToRetryOnAllOperations: true
  MaxAutoRetries: 1

某次网络抖动,请求其实已经到了优惠券服务并且核销成功了,只是响应包丢了。Ribbon 重试,核销第二次。第二天客服接到 11 个用户投诉,说自己的券被用掉了两次。查下来是同一批请求的 traceId,两次核销间隔 47 毫秒。

结论:POST、PUT、DELETE 一律不重试。只有 GET 是天然幂等的,可以放心重试。

如果是非 GET 又确实需要重试,那是业务幂等的事,得在服务端做:

@PostMapping("/coupon/use")
public Result useCoupon(@RequestHeader("X-Request-Id") String reqId,
                        @RequestBody UseCouponReq req) {
    // requestId 建唯一索引,重复请求直接返回上次结果
    CouponUseRecord exist = recordMapper.selectByReqId(reqId);
    if (exist != null) {
        return Result.ok(exist.getResult());
    }
    ...
}

连接池,默认的那个是真的烂

这个坑藏得更深。Feign 默认用的是 JDK 自带的 HttpURLConnection,它:

  • 没有连接池,每次调用新建 TCP 连接
  • 默认不开 Keep-Alive,短连接
  • 没有超时之外的并发控制

我们用 netstat 看过一次压测时的状态:

$ netstat -an | grep 10.0.1.23:8080 | awk '{print $6}' | sort | uniq -c
   1847 TIME_WAIT
     63 ESTABLISHED

1800 多个 TIME_WAIT,全是打到下游的短连接。端口耗尽只是时间问题,而且每次建连要多花 3 到 8 毫秒。

换成 Apache HttpClient 之后:

<dependency>
    <groupId>io.github.openfeign</groupId>
    <artifactId>feign-httpclient</artifactId>
</dependency>

同样的压测,TIME_WAIT 降到 51,P99 从 340 ms 降到 118 ms。

池子大小怎么定,我按这个估:maxConnectionsPerRoute ≈ 单机 QPS × 平均 RT(秒) × 1.5。我们订单服务单机峰值 300 QPS,RT 90 ms,算出来 40,取了 50。

另外记得开 feign.compression,响应体大于 2KB 时 gzip,跨机房调用能省不少带宽:

feign:
  compression:
    response:
      enabled: true
      min-request-size: 2048

顺带说下日志

Feign 的日志级别默认是 NONE,什么都没有。想看请求和响应,配成 FULL

feign:
  client:
    config:
      inventory-service:
        loggerLevel: full
logging:
  level:
    com.xxx.client.InventoryClient: DEBUG

两处都要改,缺一不可。但我只建议在联调阶段开 FULL:它会把整个请求体和响应体打出来,我们日志里最大的一条有 230 KB(一个批量查询接口),一天多产生 4 GB 日志。BASIC 只打方法和 URL、耗时,日常用它就够了。

排错时怎么确认到底哪一层超时

三句话分辨:

  • HystrixTimeoutException 或者 Timed out and no fallback available → Hystrix 那层
  • SocketTimeoutException: Read timed out → Ribbon 的 ReadTimeout
  • ConnectTimeoutException → Ribbon 的 ConnectTimeout,通常是网络或对端线程池满了
  • Timeout acquiring connection from pool → 连接池不够,调 maxConnections

小结

  • Hystrix 超时在外层,必须大于 Ribbon 的(连接 + 读取)乘以总重试次数,不然 Ribbon 的配置形同虚设。
  • 超时配置二选一:要么全写 ribbon.*,要么全写 feign.client.config.*。混着写会被覆盖。
  • OkToRetryOnAllOperations 保持 false。我们因为设成 true 核销了 11 张重复券。
  • 一定要引 feign-httpclient,默认的 HttpURLConnection 无连接池,TIME_WAIT 能堆到 1800+。
  • 连接池大小按 QPS × RT × 1.5 估,配完记得压测验证。

最后一个小提醒:MaxAutoRetriesMaxAutoRetriesNextServer相乘关系,配成 2 和 2 意味着最多 9 次请求。上游 QPS 1000 的话,下游能瞬间收到 9000。我一般第一个就设 0。

参考