网络基础: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这边还在努力重传旧的包,两边数据就乱了。
3. TCP Keep-Alive(连接空闲超时)
TCP Keep-Alive不是给你保活的,而是用来检测死连接的。默认情况下,Linux在连接空闲7200秒(2小时)后,才发送Keep-Alive探测包。
这个跟HTTP长连接的空闲超时完全两码事。很多同学混淆了这两个概念,以为设了TCP Keep-Alive就能让连接一直活着。其实它只是帮你回收死连接,效率还很低。
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) | ⚠️ 谨慎重试 | 服务已下线,重试只会浪费资源 |
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)
避坑指南:我曾经踩过的坑
- 坑一:超时不区分读写。很多早期代码里只设了一个
timeout,这其实是连接超时+读取超时共享。你应该分别设置connectTimeout和readTimeout。 - 坑二:重试不计入整体超时。如果每次请求最多重试2次,每次超时5秒,那整个调用耗时会达到15秒。我习惯在调用层设一个总超时,超过3秒直接熔断,重试也救不了。
- 坑三:本地开发把超时设很大。上了生产环境忘了改。本地网络好延迟低,开发没问题;线上稍微一卡,超时半天都不触发。