← 返回导航站首页

同一个端口,timeout=2 判超时、timeout=3 判「拒绝连接」:把超时拆成五段,才知道该修哪一段

今天同一次会话里,同一台机器对 https://github.com/ 给出过两个完全不同的答案:

本次会话开始时  curl -w "%{http_code} %{time_total}"  ->  000   21.025939
同一会话稍后    phase_probe probe                     ->  SLOW_UP  elapsed 1.2601s  bytes 71240

中间没有任何人改配置。如果只看第一个读数,结论是「GitHub 挂了」;只看第二个,结论是「一切正常」。两个结论都能被日志支持,但都是错的——因为它们把不同阶段的现象塞进了同一个叫「超时」的桶里。

这篇文章做的只有一件事:把「超时」拆成五个可以被分别测量的阶段(DNS / TCP / TLS / 首字节 / 整包),然后用本机实测说明,为什么一个数字撑不起这个语义。

所有数字都是 2026-10-28 在本机跑出来的,工具在 tools/timeout/phase_probe.py(纯标准库,selftest 32/32,退出码就是结论)。


1. 那个 21 秒不是任何人的超时

先把 21 秒的来源坐实。它不是 curl 的 --max-time,也不是某个 SDK 的默认值——它是我没设置过、但一直生效的那个东西:操作系统的 SYN 重试放弃点。

单独测它(connect 到不可路由地址,把探测阈值放宽到 40 秒,让系统自己决定何时放弃):

== C. 系统自己何时放弃(不可路由地址,阈值给 40s)==
  10.255.255.1:80  21.015s  TimeoutError: [WinError 10060] 由于连接方在一段时间后没有正确答复...

21.015 秒。 会话开始时那次 curl 的 time_total 是 21.025939。两个数只差 10.94 毫秒,指向同一件事:那 21 秒不是我给的预算,是内核的预算。

这条推论很硬:

把「超时」从一个数改成 30 / 60,你在这一层等于什么都没做——内核早在 21 秒就替你决定了结论。你的数字只在这条线以内才有意义。

而如果这条路径不是「没人应答」而是「有人拒绝」,时间尺度会掉三个数量级。


2. 十个目标,五种「连不上」

同一时刻对本机发十次请求,逐阶段计时:

目标 判定 DNS TCP TLS 首字节 字节 实等
api.github.com/rate_limit UP 0.0147 0.0963 0.2038 0.1579 1,364 0.7549
github.com/ SLOW_UP 0.0066 0.0795 0.0827 0.2366 71,234 0.8030
codeload.github.com/…tar.gz SLOW_UP 0.0005 0.0854 0.0775 0.4193 66,399 0.7404
felixwang007.github.io/… UP 0.0006 0.2526 0.2344 0.4839 20,217 1.7553
api.deepseek.com/ UP 0.0038 0.0227 0.0180 0.0935 703 0.1461
127.0.0.1:18099(开着) UP 0.0000 0.0102 — 0.0367 34,487 0.0472
127.0.0.1:18098(关着) CONNECT_REFUSED 0.0000 2.0009 — — 0 2.0010
raw.githubusercontent.com/… CONNECT_TIMEOUT 0.0006 6.0003 — — 0 6.0011
10.255.255.1/ CONNECT_TIMEOUT 0.0000 6.0000 — — 0 6.0003
no-such-host.invalid/ DNS_FAIL 0.0009 — — — 0 0.0010

这张表里最刺眼的不是谁快谁慢,是失败的代价差 6000 倍:

三种情况的用户观感完全一样:卡住了、没结果。但它们该触发的动作不一样:一个是改配置,一个是重启服务,一个是换出口。

而且注意 github.com 那一行:SLOW_UP,实等 0.80 秒,比 api.github.com 的 0.75 秒只慢 6.4%——同一个域的一对端点,会话开始时还一个是 000、一个是 200,几分钟后几乎同速。 用「超时了」三个字记录这两件事,等于把「今天网络抖了一下」和「这个域名长期不可达」写进同一个格子。


3. 同一个端口,判定被阈值决定

这是今天最不该出现、但确实复现了三次的现象。

一个确定关闭的环回端口(绑一个端口再关掉,确保没人监听),只改探测阈值:

== A. 关闭的环回端口:RST 到底几秒才到(阈值给 15s)==
  run1 port=11818  2.0149s  ConnectionRefusedError: [WinError 10061] 目标计算机积极拒绝
  run2 port=11818  2.0090s  ConnectionRefusedError: [WinError 10061]
  run3 port=11818  2.0064s  ConnectionRefusedError: [WinError 10061]

== B. 同一关闭端口,不同阈值的判定(2 vs 3)==
  timeout=2.0 -> CONNECT_TIMEOUT   tcp=2.0002s
  timeout=3.0 -> CONNECT_REFUSED   tcp=2.0129s

三件事:

  1. 「连接被拒绝」在这台机器上不是即时的。 教科书写的是「对端回一个 RST,毫秒级」,实测是 2.01 秒(三次复跑:2.0149 / 2.0090 / 2.0064)。这 2 秒来自 SYN 重传节奏叠加本机的网络过滤驱动,不是我的代码。
  2. 阈值 2.0 正好落在 RST 到达时刻之前 2 毫秒。 于是同一个端口、相隔不到一秒,timeout=2 判「SYN 没应答」,timeout=3 判「对端拒绝」。
  3. 这两个判定在监控面板上是两个不同的告警,而事实只有一条。

判定名不是事实,读数才是。 任何把 timeout 调成整数(1/2/5/10)的习惯,都在赌自己的阈值不会正好压在某个真实延迟的边界上。


4. 六个状态,只有一个真的叫「慢」

工具只输出六种终态,因为再多的名字就开始互相污染:

判定 标志 意思是 退出码
UP 读到 EOF,首字节 < 阈值 正常 0
SLOW_UP 读到 EOF(或探测自己的 read_cap),首字节 > 阈值 在走,只是慢 0
STALLED_NO_BYTES TCP(可能还有 TLS)已建立,零字节到达 连上了,对端不说话 2
STALLED_PARTIAL 收到若干字节,头部/body 无结束标记 说到一半停了 2
TRUNCATED Content-Length 声明 N,实收 < N,连接先关 说了,但没说完 2
CONNECT_TIMEOUT / CONNECT_REFUSED / TLS_FAIL / DNS_FAIL 到不了对端 根本没连上 3

UP 与 SLOW_UP 的区别只在阈值,但 STALLED_* 与它们的区别是有没有字节到达——这个区别不能靠时间,只能靠计数。这就是为什么「把所有 timeout 调大」是最差的修法:它把 STALLED_NO_BYTES 从「2 秒的疑问」变成「60 秒的沉默」,判定一点没变。

还有个诚实的细节:SLOW_UP 有一句说明是「读到 read_cap 主动停(未被截断,是探测自己设的上限)」。工具自己的上限不能算成对端的截断——这两件事在读数里长得像,在结论里完全不同。

4.1 相位占比不稳定,别拿单次读数做归因

同一批目标相隔十分钟跑两轮,tcp/tls/ttfb 三段的排名会换位:

目标 第一轮 tcp/tls/ttfb 第二轮 tcp/tls/ttfb
api.github.com 0.0828 / 0.0926 / 0.1629 0.0963 / 0.2038 / 0.1579
github.com 0.0980 / 0.5202 / 0.1792 0.0795 / 0.0827 / 0.2366
codeload 0.0871 / 0.0801 / 0.5013 0.0854 / 0.0775 / 0.4193

github.com 的 TLS 段从 0.5202 秒掉到 0.0827 秒(6.3 倍)。如果第一轮之后我写下「瓶颈在 TLS 握手」,那条结论十分钟后就被自己的读数推翻了。相位拆分用来定位「断在哪一段」,不用来估算容量。


5. 每段归谁管:这才是拆开它的收益

阶段 谁决定它 重试有用吗 该动的地方
DNS 解析器、上游 DNS、缓存 TTL 有时(换 resolver、热缓存) 别把 DNS 算进 connect 预算
TCP 内核 SYN 重试、防火墙/过滤驱动、中间设备 低——同一路径大概率同样结果 早失败、降阈值、换出口
TLS 证书链、SNI、中间人、后量子握手 基本无用 查证书/SNI,不是加时间
首字节 服务端排队、连接池、上游依赖 中等(幂等读可以) 缩小请求/降批量
整包 响应体大小、链路带宽、限速 中高 重试或减量

把它做成动作表:

判定 字节到达 重试 立刻要做的
DNS_FAIL 0 有时 换 resolver;别的什么都别改
CONNECT_TIMEOUT 0 低 降阈值早失败,别用 30s 等内核
CONNECT_REFUSED 0 高(服务刚起/端口临时) 带退避重试;注意 RST 也可能要 2 秒
TLS_FAIL 0 低 重试只会重复失败,去查证书
STALLED_NO_BYTES 0 中 只对幂等读重试 + 指数退避
STALLED_PARTIAL / TRUNCATED 部分 中高 重试或降批量,绝不能当成功
SLOW_UP 是 无意义 调大 read 超时或缩小请求

一条红线写在最前面:请求已经发出、且可能产生了副作用之后,不要重试。 前面的阶段可以重试,因为什么都没发生;后面不行,因为你不知道它到底发生没发生。


6. 重试预算:3 次重试就是 3 倍沉默

拿 raw.githubusercontent.com(TCP 从未建立)做基线:

== D. 重试预算:3 次重试 × 阈值 6s 的真实墙钟 ==
  串行 3 次总墙钟 18.01s      (独立复跑:18.19s)
  并行 3 次           6.05s   (3.0x)

公式很朴素,但几乎没人写进配置注释:

最坏墙钟 = ceil(尝试次数 / 并发度) × (connect_超时 + read_超时) + 退避总和
        = ceil(3 / 1) × (6 + 6)   ← 若 connect 与 read 各给 6

上面实测是 18 秒,因为每一轮的 connect 阶段就吃满了 6 秒,read 阶段根本没开始。串行重试对「连不上」是纯粹的乘法:阈值 6 秒 × 3 次 = 18 秒,一个 cron 窗口(常见 60 秒)就这么没了三分之一——而且三次尝试覆盖的是同一条已经死掉的路径。

这也解释了为什么「并行探测多个出口」在这类故障上收益是 3.0x,而「多试几次同一个出口」收益是 0。


7. 各家客户端的「分相位超时」怎么写

同一个语义,四种写法,其中一个是坑:

# requests:元组 = (connect, read),这是唯一真正分相位的常见写法
requests.get(url, timeout=(3.05, 10))        # connect 3.05s,read 10s

# httpx:显式四段
httpx.Client(timeout=httpx.Timeout(connect=3.0, read=10.0, write=5.0, pool=2.0))

# 裸 socket(本工具用的就是这层,能看见每一段)
s = socket.socket(); s.settimeout(5); s.connect(addr)   # 只约束 connect
s = ctx.wrap_socket(s, server_hostname=host)            # TLS 受同一个 settimeout
s.sendall(req)                                           # 发送同受此约束
s.recv(65536)                                            # read 也用这个值
# curl:--connect-timeout 只约束连接;--max-time 是总预算上限。
curl --connect-timeout 3 --max-time 20 -sS -o /dev/null -w '%{http_code} %{time_connect} %{time_starttransfer} %{time_total}\n' https://example.com/

curl 那个坑值得单独说:--max-time 不是分相位的,它是整包总预算。只给 --max-time 30 时,一个卡在 connect 的请求会安静地占满 30 秒——这正好是本篇开头那 21 秒的故事。


8. 工具怎么用

# 逐阶段计时 + 六态判定 + 实等耗时
python tools/timeout/phase_probe.py probe https://raw.githubusercontent.com/torvalds/linux/master/README

# 机器可读读数(喂给监控/账本)
python tools/timeout/phase_probe.py probe --json https://api.github.com/rate_limit > r.json

# 采样与判定分离:用历史读数离线复判,不重新发请求
python tools/timeout/phase_probe.py classify tools/timeout/samples/live_readings_20261028.json

# 七个本地服务端场景 + 32 项断言
python tools/timeout/phase_probe.py selftest

probe 的真实输出(对本机一个关闭端口):

URL      http://127.0.0.1:18098/
DNS      0.0 s
TCP      2.0009 s
TLS      None s
send     None s
TTFB     None s
body/全  None s
bytes    0 (declared None) status None
最深阶段 tcp
判定     CONNECT_REFUSED
实等     2.0010 s(相位全为 None 时这是唯一能进预算的数)
说明     RST: [WinError 10061] 由于目标计算机积极拒绝,无法连接。

classify 拿今天那十行读数复判,与在线判定 10/10 一致(这一步是必须的:判定逻辑要能在没有网络时复算,否则它只是当场的猜测)。


9. 今天真被自检抓住的三个 bug

写这篇的工具时,selftest 第一轮是 22/26。三个缺陷都不是笔误,而是同一个思维习惯的三种形态:

① 中途解析的头部被当成最终头部 → 正常 200 被判 TRUNCATED。
第一轮把 Content-Length 与「收到多少字节」相比,但那份头部是在第一个 TCP 分片到达时解析的,body_have 停在 0。于是 124 字节全部收到 的响应被判成「声明 11,实收 0,连接先关」。修法:循环结束后用完整缓冲区重新解析一次头部再比。

② 断言的是一条平台假设,不是不变量。
我原本断言「关闭端口 → CONNECT_REFUSED,且 < 0.05 秒」,本机直接打脸:RST 要 2.01 秒来。改成断言不变量——「判死(REFUSED 或 CONNECT_TIMEOUT)、零字节、退出码 3」,并把实测形态打印出来。测试里写死的平台性格,比不写更危险,因为它会让工具在别的机器上「正确」地把真故障报成假的。

③ 相位缺失被当成相位失败。
classify 第一版拿「URL 是 https 且 tls_s 为空」当 TLS_FAIL。于是 raw.githubusercontent.com(TCP 从未建立,根本没轮到 TLS)被判成 TLS 失败,10.255.255.1(收不到 SYN 应答)被判成 STALLED_NO_BYTES(连上了不说话)。这是两种完全相反的动作(去查证书 vs 去查服务端),却由一个空字段决定。
修法:判定必须由 deepest_phase(走到了哪一层)驱动,而不是由「哪个字段是 None」驱动;没有相位证据时不许猜,返回 UNKNOWN。这三条都补成了回归断言(现在 32/32)。

顺便说一个诚实的缺口:没有任何字段记录「探测自己等了多久」时,预算核算只能靠猜。 第一版 body_s 在零字节场景是 None,于是「3 次重试花了多久」根本算不出来。补了 elapsed_s 之后,第 6 节那 18.01 / 6.05 才成为可引用的数字。


10. 十条落地规则

  1. 超时必须分相位。 connect 与 read 分开给,DNS 单独算。一个数字撑不起五种故障。
  2. UP 不看时间看字节。 有进展 = 有字节到达;否则再慢也不是「慢」。
  3. 阈值别压在边界上。 本机 RST 到达在 2.01 秒,timeout=2 与 3 给出不同判定。
  4. 不要等内核。 系统在 21.015 秒 才放弃;你的阈值必须显著小于它,否则你在等一个不存在的决定。
  5. 重试只对「还没发生任何事」的阶段有效。 请求发出后可能已产生副作用,不重试。
  6. 算预算,不写感觉。 ceil(次数/并发) × (connect+read) + 退避:实测串行 3×6s = 18.01/18.19s,并行 3 次 = 6.05s(3.0x)。
  7. 半截 body 是第三种状态。 200 + Content-Length 未满足 = TRUNCATED,不能当成功(这条和截断那篇是同一件事的两端)。
  8. 必须记 elapsed。 零字节故障时各相位都可能是 None,只有实等时长能进预算。
  9. 采样与判定分离。 判定逻辑要能拿历史读数离线复判,否则它只是当场印象。
  10. 退出码三态。 0 有进展 / 2 连上了没走完 / 3 没连上。2 不是成功,3 也不是「重试一下就好」。

11. 边界声明(这套东西不做什么)


一句话版

「超时了」不是一条读数,是一个结论;而这个结论至少需要五个数(DNS / TCP / TLS / 首字节 / 整包)加一个字节计数才成立。 少一个,你就不小心在用一个阈值,冒充对网络的理解。


📚 更多创作资源: https://afdian.com/a/felix007