端口被 TIME_WAIT 占满:TCP 短连接避坑复盘

这次事故的报错信息第一眼看上去毫无道理:我们一个服务在流量高峰期偶发性地报一个奇怪的错误 Cannot assign requested address 无法分配请求的地址。我盯着这行错误懵了好一会儿:分配地址?我又没在创建什么地址,我只是在调用一个下游的 HTTP 接口而已啊,而且这个错误只在高峰期出现,服务器的 CPU 内存带宽也都不紧张,一个资源充裕的服务器却报无法分配地址,这到底是什么玄学?真相藏在我平时几乎从不关心的地方——TCP 连接的状态。在服务器上敲下 netstat 一看头皮发麻:有几万个连接处于 TIME_WAIT 状态密密麻麻刷满屏幕,正是它们占满了本机可用的端口号,导致服务想再发起新连接时已经无端口可用,这才是无法分配地址的真正含义——不是内存地址而是本地端口分配不出来了。而源头是一个看起来天经地义的写法:每次调用下游接口都新建一个 TCP 连接用完就关闭,而本端作为主动关闭方,每个连接关闭后都要在 TIME_WAIT 停留一两分钟,高峰期高频堆积就把端口占满了。这篇文章从这次端口被占满的事故出发,讲透 TCP 长短连接避坑:TIME_WAIT 为何存在、用长连接加连接池复用的正解、client 必须全局复用否则连接池形同虚设、用 netstat/ss 按状态统计连接排查、内核参数只是辅助而 tcp_tw_recycle 坚决不能开、长连接与短连接怎么选,以及一个根本认知——应用之下是看不见的协议,要愿意往下多看一层。

这次事故的报错信息,第一眼看上去毫无道理:我们一个服务在流量高峰期,偶发性地报一个奇怪的错误——Cannot assign requested address(无法分配请求的地址)。我盯着这行错误懵了好一会儿:分配地址?我又没在创建什么地址,我只是在调用一个下游的 HTTP 接口而已啊。而且这个错误只在高峰期出现,平峰期一切正常;服务器的 CPU、内存、带宽也都不紧张,下游接口本身也是好的。一个资源充裕的服务器,却报"无法分配地址",这到底是什么玄学?

真相藏在一个我平时几乎从不关心的地方——TCP 连接的状态。当我在服务器上敲下 netstat 一看,头皮发麻:有几万个连接处于 TIME_WAIT 状态,密密麻麻刷满了屏幕。正是这几万个 TIME_WAIT 状态的连接,占满了本机可用的端口号,导致我的服务想再发起新的对外连接时,已经"无端口可用"了——这才是那句"无法分配地址"的真正含义:不是内存地址,而是本地端口分配不出来了。而这一切的源头,是一个看起来天经地义的写法:每次调用下游接口,都新建一个 TCP 连接、用完就关闭。这篇文章,就从这次"端口被 TIME_WAIT 占满"的事故讲起,把网络编程里这些关于 TCP 连接、关于长短连接的、最容易被应用开发者忽视的坑,一个个讲透。

故障现场:几万个 TIME_WAIT 是怎么来的

先说清楚背景。我们这个服务,每处理一个请求,都要去调用好几次下游的内部 HTTP 接口。当时负责这块的写法,简化后是这样的:

// 每次调用都新建一个 client(底层新建 TCP 连接), 用完就丢
func callDownstream(url string) ([]byte, error) {
    client := &http.Client{}        // 每次都新建, 不复用
    resp, err := client.Get(url)    // 新建 TCP 连接 → 握手 → 请求
    if err != nil { return nil, err }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body)    // 用完, 连接随之关闭
}
// 高峰期: 每秒成千上万次调用, 就是每秒成千上万次"建连→用→关连"

这段代码的问题,不在于它"错"——它功能上完全正确,能跑通。问题在于它制造了海量的短连接:每调用一次,就完整地走一遍"建立 TCP 连接(三次握手)→ 发请求收响应 → 关闭 TCP 连接(四次挥手)"的全过程。而关键在于关闭这一步:在 TCP 协议里,主动发起关闭的那一方,在挥手之后,并不会立刻释放这个连接,而是会进入一个叫 TIME_WAIT 的状态,并在这个状态停留一段时间(通常是 2 倍的 MSL,默认下大约 1~2 分钟),之后才彻底释放。

而我们的服务,正是每次调用的主动关闭方。于是高峰期每秒成千上万个短连接被建立、又被我们主动关闭,每一个关闭后都要在 TIME_WAIT 里待上一两分钟才消失。建立的速度远快于消失的速度,TIME_WAIT 就像没关紧的水龙头一样疯狂堆积,几万个、十几万个……而每一个 TIME_WAIT 都占着一个本地端口号,本机的可用端口总共也就几万个(默认范围更窄),很快就被占光了。端口耗尽,新连接自然就建不出来,报出那句 Cannot assign requested address这就是"端口被 TIME_WAIT 占满"的全部真相:不是资源不够,而是大量短连接关闭后滞留的 TIME_WAIT,把端口这种有限资源给占用殆尽了。

第一件事:理解 TIME_WAIT 为什么存在

很多人第一反应是:TIME_WAIT 这么讨厌,能不能干脆别让它存在、关了连接立刻释放?要回答这个,得先理解 TIME_WAIT 不是 TCP 的设计缺陷,而是它刻意为之的一个保护机制。它的存在主要为了两件事:

主动关闭方为什么要停留 TIME_WAIT(2*MSL):

1. 确保最后一个 ACK 能可靠到达对端
   - 四次挥手, 主动关闭方最后发 ACK; 万一这个 ACK 丢了,
     对端会重发 FIN; 停留 TIME_WAIT 期间还能再回一个 ACK。
   - 如果立刻关闭, 对端重发的 FIN 就没人理了, 对端无法正常关闭。

2. 让本次连接的"迷途旧报文"在网络中彻底消失
   - 防止上一个连接延迟到达的旧数据包, 被下一个复用了
     相同四元组的新连接误收, 造成数据错乱。
   - 2*MSL 正好是一个报文在网络中存活的最长时间的两倍。

所以 TIME_WAIT 是 TCP 为了"可靠关闭"和"防止旧报文串扰"而付出的必要代价,粗暴地完全禁用它会带来连接无法正常关闭、数据错乱等更严重的问题。关键认知是:TIME_WAIT 本身没有错,它是健康的;真正的问题,是我们用"大量短连接"这种方式,人为地制造了海量的 TIME_WAIT。所以正确的方向,不是去消灭 TIME_WAIT,而是从源头上减少短连接的数量——而这,正好指向了那个被我们忽视的解法:连接复用。

第二件事:正解——用长连接 + 连接池复用

问题的根子是"短连接太多",那正解就是"少建连、多复用"——用长连接(keep-alive)配合连接池:建立的 TCP 连接用完不立刻关闭,而是放回池子里留着,下次调用同一个下游时直接复用这个已有的连接,省去反复建连、关连的开销,自然也就不再源源不断地制造 TIME_WAIT 了。

// 正解: 全局复用一个配置好连接池的 client(而不是每次新建)
var client = &http.Client{
    Timeout: 3 * time.Second,        // 别忘了超时!
    Transport: &http.Transport{
        MaxIdleConns:        100,    // 连接池总空闲连接数
        MaxIdleConnsPerHost: 20,     // 每个下游主机保留的空闲长连接数
        IdleConnTimeout:     90 * time.Second,
    },
}
func callDownstream(url string) ([]byte, error) {
    resp, err := client.Get(url)     // 复用池中已建立的长连接
    if err != nil { return nil, err }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body)     // 用完连接放回池, 不关闭
}

这个改动看着小,效果却是天壤之别:原本每秒上万次"建连→用→关连",现在变成了"少量长连接被反复复用",建连和关连的次数骤降到原来的零头,TIME_WAIT 自然就消失了。这里有个极其关键、却常被忽略的细节:复用连接的前提,是这个 client(及其底层的 Transport/连接池)必须是全局共享、长期存活的;如果你像之前那样每次调用都 new 一个新 client,那连接池每次都是空的、根本无从复用,等于白设。"每次新建客户端"是连接池失效最常见的原因——很多人配了连接池却没生效,就栽在这。

顺便说一句,我在正解里还顺手加了 Timeout。这是另一条网络编程的保命铁律:任何对外的网络调用,都必须设置超时。因为不设超时,一旦下游卡住,你的调用就会无限期地等下去,连接和线程被一个个耗尽,本服务被下游活活拖垮——这是"故障扩散、雪崩"最经典的成因之一。长连接复用解决"建连开销",超时设置解决"等待失控",两者都是网络调用的标配。我把这次的因果和解法画成一张对比图:

这张图把"病根"和"正解"并排放在一起,核心对比一目了然:短连接是"用一次扔一次",每次都付出完整的建连和善后(TIME_WAIT)成本;长连接池是"建一次用多次",把昂贵的连接当成可复用的资源池化起来。这种"池化复用昂贵资源"的思想,不只用于 TCP 连接——数据库连接池、线程池,背后都是同一套智慧:对于"创建昂贵、可重复使用"的资源,池化复用,几乎总是比"即用即弃"高效得多。

第三件事:netstat 是排查连接问题的"听诊器"

这次能快速锁定 TIME_WAIT,靠的是 netstat(及更现代的 ss)。它能列出当前机器上所有的网络连接及其状态,是排查一切"连接相关诡异问题"的听诊器。几个最实用的姿势:

# 按连接状态分类统计数量(排查连接问题第一招!)
netstat -ant | awk '{print $6}' | sort | uniq -c | sort -rn
# 输出类似:
#   45231 TIME_WAIT   ← 如果这个数巨大, 八成是短连接问题
#     312 ESTABLISHED
#      28 LISTEN

# 看和某个下游 IP 的连接情况
netstat -ant | grep 10.0.0.5

# 更现代、更快的 ss(推荐, netstat 在大量连接时很慢)
ss -ant state time-wait | wc -l    # 数 TIME_WAIT 数量

这里最该学会的,是第一条"按状态统计连接数量"的命令——它几乎是排查连接类问题的万能第一步。一眼扫过去:如果 TIME_WAIT 巨多,基本就是短连接没复用(本次事故);如果 CLOSE_WAIT 巨多,通常是你的程序收到对方关闭后,自己没有正确地 close 连接(代码 bug);如果 ESTABLISHED 居高不下还在涨,可能是连接泄漏。不同的状态分布,直接指向不同的病因。掌握 netstat/ss 这个"听诊器",很多原本玄乎的网络问题——连接建不出、连接泄漏、连接堆积——都能被你一眼听诊出大致的病灶,而不必对着应用日志干瞪眼。

第四件事:内核参数能"缓解",但别拿它当主解

查 TIME_WAIT 时,你一定会搜到一堆"改内核参数解决 TIME_WAIT"的帖子。这些参数确实能缓解,但我想强调:它们是"辅助缓解",不是"根本解决"——根本解决永远是前面说的"用长连接减少短连接"。先把常见参数和它们的真实作用说清楚:

# /etc/sysctl.conf 里几个相关参数(慎改, 理解了再动)
net.ipv4.tcp_tw_reuse = 1        # 允许"作为客户端发起连接"时复用 TIME_WAIT 的端口
                                  # —— 对"主动对外连接"的场景(本次事故)有效且较安全
net.ipv4.ip_local_port_range = 1024 65535   # 扩大本地可用端口范围, 多撑一会儿
# net.ipv4.tcp_tw_recycle —— 危险! 在 NAT 环境下会导致连接异常, 新内核已移除, 千万别开

这里要划重点、避大坑:tcp_tw_reuse 相对安全(它只允许在"作为客户端发起新连接"时,安全地复用处于 TIME_WAIT 的端口,正好对应我们这种"主动调下游"的场景);但tcp_tw_recycle 是个臭名昭著的大坑,在 NAT 网络环境下会导致来自同一 NAT 后不同客户端的连接被错误丢弃,引发诡异的连接失败,新版 Linux 内核已经直接移除了它——千万不要开启它。扩大端口范围(ip_local_port_range)则是"多给点端口续命",治标不治本。

我的态度很明确:内核参数调优,是在你无法立刻改代码、或想多一层兜底时的辅助手段;真正的、根本的解法,永远是从应用层用长连接复用,从源头上不产生那么多短连接。别本末倒置——花大力气研究内核参数,却放着"复用 client"这个一行就能解决根本问题的改动不做。要顺带理解这些连接状态,我把 TCP 连接几个关键状态列成一张表:

状态 含义 大量出现意味着
ESTABLISHED 连接已建立, 正常通信中 正常; 只增不减可能是连接泄漏
TIME_WAIT 主动关闭方等待 2MSL 短连接过多没复用(本次事故)
CLOSE_WAIT 收到对方 FIN, 本端还没 close 程序 bug: 忘了关闭连接
SYN_SENT / SYN_RECV 握手中 大量堆积可能遭 SYN 洪水攻击
FIN_WAIT_1/2 主动关闭, 等待对方响应 偶发正常; 大量堆积关注对端

第五件事:长连接 vs 短连接,怎么选

这次事故让我系统想清了"长连接和短连接到底怎么选"。它们各有适用场景,不是非黑即白。我把它们的对比列成一张表:

维度 短连接(用完即关) 长连接(复用 keep-alive)
建连开销 每次都握手, 开销大 建一次反复用, 开销摊薄
TIME_WAIT 高频堆积, 易耗尽端口 极少, 不易堆积
资源占用 不占用空闲连接 需维持空闲连接(占一点资源)
适合场景 极低频、偶尔调用一次 高频调用同一下游(绝大多数后端场景)
典型代表 一次性脚本、低频任务 微服务间调用、连接数据库/缓存

结论很清晰:在后端服务"高频调用固定几个下游"的绝大多数场景里,长连接 + 连接池几乎总是正确选择——你调用得越频繁、对同一下游的请求越密集,长连接复用省下的建连开销和避免的 TIME_WAIT 就越可观。短连接只在"极低频、偶尔才调一次、维持长连接反而浪费"的场景才划算(比如一个一天才跑一次的脚本)。而我们那次的错误,本质上是在一个典型的"高频调用"场景里,用了本该留给"低频偶发"场景的短连接——用错了工具,自然就出了事。所以选长还是短,核心就看一个问题:你对这个下游的调用,是"频繁持续"还是"偶尔一次"?频繁就长连接复用,偶尔才用短连接。

一张"网络调用怎么做才稳"的决策图

把这次踩坑沉淀的东西拧成一条主线,做成一张写"对外网络调用"时的自检图:

这张图串起了网络调用稳定性的几个关键点:频繁调用就用长连接池、连接池客户端务必全局复用、任何调用都要设超时、上线后用工具盯连接状态。其中两个红色节点是最容易栽的坑——"每次新建 client 导致连接池形同虚设"和"忘设超时导致被下游拖垮",这俩都是看起来代码能跑、实则埋着雷的典型。走一遍这张图,这类网络稳定性问题就能提前规避大半。

我立下的几条网络调用铁律

这次"端口被 TIME_WAIT 占满"的事故后,团队的规范里加了这么几条:

  1. 客户端全局复用:HTTP client、数据库/缓存连接,都用全局共享、长期存活的实例配连接池,严禁每次调用新建。
  2. 高频调用用长连接:对固定下游的频繁调用一律长连接 + 连接池复用,从源头杜绝海量短连接和 TIME_WAIT。
  3. 任何网络调用必设超时:连接超时、读写超时都要设,绝不让一个慢下游无限期拖住你的连接和线程。
  4. 合理配置连接池:根据并发量设池大小、每主机空闲连接数、空闲回收时间,别用默认值裸跑。
  5. 内核参数是辅助非主解:tcp_tw_reuse 可作兜底, 但根本解法是应用层复用;tcp_tw_recycle 坚决不开。
  6. 监控连接状态:把 TIME_WAIT、CLOSE_WAIT、ESTABLISHED 等连接数纳入监控,异常堆积及时告警。
  7. 排查先看 netstat/ss:遇到连接建不出、疑似连接泄漏,第一步按状态统计连接数,据分布定位病因。

这几条里,第一条"客户端全局复用"是直接根治这次事故的——它一行改动,既消灭了 TIME_WAIT,又顺带提升了性能(省去反复建连)。而第三条"必设超时"我想再强调一次:它和 TIME_WAIT 是网络调用的两大高发雷区,一个让你"建不出连接",一个让你"被下游拖垮",而且都极其隐蔽——平时风平浪静,只在高峰期或下游异常时才爆发。把"复用连接"和"设置超时"这两件事刻进每一次网络调用的肌肉记忆里,你就避开了网络编程里最常见、也最致命的一大类坑。

写在最后:应用之下,是看不见的协议

这次被 TIME_WAIT 折腾的经历,给我最深的触动,是它让我意识到:作为应用层开发者,我们写的每一行"调用一个接口"的代码,底下都站着一整套我们平时看不见、却时刻在运转的网络协议——TCP 的连接建立与关闭、各种连接状态的流转、端口这种有限资源的分配。平时它们默默工作、岁月静好,可一旦你的用法触碰了它们的底线(比如制造海量短连接),它们就会以一种你完全意想不到的方式(一句"无法分配地址")给你当头一棒。而那一刻,如果你对这层"看不见的协议"一无所知,就只能对着报错抓瞎、把责任甩给"玄学"。

所以这次事故之后,我对"全栈"这个词有了更朴素的理解:它不是说你要精通每一层,而是说当问题发生时,你得有能力、有意识地往你平时工作的那一层之下多看一层。我平时是应用层开发者,关心的是业务逻辑、是接口对不对;可这次的问题,根本不在应用层,而在它脚下的传输层——如果我满足于"我只管调接口、底层我不管",这个 bug 我永远查不明白。正是"愿意往下多看一层、去理解应用代码底下那套网络协议在做什么"的这份意识,才让我从"无法分配地址"这句天书,一步步追到了 TIME_WAIT、追到了短连接、追到了那个该被复用却没被复用的 client。

这个道理,适用于我们技术栈的每一层:应用之下是协议,协议之下是内核,内核之下是硬件——每一层都在为上一层"默默兜底",也都在用上一层看不见的规则约束着它。你不需要成为每一层的专家,但你需要保有一份"向下的好奇与敬畏":知道你脚下还有一层、知道它大概在做什么、知道出了上层解释不了的问题时该往下去看。所以,如果你也是一名应用层开发者,我想把这次踩坑最想说的话送给你:别把"网络调用"仅仅当成一行 client.Get,在它之下,是 TCP 的握手与挥手、是连接状态的流转、是端口资源的分配——理解这层看不见的协议,不是为了炫技,而是为了在某个高峰期的深夜,当那句莫名其妙的"无法分配地址"跳出来时,你能不慌不忙地敲下 netstat,然后心里有底地说一句:'哦,是 TIME_WAIT,我知道怎么回事了。'那份从容,正是"多懂一层"给你的底气。

回头看,这次事故其实是一份伪装成灾难的礼物:它逼着我啃下了 TCP 连接状态这块平时绝不会主动去碰的硬骨头,而这份"被迫"换来的理解,后来在好几次连接相关的排查里都帮我节省了大量时间。技术债总要还,知识的欠账也一样——你平时绕过去没学的那层原理,迟早会在某个生产事故里,连本带利地要求你补上。与其被动地还,不如主动地学。愿你脚下的每一层协议,都不再是黑箱,而是你心里有数、随时可以下探一层去看的清澈世界。

把这份向下探索的习惯带在身上,你会越来越稳。

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

明明有索引却全表扫描:索引失效避坑复盘

2026-6-1 12:51:17

技术教程

对账邮件发了三遍:多实例定时任务避坑复盘

2026-6-1 13:01:39

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