那是一个看起来风平浪静的周三上午,监控突然开始疯狂报警:一台核心服务节点的接口大面积超时,紧接着日志里刷出一片刺眼的 too many open files。服务还活着,但已经接不了新请求了——它像一个手里攥满了东西、却怎么也腾不出手来接新活的人。
第一反应当然是"句柄不够,调大 ulimit"。我手忙脚乱地把文件描述符上限从 65535 提到了 100 万,服务缓了一口气,十几分钟后又被打满。治标不治本,问题根本不在上限——而在于这些句柄到底被谁占着、为什么一直不释放。
登上机器,一条 netstat 把真相摊在了眼前:
# 按 TCP 状态聚合统计当前连接数
netstat -ant | awk '{print $6}' | sort | uniq -c | sort -rn
# 38492 CLOSE_WAIT ← 触目惊心
# 412 ESTABLISHED
# 87 TIME_WAIT
# 15 LISTEN
三万八千多个 CLOSE_WAIT。每一个都死死占着一个文件描述符,只增不减。那一刻我才意识到,自己对 TCP 连接的理解,一直停留在"connect 然后 close"这种乐观的想象里,从没认真看过中间那张四次挥手的状态机。而 CLOSE_WAIT 这个状态,恰恰是这张状态机里一个最容易被应用代码忽略、也最能憋出大事的环节。
这篇文章,就是我把那次事故从头到尾啃明白之后,梳理出的一份"TCP 连接状态自救指南"。它不讲协议规范的条条框框,只讲那些真的会在生产环境里把你绊倒的状态和坑。
先纠正几个关于 TCP 连接的常见误解
在拆解事故之前,先把几个我自己曾经深信不疑、后来被现实狠狠打脸的误解列出来。如果你也中了招,这篇文章可能正好对症。
| 常见误解 | 真相 |
|---|---|
调用了 close(),连接就立刻消失了 |
四次挥手要走完整个流程,连接会经历 FIN_WAIT / TIME_WAIT 等中间态,不是瞬间蒸发 |
CLOSE_WAIT 和 TIME_WAIT 差不多,都是要等的状态 |
完全不同:TIME_WAIT 是主动关闭方的正常等待,会自动消失;CLOSE_WAIT 堆积几乎一定是你的代码忘了关连接 |
句柄不够就调大 ulimit |
那只是给漏水的桶换个更大的桶。连接泄漏不修,多大的上限都会被填满 |
| HTTP 请求拿到结果就完事了 | 很多客户端库要求你显式关闭 response body,否则底层 TCP 连接不会归还连接池,慢慢就泄漏了 |
TIME_WAIT 是个 bug,应该想办法干掉 |
TIME_WAIT 是协议特意设计的保护机制,粗暴干掉会引入更隐蔽的问题 |
| 设不设超时无所谓,网络一般都很快 | 不设超时 = 把命运交给对端。对端不响应,你的连接和线程就会被无限期挂住 |
第一件事:看懂四次挥手,才看得懂 CLOSE_WAIT
要理解 CLOSE_WAIT 为什么会堆积,必须先看清 TCP 连接是怎么"优雅分手"的。建立连接是三次握手,关闭连接则是四次挥手——之所以要四次,是因为 TCP 是全双工的,两个方向的数据通道要分别关闭。
假设是客户端主动发起关闭(主动关闭方),整个过程是这样的:
主动关闭方 被动关闭方
| |
| ── FIN ──────────────────────► | 我没数据要发了
| (进入 FIN_WAIT_1) |
| |
| ◄────────────────── ACK ────── | 好,我知道了
| (进入 FIN_WAIT_2) (进入 CLOSE_WAIT) ← 关键!
| |
| ...被动方还能继续发数据...
| |
| ◄────────────────── FIN ────── | 我也没数据了 (调用 close())
| (进入 TIME_WAIT) (进入 LAST_ACK)
| |
| ── ACK ──────────────────────► |
| (等 2*MSL 后 CLOSED) (收到后 CLOSED)
请把目光死死锁在被动关闭方那一侧:当它收到对方的 FIN 并回了 ACK 之后,它就进入了 CLOSE_WAIT 状态。注意——这个状态会一直持续,直到被动方自己也调用 close() 发出第二个 FIN。
换句话说,CLOSE_WAIT 的含义是:"对端已经说它说完了,正等着我也说再见,可我(应用程序)迟迟不开口。"如果你的代码因为某种原因没有调用 close(),这个连接就会永远卡在 CLOSE_WAIT,死死占着一个文件描述符不放。成千上万个这样的连接堆起来,就是那天我看到的 38492。
下面这张图,把四次挥手里两端的状态流转画清楚,尤其标出了 CLOSE_WAIT 这个"等你关"的卡点:
这张图里有两个截然不同的"等待":左下角被动方的 CLOSE_WAIT,等的是你的应用代码,代码不动它就不动;右下角主动方的 TIME_WAIT,等的是固定的时钟(2 倍 MSL,通常几十秒到几分钟),时间一到自动消失。搞清楚这两者的本质区别,是排查连接问题的第一块基石。
第二件事:CLOSE_WAIT 堆积,几乎一定是你的代码没关连接
定位到一堆 CLOSE_WAIT 后,有一条几乎可以闭眼下的结论:被动关闭方的应用程序,漏掉了 close()。因为从协议层面看,操作系统已经替你收下了对端的 FIN 并回了 ACK,接下来该调 close() 的那一步,内核帮不了你——它必须由你的代码主动发起。
那次事故的根因,藏在一段平平无奇的 HTTP 客户端调用里。我们用 Go 写了个服务,会去调用一个下游 API,代码大致长这样:
// 反例:拿到响应就用,忘了关 Body
func fetchUser(id string) (*User, error) {
resp, err := http.Get("http://downstream/user/" + id)
if err != nil {
return nil, err
}
// ⚠️ 致命:没有 defer resp.Body.Close()
var u User
json.NewDecoder(resp.Body).Decode(&u)
return &u, nil
}
问题就出在那行注释上。在 Go 里,http.Response.Body 是一个需要你显式关闭的资源。不关它,底层那条 TCP 连接就不会被释放、也不会归还给连接池——从对端的角度看,它发完数据调了 close() 进入了 FIN_WAIT,而我们这边的内核进入了 CLOSE_WAIT,却因为应用层迟迟不 close,永远停在那儿。平时调用量小,泄漏得慢,跑大半个月也没事;那天下游接口变慢、调用量又冲高,泄漏速度陡增,几分钟就把句柄吃光了。
修复其实只有一行,但它价值连城:
// 正解:拿到响应,立刻 defer 关闭 Body
func fetchUser(id string) (*User, error) {
resp, err := http.Get("http://downstream/user/" + id)
if err != nil {
return nil, err
}
defer resp.Body.Close() // ✅ 函数退出时一定归还连接
var u User
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
return nil, err
}
return &u, nil
}
这个坑不是 Go 独有的。几乎每种语言、每个网络库,都有一个"用完必须释放"的资源:Java 里是 InputStream / Response(用 try-with-resources 包住),Python 的 requests 推荐用 with 上下文或复用 Session,数据库连接则要还回连接池。记住一句话:凡是"打开"了某个连接或流,就必须有一个对应的、无论成功失败都会执行的"关闭"。在 Go 里用 defer,在 Java 里用 try-with-resources,在 Python 里用 with——这些语法糖存在的全部意义,就是不让你忘记关门。
第三件事:用工具把"谁在泄漏"精确定位出来
知道是连接泄漏还不够,大型服务里调用下游的地方可能有几十处,到底是哪一处在漏?这时候得靠工具,而不是肉眼读代码。
第一步,确认 CLOSE_WAIT 到底连到了哪个对端 IP 和端口——这能帮你缩小到"是调哪个下游服务时漏的":
# 找出处于 CLOSE_WAIT 的连接都连向谁(按对端地址聚合)
netstat -ant | grep CLOSE_WAIT | awk '{print $5}' | sort | uniq -c | sort -rn
# 38110 10.0.3.21:8080 ← 基本锁定是调这个下游时漏的
# 382 10.0.3.22:8080
第二步,既然确定了是某个端口对应的进程在泄漏句柄,就用 lsof 看这个进程到底开了多少个连向该地址的描述符:
# 看某进程(PID 替换为实际值)打开的、处于 CLOSE_WAIT 的连接数量
lsof -p | grep CLOSE_WAIT | wc -l
# 看整台机器各进程占用的 fd 数量排行,快速找出"句柄大户"
lsof | awk '{print $1}' | sort | uniq -c | sort -rn | head
定位到具体进程和下游地址之后,回到代码里搜索所有调用那个下游的地方,逐一检查"有没有配对的关闭",漏网之鱼一抓一个准。这套"netstat 聚合状态 → 锁定对端 → lsof 定位进程 → 回代码查关闭"的排查链路,是处理一切连接泄漏问题的通用套路,值得形成肌肉记忆。
第四件事:TIME_WAIT 堆积,是另一个完全不同的故事
修完 CLOSE_WAIT 没多久,我又在另一台做反向代理的机器上撞见了它的"近亲"——满屏的 TIME_WAIT,甚至偶尔报出 Cannot assign requested address。很多人会把这两个状态混为一谈,但它们的成因和解法南辕北辙。
前面说过,TIME_WAIT 出现在主动关闭方,是连接已经正常关完、进入的一个"善后"等待期,持续大约 2 倍 MSL(常见配置下约 60 秒)。它存在的意义有二:一是确保最后那个 ACK 能可靠送达对端(万一丢了,对端重发 FIN 时这边还能应答);二是让旧连接的残留数据包在网络中彻底消散,避免污染下一个复用相同四元组的新连接。所以 TIME_WAIT 不是 bug,是协议的安全设计。
那它为什么会堆积成灾?根源在于短连接太多:每处理一个请求就新建一条连接、用完立刻主动关闭,在高并发下,主动关闭方会瞬间积累海量 TIME_WAIT。每条都占用一个本地端口约 60 秒,而本地端口总共也就几万个(受 ip_local_port_range 限制),很快就分配不出新端口,于是报 Cannot assign requested address。
解法不是粗暴地"消灭 TIME_WAIT",而是从源头减少主动关闭的短连接,辅以合理的内核参数:
# /etc/sysctl.conf —— 谨慎调整,理解每一项再上
# 允许将处于 TIME_WAIT 的连接重新用于新的"出站"连接(对客户端/代理侧安全)
net.ipv4.tcp_tw_reuse = 1
# 放宽本地可用端口范围,缓解端口耗尽
net.ipv4.ip_local_port_range = 1024 65535
# ⚠️ 切勿盲目开启 tcp_tw_recycle —— 它在 NAT 环境下会丢包,新内核已移除
# net.ipv4.tcp_tw_recycle = 1 # 别碰
但内核参数只是缓解,真正的根治是用长连接复用替代频繁的短连接——这就引出了下一件事。
第五件事:长连接与连接池,从根上少建少关
无论是 CLOSE_WAIT 的泄漏风险,还是 TIME_WAIT 的端口耗尽,背后都有一个共同的减压阀:别一个请求一条连接,用完就扔;而是建立一批连接、反复复用。这就是 HTTP Keep-Alive 和连接池要解决的事。
以 Go 为例,问题往往出在"每次请求都新建一个 http.Client"或"用了默认 Transport 却没配好复用参数"。正确的做法是全局复用一个配置良好的 Client:
// 复用一个全局 Client,并配好连接池与超时
var httpClient = &http.Client{
Timeout: 5 * time.Second, // 整个请求的总超时,兜底
Transport: &http.Transport{
MaxIdleConns: 100, // 总空闲连接上限
MaxIdleConnsPerHost: 20, // 每个下游主机的空闲连接数(默认才 2,常需调大)
IdleConnTimeout: 90 * time.Second, // 空闲连接多久后关闭,回收资源
DialContext: (&net.Dialer{
Timeout: 2 * time.Second, // 建连(connect)超时
}).DialContext,
},
}
这里有个极易被忽视的细节:MaxIdleConnsPerHost 默认值只有 2。如果你的服务对同一个下游有较高并发,超过 2 的那部分连接用完后无法被放回空闲池复用,只能被关闭(产生 TIME_WAIT),下次又得重新建连。把它调大到和你的并发量匹配,既减少了建连开销,也显著压低了 TIME_WAIT 的产生速度。连接池配得好,本身就是对 TIME_WAIT 最优雅的治理。
第六件事:永远给网络操作设超时
最后这条,是所有网络编程里最朴素、也最容易被偷懒省掉的铁律:任何一次网络读写,都必须有超时。那次事故能从"下游变慢"演化成"句柄雪崩",超时缺失也是帮凶之一——请求挂在那里迟迟不返回,连接也就迟迟得不到释放。
网络超时不是一个值,而是分层的几个值,缺一不可:
| 超时类型 | 管的是什么 | 不设的后果 |
|---|---|---|
| 连接超时 (connect timeout) | TCP 三次握手建连的最长等待 | 对端宕机/网络不通时,线程长时间卡在建连 |
| 读超时 (read timeout) | 建连后等待对端返回数据的最长时间 | 对端"假死"(连上了却不回数据),连接被无限期挂住 |
| 写超时 (write timeout) | 向对端发送数据的最长时间 | 对端接收缓慢/缓冲区满时,写操作永久阻塞 |
| 整体/请求超时 | 一次完整请求的总时长上限(兜底) | 各分段都没超,但累计拖很久,仍占着资源 |
记住:不设超时,等于把你服务的生死交到了对端手里。对端一旦行为异常(变慢、假死、网络抖动),你这边的连接、线程、句柄就会被一点点抽干。超时设置是网络编程的安全带——平时感觉不到它的存在,出事那一刻,它就是救命的东西。
把这套排查与防御思路收个尾,下面这张决策树,是我把那次事故的复盘沉淀成的"连接问题速查表":
几条可以直接抄走的铁律
- 打开必关闭,且放进 finally 语义里。Go 用
defer Close(),Java 用 try-with-resources,Python 用with——保证无论成功失败都释放。 - 看到
CLOSE_WAIT堆积,先怀疑自己的代码漏了close(),而不是调ulimit。 - 分清两个等待:
CLOSE_WAIT等你的代码,TIME_WAIT等时钟。前者要改代码,后者要减短连接。 - 别一请求一连接。全局复用 Client、配好连接池(尤其
MaxIdleConnsPerHost),用长连接从根上少建少关。 - 每一个网络操作都要有超时,且分层设置:连接、读、写、整体兜底。
- 内核参数是缓解不是根治。
tcp_tw_reuse可以开,tcp_tw_recycle别碰(NAT 下丢包)。 - 排查靠工具不靠猜。
netstat看状态分布,lsof定位进程和句柄,再回代码核对关闭逻辑。
顺带说说那个吓人的 Connection reset by peer
排查过程中我还顺手搞懂了一个一直让我心里没底的报错:Connection reset by peer(连接被对端重置,本质是收到了一个 RST 包)。和优雅的四次挥手不同,RST 是 TCP 的"硬挂断"——它不走 FIN 那套你来我往的流程,而是直接告诉对方"这条连接立刻作废,别再发了"。
它通常意味着几种情况:对端进程崩溃或被 kill,内核替它发了 RST;你往一个对端已经关闭的连接上继续写数据;或者连接被中间的防火墙、负载均衡器单方面掐断。最坑的一种是连接池里的"陈旧连接":你池子里缓存着一条长连接,可对端(或中间的 LB)其实早已把它关了,你下次复用它发请求,就会撞上 RST。这也是为什么连接池一定要配 IdleConnTimeout——让空闲太久、很可能已经"凉了"的连接主动退休,而不是留着它去踩雷。理解了 RST 和正常挥手的区别,再看到这个报错就不会慌:它不是网络玄学,而是连接状态对不上的一个明确信号。
那些年我对 TCP 连接的几个误解
误解一:"句柄不够,调大 ulimit 就行了。"——这是我事故当天的第一反应,也是最典型的治标不治本。连接泄漏的本质是"只进不出",上限再高也只是把崩溃推迟几分钟。调大上限可以作为争取排查时间的应急手段,但绝不能当成修复。真正的修复永远在代码里那个缺失的 close()。
误解二:"TIME_WAIT 是个麻烦,应该想尽办法干掉它。"——我曾经也想直接 tcp_tw_recycle 一把梭。后来才明白它是协议为了可靠关闭和防止旧包串扰而故意设计的,粗暴回收会在 NAT 环境下引发莫名其妙的丢包,得不偿失(这个参数在较新内核里已被移除)。TIME_WAIT 多,该治理的是"为什么有这么多短连接",而不是这个状态本身。
误解三:"连接关没关、超时设没设,平时跑得好就没问题。"——这正是这类故障最阴险的地方:它在低流量、下游健康时完全没有症状,能潜伏几个月。直到某天下游变慢、流量冲高,所有平时被掩盖的小疏漏会在同一时刻集体爆发。网络代码的健壮性,不能用"现在没出事"来证明;只能靠"打开必关、操作必超时、连接必复用"这些铁律提前焊死。
一个延伸:服务端的优雅关闭,别在重启时制造一批 RST
把视角从"客户端怎么关连接"切到"服务端怎么被关",还有一个常被忽略的环节:服务重启或下线时的优雅关闭(graceful shutdown)。很多人发布新版本就是直接 kill 进程,这会让那一刻所有正在处理中的连接被内核强行 RST 掉,客户端那边就收获一片 Connection reset by peer——发布期间的报错毛刺,很多就是这么来的。
正确的姿势是:收到终止信号后,先停止接收新连接,再把存量请求处理完,最后才退出。Go 的 http.Server 内置了这个能力:
// 优雅关闭:不再接新请求,给在途请求一段处理时间
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
<-quit // 阻塞等待终止信号
// 给在途请求最多 10 秒时间收尾,超时再强制退出
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("forced shutdown: %v", err)
}
这背后其实还是那张状态机:Shutdown 做的,就是让服务端以"主动关闭方"的身份,对每条连接走完整的四次挥手,而不是粗暴地甩出 RST。配合 Kubernetes 的 preStop 钩子和合理的优雅终止宽限期,你的滚动发布就能做到"零连接被重置"。把连接善始善终地关掉,和小心翼翼地建立它一样重要——一个负责任的服务,既不会泄漏连接,也不会在退场时摔门而去。
写在最后
那次事故最终的修复,加起来不过几行代码:给漏掉的 resp.Body 补上 defer Close(),给 HTTP Client 配好连接池和超时,再把反代那台机器的短连接改成长连接复用。CLOSE_WAIT 的曲线当天就平了下来,句柄数稳稳地落在一个小区间里上下波动——那种"只增不减"的恐怖单调上升,再也没出现过。
但真正留下来的,不是这几行 diff,而是一种看待网络连接的全新视角。在这之前,连接对我来说是一个抽象的、要么"通"要么"不通"的黑盒;在这之后,我脑子里多了一张状态机——每一条连接此刻正处在 ESTABLISHED、CLOSE_WAIT 还是 TIME_WAIT,它在等谁、还要等多久、谁该为下一步负责,都变得具体而清晰。当你能"看见"连接的状态,很多原本玄学般的线上问题,就都有了明确的因果。
所以下次当你写下一次 HTTP 调用、开一个 socket、从连接池借一条连接时,不妨多想一秒:这条连接,谁来关、什么时候关、关不掉会卡在哪个状态?这一秒的自觉,可能就帮未来的你躲过了一次半夜被 too many open files 叫醒的事故。连接是会呼吸的资源,有借有还,才不会在某个流量高峰,被它反噬。
如果让我把这篇文章浓缩成一个可以立刻执行的小动作,那就是:现在打开你负责的任意一台服务器,敲一行 netstat -ant | awk '{print $6}' | sort | uniq -c | sort -rn,花十秒看一眼各状态的连接数分布。如果 CLOSE_WAIT 或 TIME_WAIT 异常地多,那它很可能就是一颗正在悄悄充能、只等下一个流量高峰引爆的定时炸弹。早一天看见它,就早一天有机会从容拆除,而不是被它在某个凌晨叫醒。
毕竟,网络编程里真正难的从来不是把请求发出去,而是把每一条连接都安安稳稳地收回来。会建连只是入门,懂得让连接善始善终、在任何状态下都能交代清楚它的去向,才算真正摸到了网络这门手艺的门槛。
—— 别看了 · 2026