TCP 连接的生与死:从一次端口耗尽彻底搞懂 TIME_WAIT

一个调用量很大的网关服务某天开始零星报 connect: cannot assign requested address,下游好好的,网络也通,ss -s 一看竟有六万多个 TIME_WAIT 把本地端口占了个精光。很多人第一反应是把 TIME_WAIT 当 bug 干掉,我差点就去开那个臭名昭著的 tcp_tw_recycle。这篇从端口耗尽讲起,把 TCP 连接怎么生(三次握手为什么是三次)、怎么死(四次挥手与 TIME_WAIT 在哪诞生)、为什么死后还要等 2MSL 讲透,再到用 ss 定位、连接复用治本、CLOSE_WAIT 与 TIME_WAIT 的区别。

一个调用量很大的网关服务,某天开始零星报一个诡异的错:connect: cannot assign requested address。不是下游挂了,下游好好的;也不是网络不通,手动 curl 一切正常。就是这个服务自己,突然没法再发起新连接了。登上机器 ss -s 一看,我倒吸一口凉气——六万多个处于 TIME_WAIT 状态的连接,把本地可用端口几乎占了个精光。新连接想找一个空闲端口都找不到,自然就"cannot assign requested address"了。

那次排查逼着我把 TCP 连接"从生到死"的整个生命周期重新捋了一遍。很多人对 TIME_WAIT 的第一反应是"这是个 bug,想办法干掉它",我一开始也是,差点就去改那个臭名昭著的内核参数把自己坑了。后来才明白:TIME_WAIT 不是 bug,它是 TCP 协议为了"可靠地关闭连接"而精心设计的必然产物,真正的问题不在它本身,而在我的服务用错了连接的方式。这篇就从这次端口耗尽讲起,把 TCP 连接怎么"生"(三次握手)、怎么"死"(四次挥手)、为什么死后还要"停尸"一段时间(TIME_WAIT)彻底讲清楚,以及到底该怎么根治这类堆积。

先认清:TCP 连接的几个关键状态,堆积了各自意味着什么

排查连接问题,第一步永远是看连接处在什么状态。TCP 连接在它的一生里会经历一系列状态,而当某个状态异常堆积时,往往就直接指向了问题的根源。先把最该认识的几个状态摆清楚:

状态 含义 大量堆积通常意味着
LISTEN 服务端在监听端口,等连接进来 正常
SYN_RECV 收到握手第一包,等对方回 ACK 可能遭遇 SYN Flood 攻击
ESTABLISHED 连接已建立,可正常收发数据 正常(但要看数量是否合理)
FIN_WAIT_2 己方已发 FIN,等对方也发 FIN 对方迟迟不关闭
CLOSE_WAIT 收到对方 FIN,等己方应用调 close 你的代码忘了 close 连接!
TIME_WAIT 主动关闭方关完后的等待期(2MSL) 短连接太多 / 没复用连接

这张表里有两个状态最值得记死:TIME_WAIT 堆积,几乎一定是"短连接用太多、没做连接复用";而 CLOSE_WAIT 堆积,则几乎一定是"你的代码有 bug,收到了对方的关闭请求却没调用 close"。这两个状态的归属和成因完全不同,却经常被混为一谈——分清它们,是排查的分水岭。开头我的服务堆的是 TIME_WAIT,所以方向是"连接用法"问题,而不是代码漏了 close。要理解这些状态从哪来,得先看连接是怎么建起来的。

第一件事:连接怎么"生"——三次握手,以及为什么是三次

TCP 是面向连接的,通信前必须先"握手"建立连接。这个过程要三次,缺一不可。很多人能背出"SYN、SYN-ACK、ACK",但答不上来"为什么非得三次,两次行不行"。我们先把过程画清楚:

为什么是三次而不是两次?核心在于 TCP 是全双工的,双方都需要确认"我发得出、你也收得到"这两个方向都通。第①次,客户端证明了自己的发送能力;第②次,服务端确认收到、并证明自己的发送能力;但到这里,服务端还不知道客户端能不能收到自己的包——所以必须有第③次,客户端回 ACK,让服务端确认"我发的你也收到了"。少了第三次,服务端就会在"对方到底收没收到我的 SYN-ACK"的不确定中,可能对着一个早已失效的连接请求白白分配资源。三次,是确认双向通道都畅通的最小次数。

第二件事:连接怎么"死"——四次挥手,TIME_WAIT 在这里诞生

建立要三次,关闭却要四次。原因也不难想:建立时服务端可以把"同意连接(ACK)"和"我也要连(SYN)"合并成一个包发出去;但关闭时,一方说"我没数据要发了(FIN)",另一方可能还有数据没发完,所以它得先单独回个 ACK"知道了",等自己的数据也发完了,才再发自己的 FIN。这一拆,就成了四次。而我们最关心的 TIME_WAIT,正是在这个过程里诞生的:

盯着这张图看清一个关键事实:TIME_WAIT 只出现在"主动关闭连接的那一方"。谁先发出第①个 FIN,谁最后就会进入 TIME_WAIT,并在那里停留 2MSL(MSL 是报文最大生存时间,Linux 上 TIME_WAIT 默认约 60 秒)才真正释放。这就解释了开头那个谜题:我的网关服务作为客户端,主动关闭了和下游的每一条短连接,所以每关一条,本地就多一个占着端口的 TIME_WAIT。请求量一大,几万个 TIME_WAIT 堆起来,本地端口(默认范围才两万多个)瞬间被占满,新连接就再也分配不到端口了。

第三件事:用 ss 把"谁在堆积"看清楚

定位连接问题,ss(以及老一点的 netstat)是你的眼睛。光知道"连不上"没用,得看清到底是哪种状态在堆、堆在和谁的连接上:

# 一览各状态的连接数量 —— 排查第一步,先看大盘
ss -s
# 输出里 TCP: xxx (estab N, closed M, timewait K ...) 的 timewait 就是 TIME_WAIT 数

# 按状态分类统计(最常用的一行,直接告诉你谁在堆积)
ss -ant | awk '{print $1}' | sort | uniq -c | sort -rn
#   60123 TIME-WAIT      ← ❌ 元凶在此:六万个 TIME_WAIT
#     812 ESTAB
#      33 LISTEN

# 看 TIME_WAIT 都堆在连哪个下游(定位是哪个调用没复用连接)
ss -ant state time-wait | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn
#   59800 10.0.1.20      ← 几乎全是连这个下游的,问题调用锁定

# 查本地可用端口范围(TIME_WAIT 占满的就是这个区间)
cat /proc/sys/net/ipv4/ip_local_port_range
#   32768   60999        ← 可用端口就两万八千个,六万 TIME_WAIT 早就撑爆了

这套命令的排查逻辑是层层收窄:ss -s 看大盘哪个状态异常 → 再按状态统计确认是 TIME_WAIT 还是 CLOSE_WAIT → 然后看这些连接都连向谁,锁定是哪个下游调用 → 最后对照本地端口范围,确认是不是真的把端口占满了。走完这四步,我当时的结论非常清晰:不是攻击、不是代码漏 close(那会是 CLOSE_WAIT),而是对某个下游发起了海量短连接、每次用完就主动关、于是攒了一地 TIME_WAIT。方向对了,接下来才好谈"怎么治"——而治它之前,得先想明白这玩意儿为什么非存在不可。

第四件事:TIME_WAIT 为什么必须存在,别急着干掉它

很多人(包括当年的我)第一念头是:这 TIME_WAIT 占着端口又不干活,直接想办法消灭它不就完了?但 TCP 协议的设计者让主动关闭方在那儿等满 2MSL,是有两个硬道理的,理解了就不会乱动它:

原因一:确保对方能可靠地关闭。回看四次挥手的最后一步,主动关闭方发出第④个 ACK 后就进入 TIME_WAIT。但这个 ACK 万一在网络里丢了呢?被动关闭方收不到 ACK,会以为自己的 FIN(第③步)对方没收到,于是重发 FIN。如果主动方关完连接立刻走人、不等待,这个重发的 FIN 就没人接了,被动方会一直重传、最后异常关闭。TIME_WAIT 等待的这段时间,正是为了"万一对方重发 FIN,我还在,能再回一个 ACK",保证对方也能干净地关闭。

原因二:让旧连接的"迷途报文"自然消散。同一对 IP+端口的连接关闭后,可能很快又建立一个新连接(四元组完全相同)。如果上一个连接有个延迟的数据包还在网络里游荡,等新连接建好后才姗姗来迟,就会被新连接误收,造成数据错乱。TIME_WAIT 等待 2MSL(两倍的报文最大生存时间),恰好保证了旧连接的所有残留报文都已在网络中彻底过期消失,新连接不会被它们污染。

所以结论很明确:TIME_WAIT 是可靠关闭的代价,不是 bug。真正该做的不是消灭它,而是从源头上让它别产生那么多——这就引出了真正的根治手段。

第五件事:根治之道是连接复用,而不是去改危险内核参数

TIME_WAIT 堆积的根因是"短连接太多":每个请求都新建连接、用完就主动关,关一个攒一个。所以最有效、最该优先做的根治,是用长连接 + 连接池,把连接复用起来——一条连接服务成百上千个请求,从源头上就没那么多连接需要关闭:

// ❌ 反模式:每次请求都新建 client / 连接,用完即关 → 海量 TIME_WAIT
func badCall(url string) {
    client := &http.Client{}        // 每次新建,底层连接不复用
    resp, _ := client.Get(url)
    resp.Body.Close()               // 关一个就攒一个 TIME_WAIT
}

// ✅ 复用一个全局 client,并正确配置连接池,让 TCP 连接长期复用
var sharedClient = &http.Client{
    Timeout: 5 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        200,            // 整体空闲连接上限
        MaxIdleConnsPerHost: 50,             // ✅ 每个下游保留的空闲长连接数,关键
        IdleConnTimeout:     90 * time.Second,
    },
}
func goodCall(url string) {
    resp, _ := sharedClient.Get(url)
    io.Copy(io.Discard, resp.Body)  // ✅ 必须读完 Body 再 Close,否则连接无法复用!
    resp.Body.Close()
}

这里有个极易忽略的魔鬼细节:HTTP 连接要被复用,必须把响应 Body 完整读完再 Close。很多人拿到结果就直接 resp.Body.Close(),Body 没读完,这条连接就无法放回连接池复用,只能被关闭——于是你配了连接池却依然在制造 TIME_WAIT,白忙一场。开头那个网关服务,根治办法正是把"每次 new client"改成"全局共享 client + 配好 MaxIdleConnsPerHost",TIME_WAIT 数量立刻从六万掉到了几百。

那内核参数能不能调?能,但要分清好坏。如果连接复用做到位后量还是偏大,可以适度放宽,但有一个参数千万别碰:

# ✅ 可以适度做的:让本地端口快速回收复用(对"主动发起连接"的客户端有效、相对安全)
sysctl -w net.ipv4.tcp_tw_reuse=1
# ✅ 适度扩大本地可用端口范围,给客户端更多腾挪空间
sysctl -w net.ipv4.ip_local_port_range="1024 65000"

# ❌❌ 千万别开这个:tcp_tw_recycle —— 在 NAT 环境下会导致连接被莫名拒绝
# 它会丢弃时间戳"倒退"的连接请求,而 NAT 后多个客户端的时间戳各不相同,
# 结果就是 NAT 后面一部分用户随机连不上,极难排查。
# Linux 4.12 起这个参数已被直接移除,就是因为坑了太多人。
# sysctl -w net.ipv4.tcp_tw_recycle=1   # ← 永远不要执行这一行

记住优先级:先做连接复用(治本),再考虑 tcp_tw_reuse 和扩大端口范围(治标),而 tcp_tw_recycle 是雷区、永远别踩。当年我差一点就去开 tcp_tw_recycle,幸好先搞懂了它在 NAT 下的危害——那一脚踩下去,可能引发的是比端口耗尽更难查的"部分用户随机连不上"。

第六件事:别把 CLOSE_WAIT 和 TIME_WAIT 搞混,前者是你的 bug

排查时务必分清:堆积的到底是 TIME_WAIT 还是 CLOSE_WAIT,二者药方截然相反。CLOSE_WAIT 出现在被动关闭方——对方发来 FIN、你的内核回了 ACK 进入 CLOSE_WAIT,接下来就等你的应用代码调用 close 去发出己方的 FIN。如果 CLOSE_WAIT 大量堆积且不消退,几乎可以断定:你的代码漏了 close,连接资源在泄漏。

// ❌ CLOSE_WAIT 堆积的典型成因:某条出错路径上,连接没被 Close
func leak(conn net.Conn) error {
    data, err := readAll(conn)
    if err != nil {
        return err          // ❌ 直接 return,conn 没 Close → 卡在 CLOSE_WAIT 泄漏
    }
    conn.Close()
    return process(data)
}

// ✅ 用 defer 兜底,保证任何返回路径都会关闭连接
func ok(conn net.Conn) error {
    defer conn.Close()      // ✅ 无论从哪条路径返回,都会执行 Close
    data, err := readAll(conn)
    if err != nil {
        return err
    }
    return process(data)
}

一句话区分:TIME_WAIT 是"主动关闭方关得太多",属于用法/规模问题,根治靠连接复用;CLOSE_WAIT 是"被动关闭方该关没关",属于代码 bug,根治靠把漏掉的 close 补上(并用 defer 兜底)。看到 CLOSE_WAIT 涨而不退,别去翻内核参数,去翻你的代码——一定有条路径忘了关连接。

一张图把"连接出问题了"的排查路径走一遍

把前面的状态、命令和成因串起来,遇到连接类故障时,其实有一条几乎机械的排查路径。下次再碰到"连不上下游""连接数异常",别凭感觉,照这棵树走:

这棵树的分叉点就是那句要记死的话:先用 ss 看清是哪种状态在堆,再据此分流。TIME_WAIT 多 → 连接复用问题;CLOSE_WAIT 多 → 代码漏 close;SYN_RECV 多 → 警惕攻击;FIN_WAIT_2 多 → 对方不关。每一种都对应着完全不同的药方,认错了状态就会开错药。配合下面这条命令把连接状态纳入日常监控,很多问题能在堆积成灾之前就预警:

# 把它做成定时采集 + 告警:任一异常状态数突破阈值就报警
watch -n 5 "ss -ant | awk '{print \$1}' | sort | uniq -c | sort -rn"
# 重点盯三条线:
#   TIME-WAIT 持续高位且增长 → 检查连接复用
#   CLOSE-WAIT 只增不减       → 检查代码 close(这是最该立刻告警的)
#   ESTAB 远超业务预期         → 检查连接池上限/泄漏

收口成几条连接管理的铁律

  1. 能长连接就别短连接:对高频调用的下游一律用长连接 + 连接池复用,从源头上消灭海量连接的创建与关闭,这是治本。
  2. HTTP 响应 Body 必须读完再 Close:Body 没读完的连接无法放回连接池复用,配了连接池也照样制造 TIME_WAIT
  3. 连接池参数要按下游配:MaxIdleConnsPerHost 这类"每下游空闲连接数"才是复用的关键,默认值往往太小,要按调用量调大。
  4. 永远不要开 tcp_tw_recycle:它在 NAT 环境会导致部分用户随机连不上,极难排查;Linux 4.12 起已移除。tcp_tw_reuse 才是相对安全的那个。
  5. 分清 TIME_WAIT 与 CLOSE_WAIT:前者是规模/用法问题(连接复用),后者是代码 bug(漏 close);看错状态就会开错药。
  6. 所有连接用 defer/finally 兜底关闭:保证任何出错路径都会释放连接,杜绝 CLOSE_WAIT 泄漏。
  7. 把连接状态纳入监控:定时采集各状态连接数,CLOSE_WAIT 只增不减、TIME_WAIT 持续高位都要告警,别等端口耗尽才发现。

几个特别容易踩的认知误区

这套经验分享出去时,有几个误区几乎人人都有,值得专门点破。

第一个、也是最危险的:"TIME_WAIT 是个 bug,想办法干掉就行。" 它恰恰是 TCP 为了可靠关闭而设计的必要机制——保证对方能收到最后的 ACK、保证旧连接的迷途报文消散。盲目地用激进手段去"消灭"它(比如开 tcp_tw_recycle),换来的往往是更隐蔽、更难查的连接故障。正确的方向永远是减少它的产生(连接复用),而不是消灭已经产生的它。

第二个误区:"TIME_WAITCLOSE_WAIT 差不多,都是连接没关干净。" 它俩天差地别。TIME_WAIT主动关闭方、是正常且短暂的等待;CLOSE_WAIT被动关闭方、是异常的、等你代码去关的状态。看到 TIME_WAIT 多,查连接用法;看到 CLOSE_WAIT 多,查代码漏 close。把这两个搞混,排查方向从一开始就错了。

第三个误区:"三次握手是约定俗成,四次挥手也只是多一次而已。" 次数背后是有严格逻辑的:握手三次,是确认双向通道都通的最小次数;挥手之所以比握手多一次,是因为关闭时被动方可能还有数据没发完,无法像握手那样把 ACK 和 FIN 合并成一个包。理解了"为什么是这个次数",你才能看懂 FIN_WAITLAST_ACK 这些中间状态都是怎么来的。

第四个误区:"配了连接池,连接自然就复用了。" 配了不等于用上了。HTTP 场景下,Body 没读完会让连接无法复用;连接池的 MaxIdleConnsPerHost 太小会让多余连接被直接关闭;每次新建 client 更是让连接池形同虚设。连接复用是个需要逐项确认的结果(共享 client、读完 Body、配够每下游空闲数),不是配了池子就自动达成的。我那六万个 TIME_WAIT,就是"以为配了池就万事大吉"的代价。

附:服务端视角——TIME_WAIT 该落在谁头上

前面一直站在"客户端"视角(网关去连下游)。但同一套原理换到服务端,会得出一个值得玩味的结论:既然 TIME_WAIT 只落在"主动关闭方"头上,那么连接到底由谁来主动关,就直接决定了这堆 TIME_WAIT 攒在谁的机器上。对一台要服务海量客户端的服务器来说,如果每条连接都由服务端主动关闭,那 TIME_WAIT 就全堆在自己这边——这正是要尽力避免的。

这也是 HTTP Keep-Alive 的价值所在:它让一条 TCP 连接上能连续跑多个 HTTP 请求,既省去了反复握手的开销,也大幅减少了连接的关闭次数。配置上有两个原则:其一,服务端的 keep-alive 空闲超时,最好略大于客户端的,让"该不该关这条闲连接"的决定权尽量留给客户端,从而让 TIME_WAIT 落在客户端而非服务端;其二,反向代理(如 Nginx)要对上游(你的应用服务)显式开启长连接(upstream 里配 keepalive),否则代理每次都和上游新建短连接,TIME_WAIT 照样在代理和应用之间堆成山。说到底,连接的"生与死"是一笔需要全链路一起算的账:握手的成本、挥手的代价、TIME_WAIT 的归属,每一环都值得在架构里被认真安排,而不是等它在某个深夜以"端口耗尽"的形式找上门。

还有一个常被忽略的链路细节值得补一句:客户端那两万多个本地端口,是被"四元组"消耗的,而不是被连接总数消耗的。一个 TIME_WAIT 占用的是"本地IP+本地端口+对端IP+对端端口"这一组四元组里的本地端口,所以当你的服务集中向同一个下游 IP、同一个端口发起海量短连接时,可变的只有本地端口这一项,两万多个端口很快就被同一个下游的 TIME_WAIT 吃光——这正是开头六万 TIME_WAIT 几乎全连向同一个 10.0.1.20 的原因。反过来,如果连接分散在多个下游,端口压力会缓和很多。理解这一点,你就明白为什么"端口耗尽"往往不是连接总量的问题,而是"对单一下游的短连接太密集"的问题——而连接复用,恰恰是把"对这个下游开了无数条短连接"变成"对它只保持几十条长连接",从四元组的层面就把压力卸掉了。

写在最后

回到开头那个 cannot assign requested address 的下午。最终的修复其实只有两处:把"每次请求 new 一个 http.Client"改成全局共享一个、并配好连接池;再确认每个响应都 io.Copy(io.Discard, resp.Body) 读干净再关。重启之后,TIME_WAIT 从六万多缓缓回落到几百,端口耗尽的报警再没响过。改动不大,难的是搞懂"为什么会这样"——而那要求我把 TCP 连接从握手到挥手、再到 TIME_WAIT 的整个生命周期,真正看明白一遍。

这件事给我最深的体会是:很多看起来吓人的线上故障,根子上都是对协议机制的不理解,而不是什么玄学。TIME_WAIT 堆积不是命运的捉弄,它清清楚楚地写在 TCP 的状态机里——谁主动关闭谁停尸 2MSL,你制造了多少次主动关闭,就得承受多少个 TIME_WAIT。看懂了这条因果,解法自然浮现:别制造那么多主动关闭,把连接复用起来。下次再遇到连接类的诡异问题,先别慌着改内核参数,先 ss 一下,看清是哪种状态在堆——答案,往往就藏在那张你早该熟记的 TCP 状态图里。

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

MySQL 索引优化实战:看懂 EXPLAIN,慢查询从 3 秒到 30 毫秒

2026-5-29 19:28:52

技术教程

Docker 镜像瘦身实战:从 1.8GB 到 23MB,多阶段构建与分层缓存

2026-5-29 21:12:44

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