一个连接要经过几次超时,才有资格说失败
我写过一段很短的探测代码,核心就三行:建 socket、设一个 1 秒的超时、连一下。
s = socket.socket()
s.settimeout(1)
try:
s.connect((host, port))
print("up")
except socket.timeout:
print("down") # 我把这里当成「端口没开」
这段代码在我手里跑了很久,结论一直很稳定:目标「没开」。直到有一天我把异常类型和耗时一起打出来, 才发现十次探测里有十次抛的是 socket.timeout,一次 ConnectionRefusedError 都没有。 而在我这台机器上,拒绝一个连接要花 2032 ~ 2060 毫秒——比 1 秒长。 也就是说,我的超时永远先到期,我从来没等到过那个「拒绝」。
拒绝和超时是两个完全不同的事实。前者说明有东西在回应你,只是它不认识这个端口; 后者说明你可能连说话的对象都没有。把它们合并成一个布尔值,等于主动放弃分辨能力。
一次连接要穿过的四层等待
把整条链路上会等的地方列出来,量级差得离谱:
| 层 | 我在实测里看到的量级 | 给多少合适 |
|---|---|---|
| TCP 连接建立(含被防火墙丢包时的重传) | 本机被拒 2.0 ~ 2.1 秒;远端跨省 30 ~ 120 毫秒 | 3 ~ 5 秒 |
| 单点登录回跳(302 链 + 票据校验) | 一次完整登录 20.7 秒 | 30 秒 |
| 应用层首次响应(容器/工作区启动) | 从点进去到侧边栏可用约 100 秒 | 不是超时问题,是异步任务 |
| WebSocket 连接建立 | 不设上限时可能永远挂着 | 必须显式给 10 秒 |
第一行那个 2 秒最反直觉。它不是因为服务慢,是因为握手包被丢了,客户端按操作系统的重传节奏 等了一轮。整齐的两秒(十个样本全落在 30 毫秒的区间里)说明它是一条固定的路径, 不是随机的网络抖动。这个「整齐」本身就是判据:抖动不整齐,机制才整齐。
超时要按层设,不能设一个总数
我后来把探测改成三段,每段一个数字,各管各的:
t_connect = 4 # 连接建立:要能覆盖被丢包后的重传
t_read = 15 # 等服务端第一个字节:跟业务有关
s.settimeout(t_connect)
s.connect((host, port))
s.settimeout(t_read) # 连上之后换一个超时,不要复用
关键在于第二行 settimeout:连接阶段和读取阶段该用不同的值。 连接阶段慢通常意味着包丢了,读取阶段慢通常意味着服务在算。这两件事的对策完全相反: 前者的对策是换网络路径,后者的对策是加时间或者异步化。 用一个 5 秒把两段都盖住,两种问题会产出同一种日志。
WebSocket 那一层最容易忘
HTTP 客户端基本都带默认超时,而 WebSocket 客户端常见的行为是「一直等」。 我现在的连接代码里,建立连接这一步固定写成:
ws = await websockets.connect(url, open_timeout=10, ping_interval=None)
open_timeout 是建立握手那一步的上限,跟收到第一条消息之后的空闲超时不是一回事。 少了它,对方不回应握手的时候,进程会安静地停在那里,日志上什么都不写—— 既不报错也不退出,看上去像「卡住了」,实际上是在等一个永远不会来的响应。
同一次改造里我还把 ping_interval 显式设成 None。 默认的心跳在某些服务上会被当作异常流量直接断开,断开发生在空闲期, 症状是「用着用着突然没了」,很难归因。
三条结论
一、超时值必须大于你实测到的正常上限,而不是你期望的值。我实测拒绝要 2 秒, 设 1 秒就等于把「拒绝」永久翻译成「超时」。
二、连接阶段和读写阶段的超时要分开设。它们的失败含义不同,混在一起就再也分不开。
三、WebSocket 一类长连接,建立阶段必须显式给上限。没有上限的等待不会报错, 它只是让进程安静地停住,这是最难查的一类故障。
这三条都不需要多聪明,只需要在写第一行网络代码的时候问一句: 我准备等的这个东西,正常情况下要花多久?如果答不上来,那个数字就是猜的。
- 点赞
- 收藏
- 关注作者
评论(0)