
文章目录
-
- [一、TIME_WAIT 是主动关闭方的「墓碑」](#一、TIME_WAIT 是主动关闭方的「墓碑」)
- [二、实测:一条短连接,一个 30 秒的坟](#二、实测:一条短连接,一个 30 秒的坟)
- 三、反常识:它不是慢慢消化的
- 四、撞墙那一刻,报错不是教科书那样
- 五、解法:从根上不产生坟
- [六、30 秒带走版](#六、30 秒带走版)
上一篇讲连接池,结尾留了个问题:短连接除了慢,还会怎样?这篇给它一个戏剧性的答案。
我在本机做了个实验:对一个测试服务连续发起短连接------连上、发一个字节、客户端主动关闭------然后数着 netstat 里的 TIME_WAIT。第 16,216 次,连不上了。
没有报「端口不足」,没有 EADDRNOTAVAIL,而是一个朴素的 TimeoutError。这篇文章讲清楚这 16,216 是怎么数出来的、中间的每一秒发生了什么,以及为什么你的代码迟早会撞上同一面墙。
一、TIME_WAIT 是主动关闭方的「墓碑」
TCP 四次挥手,先发起关闭的那一方 要付代价:进入 TIME_WAIT,待够 2×MSL(MSL=Maximum Segment Lifetime,报文在网络里的最长寿命)才彻底消失。
为什么需要这个墓碑?两个理由,都和「网络上还有旧报文」有关:
- 最后的 ACK 可能丢。主动方收到对方的 FIN 后要回最后一个 ACK;这个 ACK 如果在路上丢了,对方会重传 FIN------主动方必须还在,才能补发 ACK,让对方正常关闭;
- 旧报文可能迟到 。如果立刻用同一个四元组(源 IP:端口 + 目标 IP:端口)建新连接,旧连接的延迟报文可能被新连接误收,造成数据错乱。等够
2×MSL,旧报文自然消亡。
TIME_WAIT 期间,这个四元组被占着。对一个固定目标(同一个 IP:端口)发起连接时,可变的是源端口------而源端口的池子是有限的。
macOS 实测参数:net.inet.ip.portrange = 49152--65535,共 16,384 个端口 ;net.inet.tcp.msl = 15,000 ms ,所以 TIME_WAIT ≈ 30 秒 。这两个数一除,答案就出来了:同一个目标,30 秒窗口内最多约 546 次短连接(16384 ÷ 30)。我实测的 16,216 次撞墙,和这个理论值只差一点点------差的是系统里其他进程占用的端口。
二、实测:一条短连接,一个 30 秒的坟
向本地测试服务连续发短连接(客户端主动 close),用 netstat 数服务端视角的 TIME_WAIT:

- 50 次短连接 → 50 个 TIME_WAIT;
- 累计 500 次 → 558 个;
- 累计 1,000 次 → 1,591 个。
连接数与 TIME_WAIT 数基本 1:1 ------每一条短连接,都在对方的连接表里留一个 30 秒的坟。注意坟是「对方视角」也有份:服务端如果也主动 close(HTTP/1.0 服务端就是每次响应完就关),服务端侧同样堆 TIME_WAIT------只是服务端的 TIME_WAIT 共享同一个 listen 端口,占内存,不占 ephemeral 端口。
三、反常识:它不是慢慢消化的
我原以为 TIME_WAIT 会像队列一样慢慢消化------每秒消失几个,平滑下降。实测完全不是:

55 个 TIME_WAIT,原封不动挂了 32.6 秒 ,然后在第 33.6 秒集体归零 。另一组 1,643 个的实测:30.5 秒归零。
断崖,不是斜坡。原因是每条连接的 TIME_WAIT 是各自计时的(从「最后一次握手」起算 2×MSL),而这批连接是同一时刻建立的------所以它们也几乎同时到期 。这个形态对排障很重要:如果你在 31 秒时看 netstat,看到的不是「快消完了」,而是「一个都没少」。指标上看不到进展,但再过 3 秒全部清零------「看着没动」和「马上全好」可以同时是真的。
四、撞墙那一刻,报错不是教科书那样
第 16,216 次连接失败。错误是:
TimeoutError: [Errno 60] Operation timed out
不是全网教程都在讲的 EADDRNOTAVAIL(Cannot assign requested address)。
这不是实验失败,是诚实的观察:端口耗尽时的错误形态取决于内核状态 。端口池见底后,connect() 拿不到源端口,SYN 发不出去,最终以连接超时的形式浮出水面------Linux 上更常见干净的 EADDRNOTAVAIL,但 macOS 这次给的是超时。排障时别只认那一种错,短连接洪水后的「连接超时」也要把端口耗尽列进怀疑名单。
顺带一个服务端侧的数字:撞墙时服务端 TIME_WAIT 计数 16,883------客户端端口池(16,384)和服务端连接表几乎同时见底。
五、解法:从根上不产生坟

方案一,连接池(正解,上一篇的主角)。 一条长连接复用 100 次请求:TIME_WAIT = 0 。100 次短连接:TIME_WAIT = 100 。同样 100 个请求,坟数 0 与 100 的差别。这就是 requests.Session / PoolManager 除了「省握手耗时」之外的第二个价值------省端口。
方案二,让服务端当主动关闭方要小心。 如果服务端先关(HTTP/1.0 模式每次响应完就关),坟堆在服务端:不占 ephemeral 端口,但每个坟占内核内存,几千个并发短连接就是几千个 TIME_WAIT 结构。HTTP keep-alive(服务端保持连接)把「每请求一次坟」变成「一条连接零座坟」。
方案三,系统参数是兜底不是首选。 Linux 上 net.ipv4.tcp_tw_reuse=1 允许内核把 TIME_WAIT 的四元组复用给出站新连接(靠 TCP 时间戳保证安全,只对作为客户端的出站连接有效);扩大 ip_local_port_range 能提高上限。但这些都是「把墙往后挪」------连接池是「不撞墙」。另外 SO_REUSEADDR 救不了这个场景:它管的是 bind() 重绑监听端口,不出站端口耗尽。
六、30 秒带走版
- TIME_WAIT 是主动关闭方 的代价,时长
2×MSL(macOS 实测约 30 秒,Linux 一般 60 秒); - 短连接与 TIME_WAIT 1:1------50 条短连接留 50 个坟;
- TIME_WAIT 不是慢慢消化,是整批到点集体消失(实测 55 个挂 32.6 秒后断崖归零)------排障时「看着没动」可能意味着「马上全好」;
- 同一目标 30 秒窗口内的短连接上限 ≈ 端口数(macOS 16,384,实测第 16,216 次撞墙)------约 546 次/秒的可持续速率;
- 撞墙报错不一定是
EADDRNOTAVAIL,也可能是连接超时------取决于内核; - 正解是连接池(100 请求 0 座坟);
tcp_tw_reuse是兜底,SO_REUSEADDR不管这个场景。
