第二章:超时机制基础——连接、读取、写入

说到超时,很多人第一反应就是「设置个数字呗」。嗯,其实没那么简单。

我在刚开始做 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 三种超时,如何协同配置?

三种超时不是孤立存在的。它们是同一个请求的生命周期中的三个不同阶段,必须协同工作。

这里我画了一个图,帮你理清这个关系。

超时机制三阶段关系图 请求生命周期中的三种超时 连接阶段 TCP 三次握手 连接超时 建议:1-5秒 传送阶段 发送请求数据 写入超时 建议:3-10秒 响应阶段 等待服务端返回 读取超时 建议:按业务定 🔑 关键理解 • 三个超时对应请求的三个阶段:建立连接 → 发送数据 → 等待响应 • 连接超时失败:网络/服务端不可达 → 立即重试或返回 • 写入超时失败:对端消费能力跟不上 → 需要降级或换路 • 读取超时失败:服务端响应慢或挂了 → 重试前要判断幂等性

我个人的经验是:连接超时和写入超时相对「硬」,失败后基本没有什么等待的价值。读取超时则比较「软」,要根据业务逻辑来调整。

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 没有单独的写入超时参数
# 写入操作本身就很快,所以一般不太需要

好了,三种超时的核心概念就聊到这里。记住一个口诀:连接要快,读写要稳。