下游没挂自己先崩:TCP 连接 TIME_WAIT 端口耗尽避坑

一个调用下游接口的服务,平时风平浪静,流量一冲高就大面积抽风:日志刷屏般地报 connection refused,更多的是一句陌生的 cannot assign requested address,可被调用的下游监控却一切正常、根本没挂。我顺着"下游扛不住"查了半天一无所获,直到在本机敲下 ss -s,数字一出来就全明白了——几万个连接密密麻麻堆在 TIME_WAIT,本机用来对外发起连接的临时端口被占得一干二净,新连接再也分不到端口号。真凶不在下游而在我自己:每次调用都新建一个 HTTP 客户端、建一条全新 TCP 连接,用完即关,海量短连接转瞬即逝却各自留下一个要驻留约 60 秒的 TIME_WAIT,像退潮后来不及退走的水洼,越积越多,最终淹没了端口这片海滩。这篇文章从这次端口耗尽事故出发,讲透 TCP 连接的生与死:三次握手四次挥手、TIME_WAIT 为何要等 2MSL、临时端口如何被耗光、短连接的握手浪费、全局复用客户端与连接池、keep-alive 真正生效的细节、tcp_tw_reuse 与千万别碰的 tcp_tw_recycle、TIME_WAIT 与 CLOSE_WAIT 的本质区别,以及重试如何火上浇油。

有个服务,职责很简单:接到请求后,转头去调用下游的一个接口,把结果加工一下返回。平时风平浪静,QPS 不高的时候它乖得像只猫。可每逢大促或者活动开闸,流量一冲高,它就开始大面积抽风——日志里刷屏般地报错,有的是 connection refused,更多的是一句看着就陌生的 cannot assign requested address。最让人摸不着头脑的是:被调用的那个下游服务,监控上一切正常,根本没挂。

一开始我顺着"下游扛不住"的方向查,翻了半天下游的日志和监控,人家清清白白。直到我在出问题的这台机器上敲了一行 ss -s 看连接统计,数字一出来我就明白了:几万个连接堆在 TIME_WAIT 状态,密密麻麻。本机用来发起对外连接的临时端口,被这些"用完了却还没散场"的连接占得一干二净,新的请求想再建一个连接,系统已经分不出端口号了——这才有了那句 cannot assign requested address

真凶并不在下游,而在我自己:每次调用下游,代码都新建一个 HTTP 客户端、建一条全新的 TCP 连接,用完立刻关闭。低流量时这点浪费看不出来;高流量时,海量的短连接转瞬即逝,却各自在关闭后留下一个要驻留好一会儿的 TIME_WAIT,像退潮后滩涂上来不及退走的水洼,越积越多,最后把端口这片"海滩"彻底淹没。这篇文章,就从这次"端口耗尽"事故出发,把 TCP 连接的生与死、TIME_WAIT 到底图什么、以及怎么用长连接和连接池从根上解决,一次讲清楚。

先掰扯几个关于连接的想当然

动手之前,先把我和不少同行都踩过的几个误区摆出来,你对照看看有没有中招。

想当然的念头 实际的真相
"连接报错,肯定是下游挂了或网络断了" 很多时候是本机端口耗尽、连接没复用,下游清清白白
"TIME_WAIT 是异常状态,得想办法干掉它" 它是 TCP 协议正常且必要的设计,粗暴消灭反而会引入数据错乱风险
"用完连接赶紧关,干净利落不留隐患" 高频短连接恰恰是 TIME_WAIT 堆积、端口耗尽的根源
"本机端口有六万多个,怎么可能不够用" 临时端口范围默认就那么一段,TIME_WAIT 驻留期内不释放,几秒就能耗光
"调下游慢,加机器/加线程就能扛" 不解决连接复用,加再多并发只会更快把端口打满

这些念头的共同毛病,是把"连接"当成了一种用过即弃、取之不尽的廉价资源,却忘了每一条 TCP 连接的建立和销毁都有实打实的成本,而销毁之后还要留下一段"善后时间"。要理解这一点,得先看看一条 TCP 连接完整的一生。

第一件事:一条 TCP 连接的生与死,以及 TIME_WAIT 在等什么

一条 TCP 连接的建立,要经过著名的三次握手:客户端发 SYN,服务端回 SYN+ACK,客户端再回 ACK,双方确认彼此收发都正常,连接才算建好。而连接的关闭,要经过四次挥手:一方发 FIN 说"我没数据要发了",另一方 ACK 确认,稍后它也发 FIN,对方再 ACK,连接才算彻底拆除。

关键就在挥手的最后。主动发起关闭的那一方,在发出最后一个 ACK 之后,并不会立刻把连接彻底丢掉,而是进入一个叫 TIME_WAIT 的状态,默默等待大约 2 个 MSL(报文最大生存时间,Linux 上通常合计 60 秒)才真正释放。它为什么要等?有两个理由:一是确保自己最后发出的那个 ACK 真的到达了对端——万一对端没收到、重发了 FIN,自己还能再补一个 ACK;二是让这条连接上残留在网络里的旧数据包自然过期消散,免得它们窜到下一条复用了相同四元组的新连接里,造成数据错乱。

所以 TIME_WAIT 不是 bug,也不是异常,而是 TCP 为了"可靠关闭"和"防止串包"刻意付出的代价。下面这张图,画的就是四次挥手里主动关闭方一路走到 TIME_WAIT 的过程:

看懂这张图就抓住了要害:谁主动关闭,谁就背上那个 60 秒的 TIME_WAIT。我的服务每次调用下游都是自己主动关闭连接,于是每一次调用都给本机攒下一个要驻留一分钟的 TIME_WAIT。高频之下,它们堆积的速度远超释放的速度,端口自然就被淹没了。接下来,我们就顺着"端口怎么被耗尽"往下挖。

第二件事:临时端口是怎么被一点点耗光的

要看清现场,先得有趁手的工具。排查连接问题,ss(或老一点的 netstat)是第一选择。我当时就是靠它一眼锁定了 TIME_WAIT 海啸。

# 看各种 TCP 状态的总览统计
ss -s

# 按状态分类计数,TIME_WAIT 一栏若是几万,基本就是它了
ss -ant | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn

# 查看本机临时端口范围(对外发起连接时从这段里取)
cat /proc/sys/net/ipv4/ip_local_port_range
# 典型输出: 32768   60999  → 也就只有约 2.8 万个可用

关键认知在这里:当你的服务主动去连接别人时,本机需要分配一个临时端口(ephemeral port)作为这条连接的源端口。这段端口范围默认并不大,上面例子里只有约 2.8 万个。而一条连接关闭后进入 TIME_WAIT,它占用的这个源端口在 60 秒内不会被释放、不能被复用。算一笔账就触目惊心:如果你每秒新建并关闭五百个短连接,不到一分钟,两万八千个端口就被 TIME_WAIT 占满了,第五百零一个连接想出门,系统已经没有端口可分配——于是抛出 cannot assign requested address

这就是"端口耗尽"的全貌:不是连接数真的有几万个在活跃通信,而是大量已经关闭、却还在 TIME_WAIT 里数秒等待的连接,把端口这个有限资源给"冻结"住了。下游一根毫毛都没伤着,本机却先把自己憋死了。

第三件事:病根是"短连接",每次都新建是巨大的浪费

顺着这条线索回到代码,真凶水落石出:我在每次调用下游时,都临时 new 一个 HTTP 客户端,发一次请求,然后让它关闭。每一次调用,都意味着完整地走一遍 TCP 三次握手(还可能加上 TLS 握手),通信几毫秒,然后四次挥手关闭,再留下一个 TIME_WAIT。

// 反例:每次调用都新建 client,底层连接用完即关
func callDownstream(url string) ([]byte, error) {
    client := &http.Client{} // 每次都是新的,连接不复用
    resp, err := client.Get(url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body)
}
// 高频调用 → 海量短连接 → TIME_WAIT 堆积 → 端口耗尽

正确的做法,是把客户端(连接池)复用起来:全局共享一个 client,让它内部维护一个长连接池,请求结束后连接不关闭而是放回池子,下次直接拿来再用。这样既省去了反复握手的开销,更关键的是——连接不再被频繁主动关闭,TIME_WAIT 也就无从堆积。

// 正解:全局复用一个 client,内部连接池长存
var sharedClient = &http.Client{
    Timeout: 3 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        200,              // 全局最大空闲连接
        MaxIdleConnsPerHost: 50,               // 每个下游主机的空闲连接
        IdleConnTimeout:     90 * time.Second, // 空闲多久后回收
    },
}

func callDownstream(url string) ([]byte, error) {
    resp, err := sharedClient.Get(url) // 复用池中长连接
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body) // 务必读完 body,连接才能放回池子
}

这里有个极易被忽略、却能让连接复用彻底失效的细节:响应的 Body 一定要读完并关闭。如果你只读了一部分就走人,这条连接的状态就乱了,无法被安全地放回池子,客户端只能把它关掉重建——你以为开了连接池,实际还是在不停建短连接。所以 defer resp.Body.Close() 之外,把 Body 完整读到 EOF 同样重要。

第四件事:让 HTTP keep-alive 真正生效

连接池能复用的前提,是底层 TCP 连接在一次请求完成后不被关闭,这靠的是 HTTP 的 keep-alive(长连接)。HTTP/1.1 默认就开启了它,但有几种常见情形会让它形同虚设:服务端或客户端显式发了 Connection: close;客户端用完不复用而是新建;或者空闲连接超时被回收得太激进。所以光"以为开了"还不够,得确认两端都没把它关掉。

# 用 curl 验证服务端是否支持 keep-alive
# 同一条命令里发两次请求,观察是否复用了同一连接
curl -v http://downstream/api -o /dev/null \
     --next http://downstream/api -o /dev/null 2>&1 \
     | grep -E "Re-using|Connection|Connected"
# 出现 "Re-using existing connection" 即说明长连接生效

连接池的几个参数也要按场景调好,否则可能"够用但不好用"。MaxIdleConnsPerHost 尤其关键:它决定了对单个下游主机能保留多少空闲长连接,如果设得太小(Go 默认每主机只有 2 个),高并发下多出来的连接用完还是会被关闭,TIME_WAIT 依旧会冒头。把它调到和你的并发量相称,才能真正按住短连接。

第五件事:内核层的缓解手段,以及一个千万别用的开关

把连接复用做好是治本,但有些遗留场景一时改不动代码,可以在内核参数层面做缓解。最常用、也相对安全的是开启 tcp_tw_reuse,它允许在安全的前提下,把处于 TIME_WAIT 的连接重新用于新发起的出站连接;另外适当放宽临时端口范围,也能多争取一些缓冲。

# /etc/sysctl.conf,改完执行 sysctl -p 生效
# 允许出站连接复用 TIME_WAIT 的端口(对客户端侧安全)
net.ipv4.tcp_tw_reuse = 1
# 放宽本机临时端口范围,争取更多可用端口
net.ipv4.ip_local_port_range = 1024 65535
# 适当下调 FIN_WAIT2 等待,加速回收(按需)
net.ipv4.tcp_fin_timeout = 30

这里必须郑重提醒一个千万别碰的开关:老资料里常推荐的 tcp_tw_recycle。它在 NAT 环境下会因为时间戳判断错误而丢弃合法连接,引发诡异的连接失败,危害极大,以至于 Linux 4.12 之后内核已经把它彻底移除了。如果你在某篇旧博客里看到"开启 tcp_tw_recycle 解决 TIME_WAIT",请直接划走——它解决的麻烦,远不及它制造的麻烦。记住:内核参数是缓解,连接复用才是根治。

第六件事:谁主动关闭,谁背 TIME_WAIT

回到最根本的那条规律:TIME_WAIT 落在主动关闭连接的一方。这给了我们一个设计上的着力点。对于客户端调用下游这种场景,最好的策略就是让连接长期存活、由两端尽量少地主动关闭——用连接池把连接攒住,谁也别没事就关。这样 TIME_WAIT 自然稀少,因为根本没几次"关闭"动作发生。

反过来,对于对外提供服务的一方(比如你的 Web 服务被海量客户端访问),如果由服务端主动关闭每条连接,TIME_WAIT 就会堆在服务端。通常的做法是合理设置 keep-alive 超时,让闲置连接由客户端侧先关闭,或者借助前面的内核参数与连接复用来摊薄成本。一句话:把"谁来关闭"想清楚,就掌握了 TIME_WAIT 会堆在谁家。

到这儿,这次事故的来龙去脉和解法都齐了。我把排查思路收成一张决策图,下次再遇到连接类报错,照着走即可:

把这套动作固化下来,这类"下游没挂、自己先崩"的连接事故,基本能在十几分钟内定位。最后,把零散经验拧成几条可直接照做的铁律:

  1. 连接报错先别甩锅下游,在本机 ss -s 看连接状态,TIME_WAIT 堆积往往是自己的问题。
  2. 对外调用一律走全局复用的客户端 + 连接池,杜绝每次新建的短连接。
  3. 响应 Body 必须读完并关闭,否则连接无法放回池子,复用白搭。
  4. 把 keep-alive 调到真生效,尤其调大每主机空闲连接数,别用默认值硬扛高并发。
  5. TIME_WAIT 是正常设计,别想着消灭它,要做的是减少主动关闭的次数。
  6. 内核参数用 tcp_tw_reuse 和放宽端口范围缓解,但坚决不碰 tcp_tw_recycle
  7. 分清 TIME_WAIT 与 CLOSE_WAIT:前者是正常善后,后者大量堆积是没正确关连接的 bug。

一张连接报错速查表

把几种常见的连接类报错、可能的原因和对策汇成一张表,下次线上一冒红就能对号入座。

现象 / 报错 多半的原因 对策
cannot assign requested address 本机临时端口被 TIME_WAIT 占满 连接复用为主,tcp_tw_reuse + 放宽端口范围为辅
大量 TIME_WAIT(本机) 高频短连接,自己主动关闭 全局 client + 连接池,读完 Body
大量 CLOSE_WAIT(本机) 代码没正确 close 连接(连接泄漏) 排查未关闭的连接/响应体,补 close
connection refused 对端端口没监听/已挂/被拒 查下游存活与端口,确认地址端口
connection reset by peer 对端强制关闭/超时/连接被复用出错 查下游超时与负载,检查连接复用是否正确
偶发超时但下游正常 DNS 解析慢、TCP 重传、链路抖动 查 DNS 缓存、抓包看重传、加超时与重试

特别提醒:CLOSE_WAIT 和 TIME_WAIT 是两码事

很多人把这两个状态混为一谈,其实它们指向完全不同的问题,搞反了排查方向就全错了。

TIME_WAIT 出现在主动关闭方,是连接正常关闭后的善后等待,会自己在约 60 秒后消失。它大量堆积,说明你建了太多短连接,是"用法"问题,本身不算 bug。

CLOSE_WAIT 则出现在被动关闭方:对端已经发来 FIN 说要关了,而你的程序却迟迟没有调用 close 去回应、完成自己这侧的关闭。它不会自动消失,会一直挂着直到进程退出。所以一旦看到本机 CLOSE_WAIT 越积越多,几乎可以断定:代码里有连接(或响应体、文件句柄)没被正确关闭,这是实打实的资源泄漏 bug,得去代码里揪出那个忘了 close 的地方,而不是去调内核参数。

一句话区分:TIME_WAIT 是"关得太频繁"的用法问题,CLOSE_WAIT 是"忘了关"的代码 bug。看到哪个堆积,就往哪个方向查,别南辕北辙。

顺带说说:握手的代价,和重试为何会火上浇油

短连接的成本,不只是事后那个 TIME_WAIT。在连接真正能传数据之前,它得先把"握手"这一整套流程走完,而这套流程的开销,在高频场景下同样不容小觑。

先是 TCP 的三次握手,一来一回,至少要消耗一个 RTT(往返时延)。如果跨机房、跨地域,一个 RTT 可能就是几十毫秒。更重的是 HTTPS:在 TCP 握手之上,还要再叠一层 TLS 握手,要交换证书、协商密钥,传统 TLS 1.2 又是一到两个 RTT。也就是说,一次全新的 HTTPS 短连接,在发出第一个有效字节之前,可能已经白白花掉了上百毫秒,而真正的业务数据传输也许只要几毫秒。复用长连接,省下的正是这每一次重复的握手开销——连接池里那条早就握过手的连接,拿来即用,直接进入正题。

另一个容易把局面彻底推向崩溃的因素,是重试。很多服务为了"提高成功率",会给调用下游加上失败重试。这本是好意,但在端口耗尽的场景里,它恰恰是火上浇油:连接建不出来 → 报错 → 触发重试 → 又去抢本就枯竭的端口 → 更建不出来 → 再重试……请求量被重试放大成数倍,端口耗尽愈演愈烈,服务在自己制造的风暴里越陷越深。

// 危险:无脑重试,会在端口耗尽时雪上加霜
for i := 0; i < 3; i++ {
    resp, err := callDownstream(url)
    if err == nil {
        return resp, nil
    }
    // 立即重试,等于把压力瞬间放大 3 倍
}

// 稳妥:配合连接复用,并对"端口耗尽"这类错误克制重试
// 用指数退避 + 抖动,给系统留出喘息和回收端口的时间
backoff := 50 * time.Millisecond
for i := 0; i < 3; i++ {
    resp, err := callDownstream(url)
    if err == nil {
        return resp, nil
    }
    if !isRetriable(err) { // 端口耗尽/4xx 等不该盲目重试
        return nil, err
    }
    time.Sleep(backoff + jitter())
    backoff *= 2
}

所以处理连接问题时,要把"建连接""复用连接""重试策略"三件事放在一起看。连接复用是地基,它把握手开销和 TIME_WAIT 一起按住;而重试要克制、要带退避,绝不能在系统已经端口告急时,还不分青红皂白地往上猛冲。两者配合,服务才能在流量洪峰下稳得住。

写在最后

这次事故最值得咀嚼的地方在于:报错信息把我引向了下游,真正的病根却在自己脚下。cannot assign requested address 听起来像是网络或对端的问题,实际却是本机端口被自己制造的 TIME_WAIT 淹没——错误信息有时不是指路牌,而是烟雾弹。它提醒我,排查问题时不能只盯着报错字面去顺藤摸瓜,更要回到资源的全局视角:端口、连接、句柄,这些有限的东西,是不是被自己悄悄耗光了。

而解法回过头看其实朴素得可爱:别每次都新建连接,把它复用起来就好。短连接像一次性纸杯,用着省心,可当你一秒钟要喝五百杯水,堆起来的纸杯比水还占地方;长连接则像一只反复冲洗、反复使用的杯子,前期多花一点心思维护,长远却干净又省事。愿你我下次写下"调用下游"这几个字时,都能下意识地问一句:这条连接,用完之后,是被我珍惜地留下了,还是被随手扔进了那片越堆越高的 TIME_WAIT 滩涂?

如果你手上也有调用下游、调用第三方接口的服务,不妨今天就花十几分钟做三件小事自查。第一,登上几台核心机器敲一下 ss -s 和按状态分类的统计,看看 TIME_WAIT、CLOSE_WAIT 的量级正不正常,心里先有个底。第二,翻一翻调用下游的代码,确认 HTTP 客户端是全局复用的,而不是在函数里每次 new 一个;顺便检查响应体有没有被读完关闭。第三,核对一下连接池的关键参数,尤其是每主机的空闲连接数,别还停在框架的小默认值上硬扛高并发。这三件事都不难,却能在下一个流量高峰把你从深夜的告警里解救出来。

网络编程里有许多这样"反直觉"的细节:看起来最干净利落的"用完即关",在高频之下反而成了压垮自己的稻草;看起来该被消灭的 TIME_WAIT,其实是协议精心设计的安全垫。这些细节不会写在业务需求文档里,却实实在在地决定了你的服务能不能扛住那一波突如其来的流量。把对连接的这份理解刻进习惯,你会发现,很多"玄学故障"其实都有清清楚楚的来路,而你,正一次比一次更快地看穿它们。

毕竟,真正稳的服务,从来不是靠堆机器硬扛出来的,而是把每一条连接都用在了刀刃上。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

测试秒回上线却超时:MySQL 索引为何悄悄失效

2026-5-29 23:28:07

技术教程

手动好用 cron 却出空备份:定时任务避坑全复盘

2026-5-30 1:01:31

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索