一个接口超时,为什么拖垮整条链路
Spring Cloud 项目里最常见的线上故障不是某个服务挂了,而是某个下游变慢:网关线程被占满,上游服务的 Feign 连接池耗尽,最后整个调用链一起雪崩。OpenFeign 本身只是个声明式 HTTP 客户端,真正决定稳定性的是三组参数:超时、重试、熔断降级。这三者配得不匹配,比不配更危险。
下面以 Spring Boot 3.3 + Spring Cloud 2023.0.x + OpenFeign 4.x 为例,把参数关系和踩坑点讲清楚。
超时:三层时间要逐层放大
Feign 的超时只有两个:建连超时和读取超时。默认值分别是 10 秒和 60 秒,60 秒的读超时对多数接口都太长了,等于把线程白送出去。配置按客户端名分组,default 是兜底:
spring:
cloud:
openfeign:
client:
config:
default:
connect-timeout: 1000
read-timeout: 3000
logger-level: basic
order-service:
read-timeout: 2000
circuitbreaker:
enabled: true
一次请求实际会经过多层超时:浏览器或网关的最外层超时、调用方 Feign 的读超时、被调服务的处理耗时、它内部再调下游的 Feign 超时、数据库查询超时。合理的关系是外层略大于内层,并留出重试和网络抖动的余量。常见错误是网关 3 秒、Feign 5 秒,结果网关先把连接断掉,调用方还在等,日志里只有一堆 502,看不出根因。
另外,Feign 的读超时是单次请求的,不包含负载均衡选实例和排队等连接的时间。高并发下如果连接池不够,请求会卡在 pending acquire,这部分时间不受 read-timeout 约束,表现是接口耗时远超配置值。
重试:默认不重试反而是好事
Spring Cloud OpenFeign 默认使用 Retryer.NEVER_RETRY,也就是不自动重试。很多团队为了“提高成功率”随手加一个 Retryer Bean,却没意识到三个问题:重试会放大下游流量;重试会叠加超时,3 次 × 3 秒就变成 9 秒;对 POST 这类非幂等接口重试,可能产生重复下单。
比较稳妥的策略是:只对幂等查询开启重试,次数控制在 1 到 2 次,并且设置在网关或调用方的最外层,而不是每一层都重试。如果基于 Spring Cloud LoadBalancer 做实例级重试,还需要引入 spring-retry 并显式开启。重试和熔断要一起看:下游已经熔断时,重试没有意义,只会增加无谓等待。
熔断降级:fallback 别把异常吞了
开启 spring.cloud.openfeign.circuitbreaker.enabled 后,Feign 调用会经过熔断器。用 Sentinel 时对应的是 feign.sentinel.enabled=true;用 Resilience4j 则要自己配超时和熔断规则。降级类必须是 Spring Bean,推荐用 FallbackFactory,因为它能拿到异常:
@FeignClient(name = "order-service", path = "/api/orders",
fallbackFactory = OrderClientFallbackFactory.class)
public interface OrderClient {
@GetMapping("/{id}")
OrderVO getById(@PathVariable("id") Long id);
}
@Slf4j
@Component
public class OrderClientFallbackFactory implements FallbackFactory<OrderClient> {
@Override
public OrderClient create(Throwable cause) {
return id -> {
log.error("查询订单失败, orderId={}", id, cause);
throw new BizException("ORDER_UNAVAILABLE", "订单服务暂不可用,请稍后重试");
};
}
}
最差的写法是 fallback 里返回 null 或空对象,调用方以为查到了,继续往下走,最后在别处报 NPE。降级要么给出明确的业务错误码,要么返回带标记的兜底数据,并且一定把原始异常记下来。
常见坑
坑一:@FeignClient 同时写了 name 和 url。 一旦写了 url,请求就直连该地址,服务发现和负载均衡全部失效。测试环境临时指向某台机器时要注意,别把 url 带进生产配置。
坑二:接口路径重复。 类上写 @RequestMapping("/api/orders"),方法上再写 /api/orders/{id},最终路径变成 /api/orders/api/orders/{id}。类上只放前缀,方法上放相对路径。
坑三:请求头丢失。 Feign 不会自动透传 Authorization、租户 ID、traceId。需要实现 RequestInterceptor 统一补,或者用 @RequestHeader 显式声明。透传前想清楚哪些头可以信任,别把外部传入的租户头原样带到内部服务。
坑四:扫描不到客户端。 启动类没加 @EnableFeignClients,或者 basePackages 没覆盖到接口所在包,启动时报 No qualifying bean of type OrderClient。
排查步骤
- 把客户端包的日志级别开到
DEBUG,确认请求 URL、耗时、异常类型,分清是建连失败、读超时还是熔断拒绝。 - 绕过 Feign,用
curl直接打下游实例,排除网络和服务发现问题。 - 看下游的线程池、连接池和 GC,很多“Feign 超时”其实是下游被慢 SQL 拖住了。
- 检查熔断器状态和 fallback 日志,确认是真实失败还是降级逻辑误伤。
- 最后对齐网关、Feign、下游三层的超时值,确保外层大于内层。
评论