第二章:超时机制基础——连接、读取、写入
说到超时,很多人第一反应就是「设置个数字呗」。嗯,其实没那么简单。
我在刚开始做 SDK 的时候,也犯过这个错。以为超时就是一把梭,全局配个 10 秒就完事了。结果线上各种奇奇怪怪的问题,有的一直连不上,有的读了一半卡死,有的发送大包直接超时。
后来我才明白,超时不是一刀切的事情。它至少有三个维度:连接超时、读取超时、写入超时。每个维度的意义、影响、配置策略都不一样。今天咱们就把这三个东西彻底掰扯清楚。
2.1 连接超时(Connect Timeout)
先说说连接超时。什么是连接超时?说白了,就是客户端发起 TCP 握手(SYN -> SYN-ACK -> ACK)的时候,愿意等多长时间。
你想想看,如果服务端挂了、网络断了、防火墙丢了包,你的 SYN 包发出去就石沉大海了。这时候如果没有超时,你的线程就会一直卡在 connect() 调用上。有的系统默认 TCP 重试时间很长(我记得 Linux 默认是 127 秒),那就等着吧。
核心原则:连接超时应当设置的比较短。
为什么?因为连接阶段是最快能判断失败的地方。如果 2-3 秒都连不上,大概率是网络有问题或者服务端已经挂了。没必要再等下去。
我个人习惯,内网调用的连接超时设在 1-3 秒,外网可以宽裕一点,5-10 秒。
小技巧:连接超时不要超过上层负载均衡的探测间隔。否则会出现「客户端还在重试,负载均衡已经摘掉了节点」的尴尬局面。
我曾经帮一个团队排查问题,他们的 RPC 调用偶尔会卡 30 秒然后抛异常。查来查去,发现连接超时设了 30 秒。但实际上,3 秒连不上基本就是废了,等 30 秒纯粹是在浪费资源。
2.2 读取超时(Read Timeout / Socket Timeout)
读取超时,很多时候也被叫做 Socket 超时。它指的是:连接建立之后,客户端等待服务端返回数据的最长时间。
这个就容易踩坑了。很多人以为读取超时是「整个请求的总耗时上限」,其实不是。它是一次 read() 调用的等待时间。
举个例子:你发了一个请求,服务端要处理 10 秒才能返回结果。你设的读取超时是 5 秒。按照一次读取的思想,可能客户端在第 4 秒的时候收到了第一个字节,然后继续读。这时候超时计时器可能会被重置(取决于具体实现),所以请求仍然能完成。
但如果是 JDK 原生的 java.net.SocketTimeoutException,它监视的是两个字节之间的间隔时间。也就是说,服务端如果中间某段时间不给你发数据,超过了读取超时,那就断了。
避坑指南:我曾经在对接一个流式接口时栽过跟头。服务端是边计算边返回的,处理一个大文件需要 30 秒,但每 10 秒会 flush 一次中间结果。我的读取超时设了 15 秒,结果到 20 秒的时候,就因为两次 flush 间隔太长被断开了。最后只能把读取超时调到 60 秒。
读取超时的配置,要看你业务的响应模式:
- 短请求(毫秒级):2-5 秒足够。
- 中等请求(秒级):10-30 秒合理。
- 长请求/流式响应:根据服务端输出间隔来设,一般 60-120 秒。
- 实时推送:可能设成 0(无限等待),但要配合心跳。
2.3 写入超时(Write Timeout)
写入超时相对简单一些,但也不是完全没有坑。
它指的是:客户端将请求数据写到操作系统的 TCP 发送缓冲区时,允许等待的时间。
正常情况下,Socket 的发送缓冲区是够用的,write() 调用几乎是瞬间完成。但有一种情况很麻烦——对端消费能力跟不上。
你发得很快,服务端收得很慢。TCP 的滑动窗口机制会逐渐缩小窗口,直到窗口大小为 0。这时候你再 write(),数据就写不进去了,线程就会阻塞在写操作上。
如果没有写入超时,这个阻塞可能持续到 TCP 连接超时(通常是 10 多分钟甚至 30 分钟)。这在生产环境中是不能接受的。
配置建议:写入超时通常设置成 3-10 秒。如果超过这个时间还写不进去,说明对端或者网络已经严重拥堵了,赶紧断开重试才是正确的选择。
我调过的一个案例:某个 SDK 的批量上传功能,客户端是 100 路并发写的。服务端偶尔 GC 会导致几秒钟的停顿,这时候客户端的写入缓冲区很快就满了。没有写入超时的话,一堆线程就卡在 write() 不动了,最终导致整个进程假死。加了 5 秒的写入超时,问题就解决了。
2.4 三种超时,如何协同配置?
三种超时不是孤立存在的。它们是同一个请求的生命周期中的三个不同阶段,必须协同工作。
这里我画了一个图,帮你理清这个关系。
我个人的经验是:连接超时和写入超时相对「硬」,失败后基本没有什么等待的价值。读取超时则比较「软」,要根据业务逻辑来调整。
2.5 实际中的配置参考
给你一个我在实际项目中用过的配置表。当然,不是让你照抄,但可以作为起点来调整。
| 场景 | 连接超时 | 读取超时 | 写入超时 |
|---|---|---|---|
| 内部 RPC(同机房) | 1秒 | 5秒 | 3秒 |
| 内部 RPC(跨机房) | 3秒 | 10秒 | 5秒 |
| 外部 API(普通) | 5秒 | 30秒 | 10秒 |
| 外部 API(大文件) | 10秒 | 120秒 | 30秒 |
| 数据库连接 | 5秒 | 30秒 | 10秒 |
另一个坑:不要把超时时间设置的太短。我见过有人把读取超时设成 1 秒,理由是「追求性能」。结果服务端高峰期稍微慢了一点,几乎所有请求都在重试,反而把系统打垮了。超时重试是一把双刃剑,后面我们会专门讲。
2.6 代码示例:在 HTTP 客户端中配置三种超时
光说理论不过瘾,来段代码看看实际怎么配置。
以 Java 的 OkHttp 为例:
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS) // 连接超时
.readTimeout(30, TimeUnit.SECONDS) // 读取超时
.writeTimeout(10, TimeUnit.SECONDS) // 写入超时
.build();
以 Go 的 http.Client 为例:
client := &http.Client{
Timeout: 30 * time.Second, // 注意:这是整体超时,不是读取超时
// Go 标准库没有单独的读取超时和写入超时
// 需要通过 Transport 来设置:
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second, // 连接超时
}).DialContext,
ResponseHeaderTimeout: 10 * time.Second, // 等待响应头的超时
// 写入超时需要通过自定义的 net.Conn 包装来实现
},
}
注意:Go 标准库的 http.Client 中,整体的 Timeout 字段其实包含了从请求开始到读取响应体的所有阶段。如果你需要更精细的控制,要么用 Transport 的细分参数,要么用第三方的 HTTP 库。
Python 的 requests 库更直接:
import requests
response = requests.get(
'https://api.example.com/data',
timeout=(5, 30) # (连接超时, 读取超时)
)
# 注意:requests 没有单独的写入超时参数
# 写入操作本身就很快,所以一般不太需要
好了,三种超时的核心概念就聊到这里。记住一个口诀:连接要快,读写要稳。