我的服务调用下游一切正常,可一到高峰期就大量报连接失败、报错说没有可用端口了,我的机器明明负载不高、内存也充足,排查半天发现是几万个处于 TIME_WAIT 状态的连接把本地端口耗光了的深度复盘
这是一次让我对"一个东西用完之后,并不会立刻消失"有了刻骨认知的事故。我有个服务,需要频繁地调用一个下游接口。我的写法很"干净":每次调用都新建一个连接、发请求、拿到响应、然后立刻关掉连接,用完即走,绝不留恋。平时跑得好好的,我也觉得这写法清清爽爽、没毛病。
可一到流量高峰,灾难就来了:服务大量报错,说"无法分配请求的地址 / 没有可用端口(Cannot assign requested address)",新的下游调用建立连接接连失败。我第一反应是机器扛不住了,可一看监控傻眼:CPU 不高、内存充足、下游也好好的,机器从各项资源看都很闲,凭什么连接就建不出来了?我百思不得其解,直到我在机器上敲了一行 netstat 统计连接状态,整个人都不好了:有几万个连接,全都处于一个我平时压根没注意过的状态——TIME_WAIT!正是这几万个"已经用完、却还赖着不走"的连接,把本地可用的端口号占得一干二净,新连接连个能用的端口都分配不到。
故障现场:机器很闲,端口却被几万个 TIME_WAIT 占满
我把当时的连接状态统计还原出来,问题一目了然:
# 高峰期在服务机器上统计 TCP 连接状态:
$ netstat -ant | awk '{print $6}' | sort | uniq -c | sort -rn
38000 TIME_WAIT ← 三万八! 几乎占满了本地端口
420 ESTABLISHED ← 真正在用的连接其实没几个
12 LISTEN
# 本地可用端口范围(临时端口):
$ cat /proc/sys/net/ipv4/ip_local_port_range
32768 60999 ← 只有约 2.8 万个可用端口
# 现象: TIME_WAIT 数量 > 可用端口数 → 新建出站连接时无端口可用
# → connect() 失败: "Cannot assign requested address" (EADDRNOTAVAIL)
# 关键: 这些 TIME_WAIT 是【我主动关闭连接】留下的:
# 每次调用 = 新建连接 + 用完【我方主动 close】
# 主动关闭的一方, 会进入 TIME_WAIT, 并停留 2*MSL(通常 60 秒)才释放
# 高峰期每秒新建/关闭几百上千个连接 → TIME_WAIT 疯狂堆积、来不及释放
看着那三万八千个 TIME_WAIT,我才彻底明白:我以为"close 掉连接"就等于"连接立刻、彻底地消失了、资源马上还回去了",可 TCP 根本不是这样——主动关闭连接的一方,在 close 之后会进入一个叫 TIME_WAIT 的状态,并在这个状态里停留 2 倍 MSL(通常是 60 秒左右)才真正释放,期间它仍然占着那个本地端口。而我"用完即关"的写法,让我的服务成了每一个连接的主动关闭方;高峰期每秒成百上千次"新建—关闭",产生的 TIME_WAIT 远比 60 秒内能释放的多,于是它们像潮水一样堆积,直到把不到 3 万个本地端口全部占满。我以为连接"用完就没了",其实它"用完还要再赖 60 秒"——而正是这 60 秒的"尸体",在高峰期堆成了山。
第一件事:搞懂 TIME_WAIT——它不是泄漏,是 TCP 为可靠关闭留的"缓冲期"
冷静下来,我去把"TCP 连接关闭与 TIME_WAIT"这一课认真补了,才明白这个状态的来龙去脉:
【TIME_WAIT 是什么, 为什么会有它】
TCP 四次挥手关闭连接时:
- 【主动关闭】的一方(先发 FIN 的那方), 在最后会进入 TIME_WAIT 状态
- 它要在 TIME_WAIT 停留 2*MSL(MSL=报文最大生存时间, 常见合计约 60 秒)
- 这期间, 这个连接占用的【本地端口】不会被释放
为什么要有 TIME_WAIT(它不是 bug, 是必要设计):
1. 确保最后一个 ACK 能到达对端: 万一 ACK 丢了, 对端会重发 FIN,
主动方还在 TIME_WAIT 就能再回 ACK; 否则对端关不干净
2. 让本次连接的"迷途旧报文"在网络中自然消亡, 不会窜进
"相同四元组的新连接", 污染新连接的数据
谁会堆积 TIME_WAIT:
- 【主动关闭连接的一方】才有 TIME_WAIT
- 所以"每次请求都由我方新建并主动关闭"的短连接模式, 我方狂积 TIME_WAIT
- 而出站连接要占一个【本地临时端口】, 端口范围有限(约2.8万)
→ TIME_WAIT 堆积超过端口数 → 新出站连接无端口可用 → 建连失败
核心认知:
TIME_WAIT 不是泄漏、不是错误, 是 TCP 为"可靠关闭"付出的、合理的代价。
问题不在 TIME_WAIT 本身, 而在【我制造了太多需要主动关闭的短连接】,
让这个本该少量、短暂的状态, 堆积成了压垮端口的洪水。
这一下点醒了我:问题的根子,不是 TIME_WAIT "有毛病",而是我"用完即关"的短连接模式,逼着我的服务不停地当主动关闭方、不停地产生 TIME_WAIT。TIME_WAIT 本身是 TCP 为了"干净、可靠地关闭连接"必须付出的代价,它合理且短暂——前提是连接别建得那么频繁。我却把"每次都新建、用完就关"当成了优雅,殊不知每一次"关",都留下了一个要赖 60 秒的端口占用;量一大,这些"合理的小代价",累积成了"压垮系统的大灾难"。不是某一次关闭错了,而是我让"关闭"这件带着延迟代价的事,发生得太频繁了。
第二件事:正解——从源头减少短连接,用连接池/长连接复用,别频繁建关
找到根因,正解就清晰了:治本之策是从源头减少"新建—主动关闭"的次数——用连接池/长连接(keep-alive)复用连接,而不是每次请求都新建用完就关;辅以内核参数(如 tcp_tw_reuse)和合理的端口范围作为兜底。让 TIME_WAIT 回到它本该有的"少量、短暂"。
// 错误: 每次请求都新建 client/连接, 用完关闭 → 我方狂积 TIME_WAIT
func callBad() {
client := &http.Client{} // 每次新建, 底层连接用完就关
resp, _ := client.Get("http://downstream/api")
resp.Body.Close() // 连接关闭, 我方进入 TIME_WAIT
}
// 正解: 复用一个带连接池的 client(长连接 keep-alive), 全局共享
var sharedClient = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 100, // 连接池: 复用空闲连接
MaxIdleConnsPerHost: 100, // 对同一下游保留足够的空闲连接
IdleConnTimeout: 90 * time.Second,
},
}
func callGood() {
resp, _ := sharedClient.Get("http://downstream/api")
defer func() {
io.Copy(io.Discard, resp.Body) // 读完 body, 连接才能回池复用
resp.Body.Close() // 这里的 Close 是"归还到池", 不是真关
}()
// ... 用 resp ...
// 连接被复用, 不再频繁新建/主动关闭 → TIME_WAIT 大幅减少 ✓
}
这套做法的精髓,是把"一次性的、用完就扔的短连接",换成"建立一次、反复使用的长连接":连接池里的连接发完一个请求后不关闭、而是放回池里给下一个请求用,新建和关闭的次数从"每请求一次"骤降到"偶尔补充/回收",TIME_WAIT 自然就降下来了。这不仅解决了端口耗尽,还顺带省掉了每次新建连接的三次握手、TLS 握手开销,性能也更好。治本是"少制造需要关闭的连接",而不是"想办法让关闭快点"。
【缓解/解决 TIME_WAIT 堆积, 按"治本→兜底"排】
治本(强烈推荐):
1. 连接复用: 用连接池 / HTTP keep-alive / 长连接, 别每次新建用完就关
—— 从源头减少"主动关闭"的次数, TIME_WAIT 自然降下来
2. 让"对端"做主动关闭方(如让下游/服务端先关), TIME_WAIT 就堆在对端
兜底(治标, 配合用):
3. 开启 net.ipv4.tcp_tw_reuse=1: 允许新出站连接复用 TIME_WAIT 的端口
(对客户端/出站方向安全有效)
4. 扩大本地端口范围 ip_local_port_range, 增加可用端口
5. 多目标地址/多源 IP 分散四元组
⚠ 别乱用 tcp_tw_recycle: 在 NAT 环境下会丢连接, 新内核已移除, 不要用
第三件事:其他"用完没立刻释放、积少成多压垮系统"的同类坑
顺着"资源用完后有延迟释放、频繁使用就会堆积"这条线,我把系统里同类的坑都排查了一遍,它们都源于"把'用完'当成了'立刻归还'":
第一个,数据库连接不复用。和 TCP 短连接同理——每次操作新建数据库连接、用完关,既慢又积压资源。该用连接池复用。
第二个,文件句柄/socket 没及时关或关了有延迟。大量打开文件/socket,受 ulimit 限制,用完没复用、或释放有延迟,高频时句柄耗尽。
第三个,线程/goroutine 频繁创建销毁。每个任务新建线程、跑完销毁,创建销毁有开销、且可能短时堆积。用线程池/协程池复用。
第四个,对象频繁分配触发 GC 压力。海量短命对象用完等 GC 回收,回收有延迟,高频分配下内存堆积、GC 频繁。热点路径可用对象池复用。
第四件事:短连接 vs 长连接复用,一张表看清差别
我把"每次新建用完就关的短连接"和"连接池/长连接复用"的关键差别整理成一张表,这是我现在设计任何频繁外部调用时的依据:
| 维度 | 短连接(每次新建+关闭) | 长连接/连接池复用 |
|---|---|---|
| TIME_WAIT | 狂积(我方频繁主动关闭) | 极少(很少新建/关闭) |
| 本地端口 | 高峰易耗尽、建连失败 | 占用少而稳定 |
| 握手开销 | 每次都三次握手/TLS,慢 | 复用已建连接,省握手 |
| 性能 | 高频时差 | 明显更好 |
| 实现 | 看着"干净"但是陷阱 | 用连接池/keep-alive |
| 适用 | 极低频、一次性 | 频繁调用同一下游 |
这张表让我看清:"用完就关"这种看起来最干净、最符合直觉的写法,恰恰是高频场景下的陷阱——它把"关闭的代价(TIME_WAIT、握手)"乘以了请求次数;而连接复用用"建立一次、反复使用"摊薄了这些代价。频繁调用同一下游,就该复用连接,而不是每次都新建一个又亲手把它推进 TIME_WAIT。
第五件事:我对"关闭连接"的几个想当然
这次事故,本质是我对"连接 close 之后会怎样"抱了一堆想当然。把它们列出来,每一条都值得警惕:
| 我曾经的想当然 | 事故教我的真相 |
|---|---|
| "close 连接,资源就立刻全还回去了" | 主动关闭方会进 TIME_WAIT,占端口约 60 秒才释放 |
| "用完就关最干净、最不会出问题" | 高频下狂积 TIME_WAIT,反而耗尽端口 |
| "建连失败一定是机器/下游扛不住了" | 资源很闲也可能因 TIME_WAIT 占满端口而建连失败 |
| "TIME_WAIT 是 bug / 该想办法干掉" | 是 TCP 可靠关闭的必要设计,该减少短连接而非消灭它 |
| "每次新建连接没什么成本" | 有握手/TLS 开销 + 留下 TIME_WAIT,高频代价巨大 |
| "端口那么多,哪会用完" | 本地临时端口仅约 2.8 万,TIME_WAIT 很容易占满 |
第六件事:设计频繁外部调用、排查建连失败时,我现在的自检习惯
现在每当我设计频繁的外部调用、或排查"资源很闲却建连失败",我都会先按这张图问自己:
这张图的精髓,是"频繁调用同一下游就别每次新建用完即关、用连接池复用;建连失败先查是不是 TIME_WAIT 占满了端口"。设计就用连接池/长连接复用、让新建关闭次数最小化、排查就netstat 看是不是几万个 TIME_WAIT 把端口占光了。这套习惯,让我从"用完即关图干净"变成了"复用连接、把关闭的代价摊薄"——核心始终是:TCP 主动关闭连接的一方会进入 TIME_WAIT 状态、占用本地端口约 2*MSL(常约 60 秒)才释放,这是为可靠关闭(确保末ACK送达、让旧报文消亡)付出的必要代价;每次请求都新建并主动关闭的短连接模式,会在高峰期狂积 TIME_WAIT、把仅约 2.8 万个本地临时端口耗尽、导致新出站连接建连失败(机器却很闲);正解是从源头用连接池/长连接 keep-alive 复用连接、大幅减少新建与主动关闭的次数,辅以 tcp_tw_reuse、扩端口范围、让对端主动关闭等兜底。
我立下的几条规矩
这场"TIME_WAIT 堆积耗尽端口"的事故,换来了我做网络编程时,刻进骨子里的几条铁律:
- TCP 连接 close 不等于资源立刻释放:主动关闭方会进 TIME_WAIT,占端口约 60 秒。
- TIME_WAIT 是 TCP 可靠关闭的必要设计(保末 ACK 送达、让旧报文消亡),不是 bug、别想着消灭它。
- 每次请求新建连接用完就关的短连接模式,高峰期会狂积 TIME_WAIT、耗尽本地端口(仅约 2.8 万)。
- 治本是连接复用:连接池/长连接 keep-alive,从源头把新建与主动关闭的次数降到最低。
- 兜底可用 tcp_tw_reuse=1、扩大端口范围、让对端做主动关闭方;但别用已废弃的 tcp_tw_recycle。
- 建连失败别只怪机器/下游,先 netstat 看是不是 TIME_WAIT 把端口占满了。
- 同理一切资源:连接、句柄、线程、对象,频繁建销代价高,该用池化复用而非用完即弃。
附:一段帮我快速定位"是不是 TIME_WAIT 耗尽端口"的排查脚本
这是我后来沉淀下来的一段排查脚本,专门用来在"建连失败、机器却很闲"时,一口气确认是不是 TIME_WAIT 把端口占满了——现在它是我遇到这类报错时的第一道诊断:
#!/bin/bash
# 排查"端口耗尽 / 建连失败"是不是 TIME_WAIT 导致的
echo "=== 各 TCP 状态连接数 ==="
ss -ant | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
# 若 TIME-WAIT 数量巨大(几万), 基本就是它了
echo "=== 本地可用临时端口范围 ==="
sysctl net.ipv4.ip_local_port_range
# 算一下区间大小, 这就是出站连接能用的端口总数(常约 2.8 万)
echo "=== TIME_WAIT 总数 ==="
tw=$(ss -ant state time-wait | wc -l)
echo "TIME_WAIT = $tw"
# 若 TIME_WAIT 数量逼近/超过可用端口数 → 端口被占满, 新出站连接必然失败
echo "=== 谁在狂建短连接(按对端地址聚合 TIME_WAIT) ==="
ss -ant state time-wait | awk '{print $5}' | sed 's/:[0-9]*$//' \
| sort | uniq -c | sort -rn | head
# 找出 TIME_WAIT 主要堆在和哪个下游之间 → 那就是该改连接复用的调用
echo "=== 当前 tcp_tw_reuse 是否开启(兜底手段) ==="
sysctl net.ipv4.tcp_tw_reuse
这段脚本把我这次的排查路径固化了下来:先看各状态连接数(一眼认出海量 TIME_WAIT)、再比对可用端口范围(确认端口被占满)、然后按对端地址聚合 TIME_WAIT(揪出是和哪个下游之间在狂建短连接)、最后看兜底参数有没有开。有了它,下次再遇到"机器很闲却建连失败"这种乍看莫名其妙的报错,我几十秒就能确认是不是 TIME_WAIT 在作怪、以及该去改哪个调用的连接复用——而不必再像这次一样,从 CPU、内存、下游一路怀疑过来、白白折腾大半天。把一次惨痛的排查,沉淀成一段下次能秒级复用的脚本,这本身就是对"复用而非重复"这个道理最好的践行。
这件事过后,我把服务里所有对外的调用都过了一遍,凡是高频访问同一下游、却还在每次新建客户端的,统统改成了全局共享的连接池。改完再压测,高峰期的 TIME_WAIT 从三万八千骤降到几百,端口耗尽的报错再没出现过,顺带连下游调用的平均延迟都低了一截——省掉的那些握手时间实打实地回到了响应里。那一刻我真切体会到:很多时候性能和稳定性并不冲突,把一件带代价的事少做几次、复用起来,两头的好处会一起到来。
写在最后
回头看,这场由"短连接狂积 TIME_WAIT"引发的"机器很闲却端口耗尽、建连失败"事故,真正教给我的,远不止"用连接池"这一个技巧。它让我对"一件'用完'的事, 并不总是'立刻、彻底地了结'; 它往往会留下一个'需要时间才能散去的尾巴'(一段占用、一份代价、一个待清理的残留)。单看一次, 这尾巴微不足道; 可当我们高频地重复这件事时, 一条条本该转瞬即逝的小尾巴, 会来不及消散、层层叠叠地堆积起来, 最终拖垮整个系统",有了一次刻骨的体会。我栽跟头,是因为我把'用完即关'这个动作, 当成了'零成本、瞬间完成、不留痕迹'的——我以为'close'之后, 这个连接就干干净净地消失了, 端口立刻就还回来了;我没意识到, 每一次'关闭'都拖着一条长达 60 秒的'TIME_WAIT 尾巴', 在这 60 秒里它仍占着端口不放;低频时这尾巴一闪即逝、我永远看不见; 可一到高峰, 我每秒制造上千条尾巴、而每条要赖 60 秒, 它们便迅速堆成了淹没端口的洪水。这让我领悟到一个关于"频率、残留与累积"的深刻认知:很多操作都有一个'完成之后仍需时间才能真正了结'的'余波/尾巴'——一段未释放的占用、一份待清理的残留、一个冷却期; 这个尾巴在'低频'下完全无害(它来得及在下一次之前散尽), 却会在'高频'下变成致命的累积(产生的速度远超消散的速度);所以判断一个操作'安不安全/可不可持续', 不能只看'单次它干净不干净', 还要看'它的尾巴有多长 × 我多频繁地重复它'——单次无害的事, 高频重复可能酿成灾难;而最好的对策, 往往不是'想办法让尾巴消失得更快'(治标), 而是'从源头减少制造尾巴的次数'(治本)——复用代替新建、批量代替零散、让一次准备服务于多次使用。这给了我一种看待"一切'高频重复某个带余波的操作'之事"时的清醒:每当我要高频地重复一个操作时,要追问"这个操作完成后, 真的就彻底了结了吗?它会不会留下一条需要时间才能散去的尾巴(占用、残留、冷却)?当我把频率乘上去, 这些尾巴会不会来不及消散、堆积成灾?"——对于带余波的操作, 优先想办法'减少重复的次数'(复用、池化、批处理), 而不是放任自己高频制造、再去清理;"看清操作的余波、用复用与池化减少高频重复",是写出扛得住高峰的系统、也是做对一切'高频操作'设计的关键。认清 close 后有 TIME_WAIT 余波、短连接高频会堆积耗尽端口、治本靠连接复用而非消灭 TIME_WAIT——这,是我用一次端口耗尽、建连失败的事故,换来的、关于网络、也关于如何看待频率与累积的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写下"新建连接、用完就关"、又打算高频调用它时,先想想"每次关闭都留了条 60 秒的尾巴,我这么频繁地建关,端口扛得住吗?",并果断换上连接池,那我对着那三万八千个 TIME_WAIT 折腾的大半天,就值了。
—— 别看了 · 2026