群里有人问: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.default 和 ribbon.* 我都写了。Hoxton 里如果你用了 feign.client.config 的 connectTimeout/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 的 ReadTimeoutConnectTimeoutException→ 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估,配完记得压测验证。
最后一个小提醒:MaxAutoRetries 和 MaxAutoRetriesNextServer 是相乘关系,配成 2 和 2 意味着最多 9 次请求。上游 QPS 1000 的话,下游能瞬间收到 9000。我一般第一个就设 0。