第97章 超时模式(Timeout):连接超时、读取超时、超时重试,以及OkHttp与Feign超时配置

说起超时,我脑子里立刻浮现出几年前的一个深夜。

那会儿我们系统突然大面积卡死,所有请求都像石沉大海。排查了半天,发现是下游的一个服务挂了,但我们的HTTP客户端还在傻傻地等——默认超时是0,也就是永不超时。结果线程池被活活撑爆,整个应用挂了。

嗯,从那以后,我对超时配置就特别敏感。

什么是超时模式?

说白了,超时模式就是给每个操作设定一个「等待上限」。超过这个时间,就别等了,赶紧报错或者走备选方案。

你想想看,一个请求发出去,对方要是永远不响应,你的线程就得一直挂着。这在单线程时代可能还能忍,但在高并发场景下,线程就是最宝贵的资源,浪费不起。

超时模式主要解决三个问题:

  • 资源保护:防止线程被无限期阻塞
  • 故障隔离:某个服务挂了,不影响调用方
  • 用户体验:用户不需要等太久,快速失败比慢速成功更好

超时的三种类型

我在项目中见过很多人只配一个超时时间,其实这是不够的。一个完整的HTTP请求,至少涉及三个阶段的超时:

超时类型 含义 典型值 我踩过的坑
连接超时 建立TCP连接的最大等待时间 500ms ~ 2s 设太短,网络抖动就失败
读取超时 从连接读取数据的最大间隔时间 5s ~ 30s 设太长,慢接口拖垮线程池
写入超时 发送请求数据的最大等待时间 1s ~ 5s 一般很少单独配

核心原则:连接超时要短,读取超时要根据业务定。连接超时是网络层面的,通常几百毫秒就够了;读取超时是业务层面的,得看接口的P99响应时间。

超时重试:别让失败变成雪崩

超时之后怎么办?直接抛异常?还是再试一次?

我个人习惯是:幂等操作可以重试,非幂等操作坚决不重试

比如查询接口,超时了重试一次没问题。但如果是下单、转账这种操作,重试可能导致重复扣款——那麻烦就大了。

重试策略一般有三种:

  • 固定间隔重试:等1秒再试,简单粗暴
  • 指数退避:第一次等1秒,第二次等2秒,第三次等4秒……防止打爆下游
  • 抖动退避:在指数退避基础上加随机值,避免所有客户端同时重试

我的经验:重试次数不要超过3次。我曾经见过一个系统配了10次重试,结果下游挂了之后,上游自己先被重试流量打死了。

Java中的超时实现

先看一个最基础的例子。用Java原生Socket实现超时:

// 连接超时 + 读取超时
Socket socket = new Socket();
socket.connect(new InetSocketAddress("example.com", 80), 2000); // 2秒连接超时
socket.setSoTimeout(5000); // 5秒读取超时

// 或者用URLConnection
URL url = new URL("http://example.com");
URLConnection conn = url.openConnection();
conn.setConnectTimeout(2000);
conn.setReadTimeout(5000);

嗯,这里要注意:setSoTimeout 是读取超时,不是总超时。很多人搞混过,包括我。

OkHttp的超时配置

OkHttp是我最常用的HTTP客户端。它的超时配置非常清晰:

OkHttpClient client = new OkHttpClient.Builder()
    .connectTimeout(2, TimeUnit.SECONDS)   // 连接超时
    .readTimeout(10, TimeUnit.SECONDS)     // 读取超时
    .writeTimeout(5, TimeUnit.SECONDS)     // 写入超时
    .build();

Request request = new Request.Builder()
    .url("http://example.com/api")
    .build();

// 同步调用
try (Response response = client.newCall(request).execute()) {
    // 处理响应
} catch (SocketTimeoutException e) {
    // 超时处理
    System.err.println("请求超时了:" + e.getMessage());
}

注意:OkHttp的超时是针对单个请求的。如果你用同一个client并发发100个请求,每个请求的超时是独立的,不会互相影响。

OkHttp还支持重试。默认情况下,如果发生路由错误或连接失败,OkHttp会自动重试。但超时导致的异常不会自动重试——这个设计我觉得挺合理的,超时说明服务端可能已经处理了,重试反而危险。

Feign的超时配置

Feign在微服务里用得很多。它底层可以用OkHttp或者Apache HttpClient。配置方式有两种:

方式一:全局配置

# application.yml
feign:
  client:
    config:
      default:
        connectTimeout: 2000
        readTimeout: 5000
        loggerLevel: basic

方式二:针对某个服务单独配置

feign:
  client:
    config:
      user-service:  # 服务名
        connectTimeout: 1000
        readTimeout: 3000

如果Feign底层用的是Ribbon做负载均衡,还要注意Ribbon也有自己的超时配置:

ribbon:
  ConnectTimeout: 2000
  ReadTimeout: 5000

避坑指南:我曾经遇到过一个诡异的问题——Feign配置了5秒超时,但请求总是10秒才超时。查了半天才发现,Ribbon的超时默认是10秒,而且Feign的超时和Ribbon的超时是「取最大值」的关系。所以一定要把两者配成一致,或者干脆只用Feign的超时。

超时模式的核心流程图

下面这张图是我自己总结的超时处理流程,每次设计新系统我都会拿出来参考:

超时处理核心流程 发起请求 连接超时?(connectTimeout) 连接失败 / 重试判断 读取超时?(readTimeout) 读取失败 / 重试判断 请求成功 是否重试?(重试次数 < 3) 是,重试 否,报错 最终失败

超时配置的最佳实践

说了这么多,到底该怎么配?我总结了几条经验:

  1. 连接超时不要超过2秒。正常的内网调用,几百毫秒就够了。外网调用可以放宽到2秒。
  2. 读取超时根据P99来定。比如接口的P99是3秒,那读取超时设5秒就够。别设成30秒,那等于没设。
  3. 重试要加退避。别一超时就立刻重试,大概率还是超时。等一等再试,成功率更高。
  4. 区分读操作和写操作。读操作可以重试,写操作要谨慎。实在要重试,得保证幂等。
  5. 监控超时率。如果超时率突然飙升,说明下游可能出问题了,这时候应该降级而不是重试。

一个小技巧:我习惯在配置中心里动态管理超时时间。这样线上出问题时,可以秒级调整,不用重启应用。比如用Apollo或者Nacos,把超时时间配成可动态刷新的配置项。

总结

超时模式看起来简单,但坑不少。连接超时、读取超时、重试策略,每一个都值得认真对待。

我记得有一次,一个同事把读取超时设成了1毫秒,结果所有请求都超时了。排查了半天才发现是配置写错了。嗯,这种低级错误我也犯过,所以现在每次改超时配置,我都会再三检查。

最后送大家一句话:超时不是银弹,但没有超时,系统就是纸糊的

公众号:蓝海资料掘金营,微信deep3321