我的服务调外部接口一到高峰就报"cannot assign requested address"、机器上堆了几万个 TIME_WAIT 连接,我盯着 netstat 排查了大半天才发现连接根本没复用
这是一个让我对"TCP 连接不是免费的"这件事,刻骨铭心的故事。我有一个服务,需要频繁地去调用一个外部的 HTTP 接口。它平时跑得好好的,可一到流量高峰,就开始大面积报错,错误信息五花八门,但最扎眼的一条是:cannot assign requested address(无法分配请求的地址)。同时,接口的响应延迟,也明显变高了。我当时一头雾水:"分配地址"?我又没改网络配置,怎么会分配不出地址?
我登上机器,用 netstat 一统计,当场就惊呆了:这台机器上,竟然堆积了好几万个状态为 TIME_WAIT 的 TCP 连接!我顺着这个线索深挖,才终于揭开真相,补上了我对 HTTP 通信一个致命的认知漏洞:我的代码,在调用那个外部接口时,是每一次请求,都新建一个全新的 TCP 连接,用完之后,立刻关闭的。我从来没有复用过连接!这就带来了两个致命后果。第一,性能上:每建立一个 TCP 连接,都要付三次握手的开销,如果是 TLS,在 HTTP 之上加一层 TLS 加密,防止中间人窃听和篡改。">HTTPS,还要再付一轮昂贵的 TLS 握手;我把这些本可以复用、本可以省掉的握手开销,在每一次请求里,都重新付了一遍,延迟自然就高了。第二,也是更致命的资源上:一个 TCP 连接主动关闭后,会进入一个叫 TIME_WAIT 的状态,并停留大约 2 分钟(2MSL)才彻底释放,这期间,它仍然占用着一个本地端口;而一台机器的本地端口是有限的(大约 2.8 万个可用);当我的服务高频地新建、关闭连接,TIME_WAIT 就会疯狂堆积,很快就把所有可用的本地端口,全部占光;一旦端口耗尽,操作系统再也无法为新连接,分配出一个本地端口了——这,就是那条 cannot assign requested address 报错的真正含义!我这才痛彻地明白:TCP 连接,绝不是"用完就扔、想建就建"的免费资源;它的建立有握手成本、它的关闭有 TIME_WAIT 滞留、它占用着宝贵且有限的本地端口。在高频通信的场景下,"复用连接",不是一个可选的优化,而是一个必须的、关乎服务生死的设计。
故障现场:每次请求新建连接、用完就关,TIME_WAIT 堆积
我把这个"端口耗尽"的现场,用代码摊开给你看:
// ✗ 灾难: 每次请求都新建 client / 新建连接, 用完就关, 不复用
func callAPI(url string) ([]byte, error) {
// ✗ 每次调用都 new 一个 http.Client(或等价于每次新建连接)
client := &http.Client{
Transport: &http.Transport{
DisableKeepAlives: true, // ✗ 关掉了 keep-alive! 每次请求后立刻断开连接
},
}
resp, err := client.Get(url) // ✗ 每次都: 三次握手 (+ TLS 握手) → 请求 → 关闭
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
// 高峰期每秒成百上千次调用 → 每次新建+关闭一个连接
// 后果一(性能): 每次都重新付握手成本
// - TCP 三次握手 1 个 RTT; HTTPS 还要 TLS 握手 1~2 个 RTT。
// - 这些开销本可通过复用连接省掉, 现在每次请求都白付一遍 → 延迟高。
// 后果二(资源): TIME_WAIT 堆积 → 本地端口耗尽
// - 主动关闭连接的一方, 连接进入 TIME_WAIT, 停留约 2*MSL(~2分钟)才释放。
// - 这期间仍占用一个本地端口。
// - 本地可用端口约 2.8 万个(net.ipv4.ip_local_port_range)。
// - 高频新建+关闭 → TIME_WAIT 疯狂堆积 → 端口被占光。
// - 端口耗尽 → 无法为新连接分配本地端口 → "cannot assign requested address"!
// netstat 现场: 几万个 TIME_WAIT
// netstat -an | grep TIME_WAIT | wc -l → 几万
// 根因: 连接不复用(每次新建+关闭), 既白付握手成本, 又让 TIME_WAIT 堆积耗尽端口。
看着这段代码,我懊悔不已。问题的根源,是我每一次请求,都新建一个连接、用完就立刻关闭,从不复用(代码里甚至显式 DisableKeepAlives: true,亲手关掉了连接复用)。高峰期每秒成百上千次调用,就是每秒新建并关闭成百上千个连接,这带来两个致命后果。后果一(性能):每次都重新付握手成本——TCP 三次握手要 1 个 RTT,HTTPS 还要再付 1~2 个 RTT 的 TLS 握手;这些开销,本可以通过复用连接省掉,现在每次请求都白付一遍,延迟自然高。后果二(资源,更致命):主动关闭连接的一方,连接会进入 TIME_WAIT 状态,停留约 2×MSL(大约 2 分钟)才释放,这期间仍占着一个本地端口;而本地可用端口只有约 2.8 万个;高频新建+关闭,让 TIME_WAIT 疯狂堆积、很快把端口占光;端口一耗尽,系统就无法为新连接分配本地端口——这,正是 cannot assign requested address 的真相!归根结底:连接不复用(每次新建+关闭),既白白付了握手成本(慢),又让 TIME_WAIT 堆积、耗尽了本地端口(报错),一举两失。
第一件事:搞懂 TCP 连接的成本与 TIME_WAIT
定位到根源,我必须把"TCP 连接到底有哪些成本"这件事,从根上彻底搞清楚:
TCP 连接不是免费的: 建立有握手, 关闭有 TIME_WAIT, 占有限端口
# 建立一个连接的成本:
# - TCP 三次握手: 1 个 RTT(往返)才能开始发数据。
# - HTTPS 还要 TLS 握手: 再 1~2 个 RTT(协商密钥)。
# - 跨机房/公网, 一个 RTT 几十毫秒 → 握手就吃掉大半延迟。
# 关闭一个连接的代价: TIME_WAIT
# - 主动关闭方(先发 FIN 的)会进入 TIME_WAIT。
# - 停留约 2*MSL(Linux 默认约 60 秒)才释放。
# - 目的: 确保最后的 ACK 到达 + 让旧连接的残留报文消散(协议正确性需要)。
# - 期间: 仍占用一个 (本地ip, 本地端口, 对端ip, 对端端口) 四元组 → 占本地端口。
# 本地端口是有限的:
# - net.ipv4.ip_local_port_range 默认约 32768~60999 → 约 2.8 万个。
# - 对同一个目标 (对端ip:端口), 可用的本地端口就这么多。
# - 高频新建+关闭 → TIME_WAIT 堆积 → 端口占光 → 无法新建连接。
# 为什么"复用连接"能根治?
# - 复用 = 一个连接发完一个请求, 不关闭, 接着发下一个请求(keep-alive)。
# - 握手只在第一次付, 后续请求白嫖已有连接 → 省握手、低延迟。
# - 不频繁关闭 → 不产生大量 TIME_WAIT → 不耗端口。
# 关键认知: 高频通信必须复用连接(连接池 + keep-alive)。
# 连接是"宝贵的、要被反复使用的资源", 不是"一次性用品"。
# 核心: TCP 连接建立有握手成本、关闭有 TIME_WAIT 滞留并占有限端口;
# 高频通信不复用连接 → 既慢又会端口耗尽。
原理终于清晰了。建立一个连接,要付什么成本?——TCP 三次握手要 1 个 RTT 才能开始发数据;HTTPS 还要再付 1~2 个 RTT 的 TLS 握手;跨机房或公网时,一个 RTT 就有几十毫秒,光握手,就吃掉了大半的延迟。关闭一个连接,又有什么代价?——主动关闭的一方(先发 FIN 的)会进入 TIME_WAIT,停留约 2×MSL(Linux 默认约 60 秒)才释放;这是协议正确性的需要(确保最后的 ACK 到达、让旧报文消散);可这期间,它仍占着一个本地端口。而本地端口是有限的:ip_local_port_range 默认只有约 2.8 万个;高频新建+关闭,就会让 TIME_WAIT 堆积、把端口占光,从而无法新建连接。那为什么"复用连接"能根治?因为复用,就是一个连接发完一个请求,不关闭,接着发下一个(keep-alive):握手只在第一次付,后续请求都白嫖已有连接(省握手、低延迟);不频繁关闭,就不产生大量 TIME_WAIT,也就不耗端口。由此,我刻下一个关键认知:高频通信,必须复用连接(连接池 + keep-alive);连接,是"宝贵的、要被反复使用的资源",不是"一次性用品"。TCP 连接的建立有握手成本、关闭有 TIME_WAIT 滞留并占有限端口;高频通信不复用连接,就会既慢、又端口耗尽。
第二件事:正解——复用连接,用单例 client + 连接池
搞懂了原理,正解就清晰了:别每次新建连接,要复用——用一个全局单例的 client,内部维护连接池、开启 keep-alive,让连接被反复使用。
// ✓ 正解: 全局单例 client + 连接池 + keep-alive, 复用连接
var httpClient = &http.Client{
Timeout: 5 * time.Second, // ✓ 别忘了超时
Transport: &http.Transport{
// ✓ keep-alive 默认开启(不要 DisableKeepAlives)
MaxIdleConns: 100, // ✓ 连接池: 最多保留 100 个空闲连接复用
MaxIdleConnsPerHost: 20, // ✓ 对单个 host 最多保留 20 个空闲连接(关键!默认才 2)
IdleConnTimeout: 90 * time.Second, // 空闲连接保留 90 秒
},
}
func callAPI(url string) ([]byte, error) {
resp, err := httpClient.Get(url) // ✓ 复用池中已有连接, 无需重新握手
if err != nil {
return nil, err
}
defer func() {
io.Copy(io.Discard, resp.Body) // ✓ 关键: 读干净 Body, 连接才能放回池里复用!
resp.Body.Close()
}()
return io.ReadAll(resp.Body)
}
// 复用后的收益:
// - 握手只在第一次付, 后续请求白嫖已有连接 → 延迟大降。
// - 连接不频繁关闭 → 几乎不产生 TIME_WAIT → 端口不再耗尽。
// - netstat 里 ESTABLISHED 稳定在小数目, TIME_WAIT 寥寥无几。
// ⚠ 极易踩的坑: Body 没读完/没 Close, 连接无法放回池, 等于没复用!
// - 必须 io.Copy(io.Discard, resp.Body) 读干净, 再 Close。
// 核心: 全局单例 client + 连接池 + keep-alive, 复用连接;
// 并务必读干净 Body 再 Close, 否则连接回不了池、复用失效。
修复的方向,清晰而坚定。正解,是复用连接:用一个全局单例的 http.Client,内部配好连接池(MaxIdleConns / MaxIdleConnsPerHost)、保持 keep-alive 开启(千万不要 DisableKeepAlives),让连接用完不关、放回池里、反复使用。这里有几个关键点:第一,MaxIdleConnsPerHost 一定要调大——它默认只有 2,意味着对单个 host,最多只复用 2 个空闲连接,高并发下远远不够,会退化成"大量新建连接",所以要按并发量调到 20、50 甚至更高。第二,也是极易踩的隐蔽坑:Body 必须读干净、再 Close!如果你 Body 没读完(或没 Close),这个连接就无法被放回池里复用,等于你配了连接池,却依然在每次新建连接——必须 io.Copy(io.Discard, resp.Body) 把它读干净,再 Close。复用之后的收益立竿见影:握手只在第一次付、后续白嫖已有连接(延迟大降);连接不频繁关闭、几乎不产生 TIME_WAIT(端口不再耗尽);netstat 里 ESTABLISHED 稳定在小数目、TIME_WAIT 寥寥无几。归根结底:全局单例 client + 连接池 + keep-alive,复用连接;并务必读干净 Body 再 Close,否则连接回不了池、复用形同虚设。
第三件事:TIME_WAIT 堆积的排查与系统级缓解
除了治本的"复用连接",我也补全了一套排查 TIME_WAIT、以及系统级的缓解手段:
TIME_WAIT 排查与缓解: 先确认堆积, 治本靠复用, 系统参数是辅助
# 1. 确认 TIME_WAIT 堆积
netstat -an | grep TIME_WAIT | wc -l # 看总数(几万就是异常)
netstat -an | grep TIME_WAIT | awk '{print $5}' | sort | uniq -c | sort -rn | head
# 看 TIME_WAIT 都堆在连哪个对端 → 定位是调哪个服务没复用连接
# 2. 看本地端口范围(可用端口数)
sysctl net.ipv4.ip_local_port_range # 默认 32768 60999, 约 2.8 万
# 3. 治本: 复用连接(连接池 + keep-alive) —— 这才是根治!
# TIME_WAIT 是"主动关闭"产生的, 不频繁关闭就不会堆。
# 4. 系统级缓解(辅助, 不能替代复用):
# net.ipv4.tcp_tw_reuse = 1 # 允许复用 TIME_WAIT 连接给新的"出站"连接(客户端有效)
# 扩大端口范围: net.ipv4.ip_local_port_range = 1024 65000
# ⚠ 别用 tcp_tw_recycle: 已废弃且在 NAT 环境会丢包出大问题(高版本内核已移除)。
# 谁会产生 TIME_WAIT?
# - 主动关闭的一方。客户端频繁主动关 → 客户端堆 TIME_WAIT(本文)。
# - 服务端主动关 → 服务端堆 TIME_WAIT(如返回后立刻 close)。
# 经验:
# - 大量 TIME_WAIT 在客户端 → 八成是调外部接口没复用连接。
# - 大量 CLOSE_WAIT → 是你的程序"没有 close 连接"(另一种泄漏, 别搞混)。
# 核心: TIME_WAIT 堆积治本靠复用连接; tcp_tw_reuse/扩端口范围只是辅助;
# 切记别用已废弃的 tcp_tw_recycle。
这套排查与缓解手段,让我对 TIME_WAIT 不再恐惧。第一步,确认堆积:netstat | grep TIME_WAIT | wc -l 看总数,再 awk 统计它们都堆在连哪个对端,就能定位是调哪个服务没复用连接。第二步,看本地端口范围(ip_local_port_range),心里有数还剩多少端口。第三步,也是治本:复用连接——TIME_WAIT 是"主动关闭"产生的,不频繁关闭,就不会堆,这才是根治。而系统级参数,只是辅助、不能替代复用:可以开 tcp_tw_reuse=1(允许把 TIME_WAIT 连接复用给新的出站连接)、扩大端口范围;但千万别用 tcp_tw_recycle——它已废弃,且在 NAT 环境会丢包、出大问题(高版本内核已移除)。我还分清了一组易混淆的概念:大量 TIME_WAIT 在客户端,八成是调外部接口没复用连接(本文);而大量 CLOSE_WAIT,是你的程序没有 close 连接(另一种泄漏,别搞混)。归根结底:TIME_WAIT 堆积,治本靠复用连接;tcp_tw_reuse、扩端口范围只是辅助;切记别用已废弃的 tcp_tw_recycle。
下面这张图,是这次"端口耗尽"的来龙去脉与解法:
第四件事:连接相关报错的速查对照
这次踩坑后,我把各种"连接相关"的报错和 TCP 状态,系统整理成一张速查表,下次再遇到能快速对症。
| 现象 / 报错 | 根本原因 | 对症方案 |
|---|---|---|
| cannot assign requested address | TIME_WAIT 堆积, 本地端口耗尽 | 复用连接(连接池+keep-alive), 治本 |
| 大量 TIME_WAIT(主动关闭方) | 高频新建+关闭连接 | 复用连接; tcp_tw_reuse 辅助 |
| 大量 CLOSE_WAIT | 程序收到 FIN 却没 close 连接(连接泄漏) | 检查代码: 务必关闭/释放连接 |
| too many open files | fd 耗尽(连接/文件没关 或 ulimit 太低) | 关闭资源 + 调大 ulimit -n |
| connection reset by peer | 对端强制断开(超时/重启/被限流) | 查对端状态 + 加重试/超时 |
| 调外部接口延迟忽高忽低 | 连接没复用, 每次握手 或 池太小 | 复用 + 调大 MaxIdleConnsPerHost |
这张表,是我用这次事故换来的"急救手册"。最该刻进脑子的,是分清 TIME_WAIT 和 CLOSE_WAIT:它们看着像、实则截然不同。TIME_WAIT 是主动关闭方的正常状态(只是堆多了会耗端口),根治靠复用连接;而 CLOSE_WAIT 是你的程序收到了对端的 FIN、却忘了 close,是实打实的连接泄漏 bug,得去查代码。其余的也各有出处:too many open files 是 fd 耗尽(连接/文件没关,或 ulimit 太低);connection reset by peer 是对端强制断开(超时/重启/限流);而"延迟忽高忽低",十有八九还是连接没复用、或连接池太小。它们反复指向同一个治本之道:管好你的每一个连接——该复用的复用,该关闭的关闭。
第五件事:连接池参数,不是配了就万事大吉
修复时我也发现,连接池配错了参数,等于没配,甚至帮倒忙。我把几个关键参数的"坑"梳理清楚了。
| 参数 / 做法 | 配错的后果 | 正确姿势 |
|---|---|---|
| MaxIdleConnsPerHost(默认2) | 太小→高并发下仍大量新建连接, 复用形同虚设 | 按并发量调大(如 20~100) |
| 每次 new 一个 Client | 连接池随 Client 销毁, 等于不复用 | 全局单例 Client, 复用它 |
| Body 不读完就 Close | 连接无法放回池, 退化成每次新建 | io.Copy(Discard,Body) 读干净再 Close |
| 不设 Timeout | 慢请求占住池中连接, 池被拖垮 | 设置合理 Timeout |
| IdleConnTimeout 过长/过短 | 过长占资源; 过短频繁重建 | 结合对端 keep-alive 时长设置(如 90s) |
这张表,把我对连接池"配了就有效"的天真,彻底打破了。连接池是个需要正确配合才能生效的机制,任何一环错了,它都会悄悄退化回"每次新建连接",而你还以为自己优化了。最隐蔽的三个坑:其一,MaxIdleConnsPerHost 默认才 2,不调大,高并发下根本不够复用;其二,每次 new 一个 Client——连接池是挂在 Client 上的,Client 一销毁,池也没了,等于没复用(必须全局单例);其三,Body 不读完就 Close,连接回不了池。此外:不设 Timeout,慢请求会长期占住池中连接、把池拖垮;IdleConnTimeout 要结合对端的 keep-alive 时长来设(太长占资源、太短频繁重建)。归根结底,连接池给我的教训是:优化不能只"配上",还要"配对",更要验证它真的生效了(看 netstat 的 ESTABLISHED 是否稳定、TIME_WAIT 是否降下来)——否则,你以为的优化,可能只是一个美好的错觉。
第六件事:要发起 HTTP 调用时,我现在会怎么决策
现在,每当我准备写下一段"调外部接口"的代码,脑子里都会过一遍这张决策图——核心就一个意识:连接要复用,别一次性。
这张图的灵魂,是把"连接复用"变成发起任何网络调用前的默认前提。第一问:调用频率高吗?——偶尔一次,随手写也无妨;但只要是高频、高并发、或在循环里调,就必须复用:用全局单例 client + 连接池。然后:开 keep-alive、调大 MaxIdleConnsPerHost;再检查 Body 有没有读干净再 Close(这是连接能否回池复用的命门);设好 Timeout 防止慢请求占住连接。最后,也是我以前最缺的一步:上线后,一定要用 netstat 验证——看 ESTABLISHED 是否稳定在小数目、TIME_WAIT 是否真的降下来了,用数据确认"复用"真的生效了,而不是停留在"我以为生效了"。
我立下的几条规矩
这场"端口耗尽、高峰必崩"的事故,换来了我做网络调用时,刻进骨子里的几条铁律:
- 高频调用必须复用连接。全局单例 client + 连接池 + keep-alive,这是高频通信的生死线,不是可选优化。
- 绝不在循环里、每次请求里 new 一个 client。连接池挂在 client 上,client 一销毁池就没了,等于不复用。
- Body 一定读干净再 Close。这是连接能否放回池的命门;读不干净,连接池形同虚设。
- MaxIdleConnsPerHost 默认才 2,务必按并发调大。不调大,高并发下复用约等于没有。
- 分清 TIME_WAIT 与 CLOSE_WAIT。TIME_WAIT 多→没复用(治本靠复用);CLOSE_WAIT 多→没 close(连接泄漏,查代码)。
- tcp_tw_reuse 可辅助,tcp_tw_recycle 绝不用。后者在 NAT 下丢包,已废弃移除;系统参数永远只是辅助,治本靠复用。
- 优化要验证。改完用 netstat 看 ESTABLISHED/TIME_WAIT,确认真的生效,别活在"我以为"里。
附:一段验证"连接是否真的复用"的小实验
口说无凭。我写了个小实验,直接看"复用"和"不复用"在连接数上的天壤之别,跑一遍胜过千言。
# 实验: 对同一个目标地址连续发 5000 次请求, 观察连接状态
# ✗ 不复用(每次 curl 新建连接, 用完就关):
for i in $(seq 1 5000); do curl -s http://target/api > /dev/null; done &
# 另开一个终端实时看 TIME_WAIT 数量:
watch -n1 'netstat -an | grep TIME_WAIT | wc -l'
# → 数字飞速上涨, 几千个 TIME_WAIT, 严重时本地端口被占光报错。
# ✓ 复用(同一连接上连续发, keep-alive):
# 用支持连接复用的客户端, 或 curl 的多 url 复用同一连接:
curl -s http://target/api[1-5000] # curl 会在同一连接上复用发送(若服务端支持 keep-alive)
# → TIME_WAIT 几乎不增长, ESTABLISHED 只有 1 个, 全程飞快。
# 对比 ESTABLISHED 连接数(复用应该很少):
netstat -an | grep ESTABLISHED | grep :目标端口 | wc -l
# 不复用: 持续有大量新建的 ESTABLISHED 在闪现
# 复用: 稳定在 1 个(或连接池大小)
# 在代码里也能直接打点验证(Go 示例):
# resp.Request 的连接是否来自池, 可开 httptrace 看 GotConn{Reused: true}
# import "net/http/httptrace" → trace.GotConn = func(i httptrace.GotConnInfo){ log.Println("reused:", i.Reused) }
# 核心: 跑一遍就懂 —— 不复用 TIME_WAIT 暴涨、端口告急;
# 复用则 TIME_WAIT 几乎不动、ESTABLISHED 稳定, 用数据确认优化真生效。
这个实验,我建议每个做后端的同事都亲手跑一遍。对同一个目标,连发 5000 次请求:不复用时,另一个终端里 watch 着的 TIME_WAIT 数字,会飞速上涨,几千个 TIME_WAIT 转眼堆起来,严重时直接把端口占光报错;而复用时,TIME_WAIT 几乎纹丝不动,ESTABLISHED 稳定就那么一个(或连接池大小),全程飞快。同样是"发 5000 次请求",一个把机器的端口资源搅得天翻地覆,一个气定神闲、波澜不惊——而它们的差别,仅仅在于那个连接,是被反复使用,还是被一次次地建了又扔。在 Go 里,你甚至能用 httptrace 的 GotConn{Reused: true} 直接打点确认"这次请求,到底有没有复用到池里的连接"。这,正是我想用这个实验,刻进每个同事脑子里的最后一课:优化做没做、做对没做对,不要"感觉",要用数据去验证——那个会暴涨、或会归于平静的 TIME_WAIT 数字,从不说谎。
写在最后
回头看,这场由"连接不复用"引发的、一到高峰就端口耗尽的事故,真正教给我的,是一个比"复用连接"本身更普遍的道理:我们写的每一行调用代码背后,都站着真实的、有限的物理资源——而那些被高级语言和框架精心隐藏起来的细节,恰恰是出大事的地方。一句轻飘飘的 client.Get(url),在语言层面,简单得就像调用一个普通函数;可在它之下,是实实在在的 TCP 三次握手、TLS 协商、一个被占用的本地端口、一段长达两分钟的 TIME_WAIT 滞留。我之前的错误,就是只看到了那行简洁的代码,却忘了它背后那个"有重量、有成本、有数量上限"的连接。所以,做后端、尤其做高并发,必须培养一种"穿透抽象、看见底层资源"的能力:当你写下一个网络调用、一次文件打开、一个数据库查询时,要能看见它背后那个被占用的连接、文件句柄、内存;并诚实地问一句:"当这个操作,每秒发生一万次时,这些资源,够用吗?会被正确地回收和复用吗?"真正可靠的系统,从来不是建立在"假装资源无限"的天真之上,而是建立在对每一份有限资源的、精打细算的尊重之上。把每一个连接,都当成一件需要"借了要还、还能再借"的宝贵物品来对待——这,是我用一次"cannot assign requested address"的崩溃,换来的、关于网络编程最朴素、也最深刻的领悟。如果这篇复盘,能让你在下一次写下网络调用之前,多想一句"这连接复用了吗",那我对着 netstat 熬的这大半天,就值了。
—— 别看了 · 2026