网络基础:TCP/IP超时机制与HTTP重试原理

为什么我们非要聊这个?

很多工程师写SDK的时候,上来就调HTTP库。超时时间随便设个5秒,重试逻辑抄网上的三行代码就完事。结果呢?线上接口稍微抖一下,整个服务雪崩。

我见过最夸张的例子:某次大促,一个下游服务响应慢了8秒,结果客户端因为重试导致请求放大20倍,直接把网关打挂了。追根溯源,就是不懂底层超时机制。

说白了,超时和重试不只是调参数。你得搞清楚数据在网络里到底是怎么走的。

TCP/IP超时机制:背锅侠的真相

HTTP请求底层走的是TCP。TCP是可靠传输,但它为了保证可靠,搞了一套复杂的超时重传机制。这套机制如果跟应用层的超时打架,那就要出问题。

1. TCP连接超时(三次握手超时)

你的客户端调用connect(),发送SYN包。对端没回应,TCP不会马上就告诉你失败。它会等,等一个叫初始RTO(Retransmission Timeout)的时间。

这个值在Linux上默认是1秒(现在内核很多已调整为0.2秒)。如果1秒内没收到SYN-ACK,TCP会重发SYN,并且超时时间翻倍(指数退避)。

我记得之前排查过一个坑:服务端防火墙悄悄丢弃了SYN包,客户端等了21秒才抛连接超时。为什么是21秒?TCP默认尝试重试6次:1s + 2s + 4s + 8s + 16s + 32s ? 不对,很多系统实际在尝试第6次后才放弃。但这个等待时间让上层业务直接卡死。

TCP重试次数 超时时长(典型值) 累计耗时
第1次 1s 1s
第2次 2s 3s
第3次 4s 7s
第4次 8s 15s
第5次 16s 31s

你想想看,如果应用层设了个10秒超时,TCP还在第3次重试,应用层就自己挂了。这就是典型的超时层叠冲突。

2. TCP数据传输超时(重传超时)

连接建立后,数据包丢了怎么处理?TCP同样有RTO。而且如果触发了快速重传(收到3个冗余ACK),它不等超时就重传。

但这里有个容易被忽略的点:TCP超时是动态计算的。它根据往返时间(RTT)和抖动(RTTVAR)来调整。我遇到过一种场景:网络偶尔抖动一下,RTT从10ms变成500ms,TCP的RTO会自动调整到1秒以上。而我的应用层超时设了800ms,结果应用层重发了请求,TCP这边还在努力重传旧的包,两边数据就乱了。

警告: 应用层超时不应小于TCP的RTO最小值(通常200ms-1s),否则会产生重复请求。

3. TCP Keep-Alive(连接空闲超时)

TCP Keep-Alive不是给你保活的,而是用来检测死连接的。默认情况下,Linux在连接空闲7200秒(2小时)后,才发送Keep-Alive探测包。

这个跟HTTP长连接的空闲超时完全两码事。很多同学混淆了这两个概念,以为设了TCP Keep-Alive就能让连接一直活着。其实它只是帮你回收死连接,效率还很低。

个人习惯: 我一般不在应用层依赖TCP Keep-Alive。想检测连接健康度,用HTTP层的健康检查,或者自己写心跳,可控性高得多。

HTTP超时与重试:应用层的防弹衣

HTTP超时是应用层自己设定的。它不依赖底层TCP怎么重试。但你要是理解了我上面说的TCP机制,就知道怎么设更合理。

1. HTTP连接超时(ConnectTimeout)

这是客户端发起TCP连接的最大等待时间。你想想看,如果你设成100ms,而网络环境RTT就有200ms,那每次都直接超时。所以这个值一般要大于RTT,但又不能太大,否则服务器挂了你也死等。

我建议:

  • 内网调用:300ms-500ms(RTT通常<1ms,留足重试余量)
  • 公网调用:1s-3s(考虑网络波动和DNS解析等)
  • 如果走多个CDN或海外节点,可能需要更大

2. HTTP读取超时(ReadTimeout / SocketTimeout)

连接已经建立,但服务端迟迟不返回数据。这个超时是从你发送完请求,到收到第一个字节的最大等待时间。

实际项目中,读取超时比连接超时更容易触发。因为连接超时只发生在握手阶段,而读取超时覆盖了整个处理阶段。

我处理过一个案例:某业务接口正常响应200ms,但高峰期偶尔变慢到8秒。客户端设了6秒的超时,就时不时抛出SocketTimeoutException。如果不增加超时时间,就需要看服务端是否做了限流或慢查询优化。

3. HTTP重试原理:什么时候该重试?

重试不是万能的。有些错误重试能解决,有些反而会加重问题。

场景 可重试? 原因
连接超时(Connection Timeout) ✅ 建议重试 可能只是网络抖动或服务端临时负载高
读取超时(Read Timeout) ⚠️ 谨慎重试 请求可能已被服务端处理,重试导致重复操作
HTTP 5xx(服务端错误) ✅ 建议重试 多为瞬态错误,重试大概率成功
HTTP 4xx(客户端错误) ❌ 不重试 通常指业务异常或参数错误,重试无效
连接被拒绝(Connection Refused) ⚠️ 谨慎重试 服务已下线,重试只会浪费资源
核心原则: 只有幂等请求(GET、PUT、幂等POST)才适合对读取超时进行重试。非幂等操作(如订单创建)必须特殊处理。

4. 重试策略:指数退避 + 抖动

直接用固定间隔重试,比如每1秒重试一次,这种方式我称之为「雪崩加速器」。想象一下,服务端挂了,1000个客户端同时固定间隔重试,重启后瞬间涌入大量请求,又挂了。

正确的做法是指数退避。第一次重试等1秒,第二次2秒,第三次4秒。再加上随机抖动(Jitter),防止所有客户端在同一时刻重试。

import time
import random

def retry_with_backoff(max_retries=3, base_delay=1.0, max_delay=10.0):
    """
    指数退避 + 随机抖动
    """
    for attempt in range(max_retries):
        try:
            # 发起HTTP请求
            return http_call()
        except RetryableError as e:
            if attempt == max_retries - 1:
                raise  # 最后一次,向上抛
            # 计算退避时间:base_delay * 2^attempt
            delay = min(base_delay * (2 ** attempt), max_delay)
            # 加入随机抖动,范围是[0, delay/2]
            jitter = random.uniform(0, delay * 0.5)
            actual_delay = delay + jitter
            time.sleep(actual_delay)
我建议: 最大重试次数不要超过3次。超过3次后,异常概率已经很低,反而可能因为连续重试导致更大的延迟。如果3次都失败,应该直接熔断,别再试了。
HTTP超时与重试决策流程 发起HTTP请求 连接超时? 是 执行重试 否 读取超时? 是 请求幂等? 是 安全重试 否 不重试,抛异常 否 HTTP 5xx 状态码? 是 执行重试 否 抛出异常 总结:连接超时必重试,读取超时看幂等,5xx可重试,4xx不重试

避坑指南:我曾经踩过的坑

  • 坑一:超时不区分读写。很多早期代码里只设了一个timeout,这其实是连接超时+读取超时共享。你应该分别设置connectTimeout和readTimeout。
  • 坑二:重试不计入整体超时。如果每次请求最多重试2次,每次超时5秒,那整个调用耗时会达到15秒。我习惯在调用层设一个总超时,超过3秒直接熔断,重试也救不了。
  • 坑三:本地开发把超时设很大。上了生产环境忘了改。本地网络好延迟低,开发没问题;线上稍微一卡,超时半天都不触发。
一句话总结: 理解TCP的RTO和重试机制,才能让你应用层的超时和重试不瞎搞、不出bug。