今天同一次会话里,同一台机器对 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,退出码就是结论)。
先把 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 秒就替你决定了结论。你的数字只在这条线以内才有意义。
而如果这条路径不是「没人应答」而是「有人拒绝」,时间尺度会掉三个数量级。
同一时刻对本机发十次请求,逐阶段计时:
| 目标 | 判定 | 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 倍:
no-such-host.invalid 用 1.0 毫秒 失败——名字根本不存在,解析器立刻说「没有」。127.0.0.1:18098(本机一个确定关闭的端口)用 2.0010 秒 失败。raw.githubusercontent.com 和 10.255.255.1 用满了我给的 6 秒,一个字节都没回来。三种情况的用户观感完全一样:卡住了、没结果。但它们该触发的动作不一样:一个是改配置,一个是重启服务,一个是换出口。
而且注意 github.com 那一行:SLOW_UP,实等 0.80 秒,比 api.github.com 的 0.75 秒只慢 6.4%——同一个域的一对端点,会话开始时还一个是 000、一个是 200,几分钟后几乎同速。 用「超时了」三个字记录这两件事,等于把「今天网络抖了一下」和「这个域名长期不可达」写进同一个格子。
这是今天最不该出现、但确实复现了三次的现象。
一个确定关闭的环回端口(绑一个端口再关掉,确保没人监听),只改探测阈值:
== 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
三件事:
timeout=2 判「SYN 没应答」,timeout=3 判「对端拒绝」。判定名不是事实,读数才是。 任何把
timeout调成整数(1/2/5/10)的习惯,都在赌自己的阈值不会正好压在某个真实延迟的边界上。
工具只输出六种终态,因为再多的名字就开始互相污染:
| 判定 | 标志 | 意思是 | 退出码 |
|---|---|---|---|
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 主动停(未被截断,是探测自己设的上限)」。工具自己的上限不能算成对端的截断——这两件事在读数里长得像,在结论里完全不同。
同一批目标相隔十分钟跑两轮,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 握手」,那条结论十分钟后就被自己的读数推翻了。相位拆分用来定位「断在哪一段」,不用来估算容量。
| 阶段 | 谁决定它 | 重试有用吗 | 该动的地方 |
|---|---|---|---|
| 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 超时或缩小请求 |
一条红线写在最前面:请求已经发出、且可能产生了副作用之后,不要重试。 前面的阶段可以重试,因为什么都没发生;后面不行,因为你不知道它到底发生没发生。
拿 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。
同一个语义,四种写法,其中一个是坑:
# 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 秒的故事。
# 逐阶段计时 + 六态判定 + 实等耗时
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 一致(这一步是必须的:判定逻辑要能在没有网络时复算,否则它只是当场的猜测)。
写这篇的工具时,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 才成为可引用的数字。
connect 与 read 分开给,DNS 单独算。一个数字撑不起五种故障。UP 不看时间看字节。 有进展 = 有字节到达;否则再慢也不是「慢」。timeout=2 与 3 给出不同判定。ceil(次数/并发) × (connect+read) + 退避:实测串行 3×6s = 18.01/18.19s,并行 3 次 = 6.05s(3.0x)。200 + Content-Length 未满足 = TRUNCATED,不能当成功(这条和截断那篇是同一件事的两端)。elapsed。 零字节故障时各相位都可能是 None,只有实等时长能进预算。0 有进展 / 2 连上了没走完 / 3 没连上。2 不是成功,3 也不是「重试一下就好」。github.com 在一小时内从 000 变成 SLOW_UP;任何「这个域名稳定/不稳定」的结论都需要跨时间采样。SLOW_UP 与 UP 的分界是阈值。 阈值是人给的,所以这个标签是人的判断,不是对端的事实。read_cap 是我自己的上限。 读到它停下会被标成「未被截断」,绝不冒充对端给的完整响应。deepest_phase 是唯一权威。 没有它,classify 只返回 UNKNOWN——宁可说不知道,也不猜一个动作相反的结论。「超时了」不是一条读数,是一个结论;而这个结论至少需要五个数(DNS / TCP / TLS / 首字节 / 整包)加一个字节计数才成立。 少一个,你就不小心在用一个阈值,冒充对网络的理解。
📚 更多创作资源: https://afdian.com/a/felix007