我的服务跑着跑着就再也接受不了新连接、报 too many open files,我一查发现成百上千条连接全卡在 CLOSE_WAIT 状态死活不消失,一开始以为是对端没规矩不关连接,最后才搞明白 CLOSE_WAIT 恰恰是对端已经关了、正等着我这边关、而我的代码根本就忘了调 close
这是一次让我把 TCP 里"CLOSE_WAIT 状态"这件事,从"对端赖着不关连接",重新理解成"对端早关了、正等我这边关、而我忘了关"的事故。我的服务跑着跑着就再也接受不了新连接、报 too many open files,一查发现成百上千条连接全卡在 CLOSE_WAIT 状态死活不消失。我一开始以为是对端没规矩、不关连接。最后才搞明白:CLOSE_WAIT 恰恰是对端已经关了、正等着我这边关,而我的代码根本就忘了调 close。这篇就把这次"连接卡在 CLOSE_WAIT、fd 耗尽"的事故,从头到尾复盘一遍。
故障现场:连接数只增不减,最后 too many open files
我的服务对外提供接口,也作为客户端去调下游。运行一段时间后,它开始变得诡异:先是偶尔报错,后来直接无法接受任何新连接,日志里满屏 too many open files——文件描述符(fd)耗尽了。我重启能暂时恢复,但过一阵又复发,典型的资源泄漏。
我用 netstat -an | grep CLOSE_WAIT | wc -l 一统计,倒吸一口凉气:成百上千条连接处于 CLOSE_WAIT 状态,而且这个数字只增不减。我第一反应是"对端怎么回事,连接用完了不关?" 我去查对端,对端却一脸无辜——它那边对应的连接早就是 FIN_WAIT_2 或者干脆没了。我对着 TCP 状态机研究了半天,才终于把因果关系理顺——CLOSE_WAIT 出现在被动关闭的一方:当对端调用 close、发来 FIN 表示"我不再发数据了",我方 TCP 收到后,会回一个 ACK、然后进入 CLOSE_WAIT 状态——这个状态的含义是"对方已经关了它那一半,现在等着我的应用程序也调用 close、把我这一半也关掉"。也就是说,CLOSE_WAIT 是 TCP 在等我的应用代码主动调 close;只要我不调,连接就永远停在 CLOSE_WAIT,对应的 fd 就一直不释放。我那成百上千的 CLOSE_WAIT,根本不是对端的错,而是我的代码在连接用完后忘了 close。
// 我的问题代码: 处理完连接/响应后, 忘了 close, 连接卡在 CLOSE_WAIT
func handle(conn net.Conn) {
data := readRequest(conn)
resp := process(data)
writeResponse(conn, resp)
// ★ 没有 conn.Close()!
// 对端处理完会 close → 发 FIN → 我方进入 CLOSE_WAIT,
// 等我调 conn.Close() 才能继续四次挥手、释放 fd;
// 我没调 → 连接永远卡在 CLOSE_WAIT, fd 泄漏
}
// 作为客户端调下游时, 同样的坑: 拿到 response 不关 Body
resp, _ := http.Get(url)
data, _ := io.ReadAll(resp.Body)
// ★ 忘了 resp.Body.Close()! 底层连接进入 CLOSE_WAIT, fd 泄漏
use(data)
// 现象: CLOSE_WAIT 只增不减 → fd 越用越多 → too many open files → 服务瘫痪
// 根因: 不在对端, 在我自己 —— 被动关闭方的应用层没调 close
问题被钉死在这个认知错位上:我以为 CLOSE_WAIT 多说明"对端不关连接",但 CLOSE_WAIT 恰恰意味着"对端已经关了它那一半(发了 FIN),现在轮到我关我这一半,而我迟迟没关"。TCP 连接的关闭是"四次挥手",需要双方各自主动关闭自己这一端;对端 close 后,我方进入 CLOSE_WAIT、把"该你关了"的球传到了我的应用层手里,只有我的代码调用 close(),挥手才能继续、连接才能彻底释放。我的代码处理完就走人、忘了 close,等于接到球却一直攥在手里不传出去——连接就永远停在 CLOSE_WAIT,fd 一个都不还。堆积的 CLOSE_WAIT 不是对端的问题,而是我这个被动关闭方的应用程序漏了 close 这个明确信号。我以为是别人赖着不走,其实是我自己接了客、却忘了送客。
第一件事:想明白 CLOSE_WAIT 是"等我的应用调 close"的状态
把这次事故彻底想清楚,关键是理解TCP 关闭连接是"四次挥手",双方都要主动关闭自己的发送方向。当对端先 close(主动关闭方),它发 FIN、进入 FIN_WAIT;我方(被动关闭方)收到 FIN 后回 ACK、进入 CLOSE_WAIT——这个状态在等我的应用程序调用 close();我调了 close,我方才发出自己的 FIN、进入 LAST_ACK,收到对端 ACK 后连接才真正关闭、fd 释放。所以 CLOSE_WAIT 堆积,几乎总是应用层 bug:被动关闭方收到了 FIN,却迟迟不调 close。
这就是为什么 CLOSE_WAIT 和 TIME_WAIT 是两个完全不同的故事:TIME_WAIT 出现在主动关闭方,是协议要求的、会自动超时消失的正常状态(为了可靠关闭和防止旧包串扰);而 CLOSE_WAIT 出现在被动关闭方,它不会自动消失——它在死等你的应用调 close,你不调,它就一直在,fd 就一直占着。操作系统已经替你完成了"收 FIN、回 ACK"这一步,把连接停在 CLOSE_WAIT,就是在明确地告诉你的应用:"对方关了,该你出手关了。" 你的应用对这个信号无动于衷,操作系统也不会替你做主关闭(它不知道你是不是还想发数据),于是连接和 fd 就泄漏了。CLOSE_WAIT 多,几乎可以直接断定:你的代码有路径没 close 连接。
TCP 四次挥手与 CLOSE_WAIT 的位置:
主动关闭方(对端) 被动关闭方(我)
| |
|------- FIN ------------->| 对端 close, 发 FIN
|<------ ACK --------------| 我方回 ACK, 进入 CLOSE_WAIT
| (FIN_WAIT_2) | ★ 此刻在等【我的应用调 close()】★
| |
| | ← 如果我的代码忘了 close,
| | 就永远停在这里, fd 不释放!
| |
|<------ FIN --------------| 我调 close(), 发出我的 FIN
|------- ACK ------------->| 进入 LAST_ACK → 收到 ACK → 关闭
关键: CLOSE_WAIT 这一步, 操作系统已做完它该做的(收 FIN/回 ACK),
接下来必须由【应用程序主动调 close】才能推进。应用不调, 就泄漏。
对比 TIME_WAIT(主动关闭方, 会自动超时消失) ≠ CLOSE_WAIT(被动方, 死等 close)
想通这一层,我才明白自己错在哪:我把 CLOSE_WAIT 堆积归咎于对端,却没意识到这个状态的语义恰恰是"球在我这边、等我 close"。操作系统把连接停在 CLOSE_WAIT,是一个再清楚不过的提示:对端已经走完了它的流程,剩下的关闭动作得我的应用来做。我的代码在某些路径上(正常返回、异常、提前 return)漏掉了 close(),就等于无视了这个提示,让连接和 fd 一直挂着。CLOSE_WAIT 是 TCP 递给我的一张"请关门"的提示条,而我的代码把它一张张扔在了地上。
第二件事:正解——每条连接用完都确保 close,用 defer/finally 兜住所有路径
找到根因,正解就清晰了:每一条连接(以及连接上的 response body、stream 等资源),在用完后都必须确保被 close,而且要用 defer(Go)、try-with-resources(Java)、finally、with(Python)这类机制,保证无论正常返回还是中途异常/提前 return,都会执行 close。再配合连接池的空闲超时和 fd 数监控兜底。
// 错误: 某些路径漏 close, 连接卡 CLOSE_WAIT
func handle(conn net.Conn) {
data := readRequest(conn)
if data == nil { return } // ✗ 这条路径没 close, 泄漏!
writeResponse(conn, process(data))
// ✗ 末尾也没 close
}
// 正解1: defer close, 一进函数就登记, 所有出口都会执行
func handle(conn net.Conn) {
defer conn.Close() // ✓ 无论从哪个 return/panic 出去, 都会 close
data := readRequest(conn)
if data == nil { return } // 这里 return 也会触发 defer close
writeResponse(conn, process(data))
}
// 正解2: 作为客户端, response body 必须 close(且要读完以便连接复用)
resp, err := http.Get(url)
if err != nil { return err }
defer resp.Body.Close() // ✓ 关键! 不关 body 底层连接进 CLOSE_WAIT
io.Copy(io.Discard, resp.Body) // 读干净, 让连接能被池复用而非废弃
// Java: try-with-resources, 自动 close, 异常也不漏
try (Socket socket = serverSocket.accept();
InputStream in = socket.getInputStream()) {
handle(in);
} // ✓ 块结束(正常或异常)自动 close socket, 不会卡 CLOSE_WAIT
// HTTP 客户端同理: response 用 try-with-resources 包住
try (CloseableHttpResponse resp = client.execute(request)) {
return parse(resp.getEntity());
} // ✓ 自动释放连接回池
这套做法的精髓,是把"close 连接"这件事,从"记得在每个出口手动写"(必然会漏),变成"一获取资源就登记好释放、由语言机制保证所有路径都执行"。defer/try-with-resources/finally 的价值,正是覆盖那些容易被忽略的退出路径——提前 return、抛异常、多个分支。被动关闭方只要在收到 FIN 后(连接处理完)可靠地调用 close,CLOSE_WAIT 就会立刻推进、fd 及时释放。再加上连接池的空闲回收和对 fd 数/CLOSE_WAIT 数的监控,就能在泄漏发生时尽早发现。不是指望自己每次都记得手动关,而是用机制保证"打开了就一定会关"。
【避免 CLOSE_WAIT 泄漏, 我现在认死的几条】
1. CLOSE_WAIT 在被动关闭方, 含义是"对端关了, 等我的应用调 close"
2. 它不会自动消失; 堆积几乎必是应用层漏 close 的 bug
3. 每条连接/response body/stream 用完都必须 close
4. 用 defer/try-with-resources/finally 兜住所有出口(return/异常/分支)
5. 作为客户端: response body 不仅要 close, 最好读干净以便连接复用
6. 配连接池空闲超时; 监控进程 fd 数和 CLOSE_WAIT 连接数, 早发现泄漏
7. 区分 TIME_WAIT(主动方, 自动消失) 和 CLOSE_WAIT(被动方, 死等 close)
第三件事:其他"拿了资源、某条路径忘了归还"的同类坑
顺着"获取了一个需要显式释放的资源、却在某些路径忘了释放"这条线,我把同类的坑都排查了一遍:
第一个,文件句柄打开没关。打开文件读写后忘了 close,fd 同样泄漏,大量后会和连接一起把 too many open files 顶爆。
第二个,数据库连接/语句/结果集没归还。从连接池借了连接用完不还(不 close),池子很快被借空,后续请求全卡在等连接。
第三个,锁 acquire 了某路径没 release。加锁后在异常或提前 return 路径没解锁,别的线程永远等不到,死锁或饿死。
第四个,goroutine/线程起了不退出。启动了协程但它因 channel 阻塞等原因永不结束,越积越多,内存和调度资源泄漏。
第四件事:CLOSE_WAIT vs TIME_WAIT——一张对照表
我把这两个最容易搞混的 TCP 关闭状态摆在一起对比,核心看"出现在哪一方、会不会自动消失、堆积说明什么":
| 维度 | CLOSE_WAIT | TIME_WAIT |
|---|---|---|
| 出现在 | 被动关闭方(收到 FIN 的一方) | 主动关闭方(先 close 的一方) |
| 含义 | 对端关了, 等我的应用调 close | 已关闭, 等 2MSL 防旧包串扰 |
| 会自动消失吗 | 不会, 死等应用 close | 会, 2MSL 后自动消失 |
| 堆积说明 | 应用层 bug: 漏了 close | 短连接太多/频繁主动关闭 |
| 解决方向 | 找到没 close 的代码路径补上 | 连接复用/调内核参数 |
| 谁的责任 | 你自己的应用代码 | 协议正常机制, 多为设计问题 |
看清这张表,排查方向就明确了:看到大量 CLOSE_WAIT,别怪对端,直接去查自己的代码哪条路径漏了 close;看到大量 TIME_WAIT,才是去考虑连接复用或内核参数。我这次踩坑,就是把 CLOSE_WAIT 堆积当成了对端的问题,而它恰恰是我自己漏 close 的铁证。两个状态出现在挥手的不同方、成因完全不同,搞反了排查方向就南辕北辙。
第五件事:我曾经对 CLOSE_WAIT 想当然的几个误区
这次事故也把我对 CLOSE_WAIT 的一堆"想当然"照了个底朝天:
| 我以为 | 实际上 |
|---|---|
| CLOSE_WAIT 多是对端不关连接 | 是对端已关、等我这边关, 我没 close 才堆积 |
| CLOSE_WAIT 会像 TIME_WAIT 那样自动消失 | 它死等应用调 close, 不调就永远不消失 |
| 操作系统会替我把连接关掉 | OS 只收 FIN 回 ACK, close 必须应用主动调 |
| 处理完连接就走人, 不 close 也没事 | fd 一直占着, 累积到 too many open files |
| 客户端读完响应就行, body 不用 close | 不关 body, 底层连接进 CLOSE_WAIT 泄漏 fd |
这些误区的根子是同一个:我没理解 CLOSE_WAIT 这个状态的真正含义——它是 TCP 在明确地、耐心地等我的应用程序履行"关闭自己这一端"的责任,而我把这份等待误读成了"对端的失职"。TCP 的连接关闭是一个需要双方各自尽责的协作过程:对端尽了它的责(发 FIN),把接力棒交到我手里,而我的代码却把棒弄丢了。把"系统在等我履责"的信号,误解成"别人没履责",于是一直不去做本该我做的事,是这类资源泄漏的共同根源。
第六件事:写网络代码、排查"CLOSE_WAIT 堆积/fd 耗尽"时,我现在的自检习惯
现在每当我写网络/资源相关代码、或排查"连接卡 CLOSE_WAIT、fd 越用越多",我都会先按这张图问自己:
这张图的精髓,是"CLOSE_WAIT 堆积是被动关闭方漏 close 的应用 bug;每条连接用完都要用 defer/finally 兜住所有路径确保 close"。设计就每条连接/body/stream 一获取就 defer/try-with-resources 登记 close、覆盖所有出口、排查就看是 CLOSE_WAIT(查自己漏 close)还是 TIME_WAIT(查连接复用)。这套习惯,让我从"CLOSE_WAIT 多就怪对端"变成了"CLOSE_WAIT 多先查自己哪没 close"——核心始终是:TCP 关闭连接是四次挥手、双方都要主动关闭自己的发送方向;当对端先 close(主动关闭方)发来 FIN,我方(被动关闭方)收到后由操作系统回 ACK 并把连接置为 CLOSE_WAIT 状态,这个状态的确切含义是"对方已经关了它那一半,现在等着我的应用程序也调用 close 把我这一半关掉"——它在等我的应用代码,操作系统已经做完了它该做的(收 FIN、回 ACK)、不会替我决定关闭(它不知道我是否还想发数据),所以只要我的代码不调 close,连接就永远停在 CLOSE_WAIT、对应的 fd 一直不释放,堆积起来最终耗尽 fd 报 too many open files;因此 CLOSE_WAIT 堆积几乎总是应用层 bug——被动关闭方在某些代码路径(正常返回、异常、提前 return、多分支)漏掉了 close;它和 TIME_WAIT 截然不同:TIME_WAIT 出现在主动关闭方、是协议要求的会 2MSL 自动消失的正常状态,而 CLOSE_WAIT 出现在被动关闭方、不会自动消失、死等应用 close;正解是每条连接以及连接上的 response body、stream 等资源用完后都必须确保被 close,并用 defer(Go)、try-with-resources(Java)、finally、with(Python)这类机制保证无论正常返回还是中途异常/提前 return 都执行 close,再配合连接池空闲超时与对 fd 数、各状态连接数的监控兜底。
我立下的几条规矩
这场"CLOSE_WAIT 堆积、fd 耗尽"的事故,换来了我写网络代码时,刻进骨子里的几条铁律:
- CLOSE_WAIT 在被动关闭方,含义是"对端关了、等我的应用调 close"。
- 它不会自动消失;堆积几乎必是应用层漏 close 的 bug,先查自己。
- 每条连接、response body、stream 用完都必须 close。
- 用 defer/try-with-resources/finally 兜住所有出口(return/异常/分支)。
- 作为客户端:response body 不仅要 close,最好读干净以便连接复用。
- 配连接池空闲超时;监控进程 fd 数和各状态连接数,泄漏早报警。
- 分清 TIME_WAIT(主动方、自动消失)和 CLOSE_WAIT(被动方、死等 close)。
附:我现在排查与防治 CLOSE_WAIT 泄漏的"定位 + defer 兜底"骨架
这是我现在排查和防治 CLOSE_WAIT 泄漏固定套的骨架——把这次踩坑的教训(先确认是 CLOSE_WAIT、定位漏 close 的路径、用 defer 兜底、加监控)固化成一套流程,让"fd 耗尽、服务瘫痪"那种坑再不会埋进系统:
# 第一步: 确认连接卡在什么状态、有多少(先分清 CLOSE_WAIT 还是 TIME_WAIT)
ss -ant state close-wait | wc -l # CLOSE_WAIT 数量(被动方漏 close)
ss -ant state time-wait | wc -l # TIME_WAIT 数量(主动方/短连接多)
# 第二步: 大量 CLOSE_WAIT → 是我的进程漏 close, 看是哪个进程、连的是谁
ss -antp state close-wait # 带进程, 看 fd 挂在哪个服务上
lsof -p | grep -c CLOSE_WAIT # 该进程持有多少 CLOSE_WAIT 的 fd
cat /proc//limits | grep "open files" # 看 fd 上限, 离耗尽还有多远
# 第三步: 顺着对端地址, 回代码里找"用了这条连接却没 close"的路径
# 重点查: 提前 return、异常分支、错误处理里漏掉的 close
// 第四步: 防治 —— 封装连接处理, 用 defer 从源头保证所有路径都 close
func serve(conn net.Conn) {
defer conn.Close() // ✓ 一进来就登记, 任何出口都释放
conn.SetDeadline(time.Now().Add(30 * time.Second)) // 顺手加超时, 防卡死
for {
req, err := readRequest(conn)
if err != nil {
return // 出错也会触发 defer close, 不泄漏
}
if err := writeResponse(conn, process(req)); err != nil {
return // 同样安全释放
}
}
}
// 第五步: 客户端侧, 封装请求确保 body 必被 close + 读干净(以便连接复用)
func get(url string) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close() // ✓ 关键: body 必关
return io.ReadAll(resp.Body) // 读干净, 连接可回池复用
}
// 第六步: 加监控告警 —— fd 使用率、CLOSE_WAIT 数, 超阈值就报警, 别等耗尽
// metrics.gauge("conn.close_wait", countCloseWait())
// metrics.gauge("process.fd_used_ratio", fdUsedRatio())
这套骨架把我这次的教训钉死在了流程里:出问题先用 ss/lsof 确认是 CLOSE_WAIT(被动方漏 close)还是 TIME_WAIT、定位到具体进程和对端、回代码里揪出漏 close 的路径;防治上每条连接和 body 都用 defer 兜住 close、覆盖所有出口、顺手加读写超时;最后用 fd 使用率和 CLOSE_WAIT 数的监控告警在耗尽前就报警。这样,连接收尾的 close 就由 defer 雷打不动地执行,CLOSE_WAIT 不再堆积、fd 及时归还,而不再是当初那个"处理完就走人、把 fd 一个个漏光"的裸奔服务。把"看清等我履责的状态、及时履行自己那份释放责任"这个道理,沉淀成排查与防治的固定骨架,这是我对这次"服务被自己的连接泄漏拖垮"最实在的交代——毕竟,接了的客,总得自己亲手送走。
写在最后
回头看,这场由"漏调 close"引发的"CLOSE_WAIT 堆积、fd 耗尽"事故,真正教给我的,远不止"记得 close 连接"这一个技巧。它让我对"很多'协作'是分阶段、需要各方依次尽责的:对方完成了它那一步、把接力棒交到你手里,系统于是停在一个'等你出手'的状态;这个状态本身就是一个明确的信号——'该你了';可如果你误以为这个停顿是'对方的拖延',而不是'在等你履责',你就会一直不去做本该你做的事,让整个协作卡死、资源耗尽",有了一次刻骨的体会。我栽跟头,是因为我把一个"系统在等我履行我那一份责任"的状态,误读成了"对方没尽责"——CLOSE_WAIT 明明是 TCP 在对我说"对端已经关了、收尾的 close 该你来调了";而我看到一堆 CLOSE_WAIT,第一反应却是"对端怎么不关连接",把矛头指向了别处;于是我既没意识到这是在等我、也就一直没去补上那个 close,连接和 fd 就这样一个个泄漏,直到把整个服务拖垮。这让我领悟到一个关于"协作中的状态与责任归属"的深刻认知:在任何需要多方依次配合才能完成的流程里(关闭连接、交接工作、确认收货、双方签字、资源的获取与释放),都存在一些"一方已完成、正等另一方接手"的中间状态;这种状态的本质是一个责任的"交接点"——球已经传到了某一方手里,流程能否推进下去,取决于这一方有没有意识到"现在轮到我了"并履行自己那一份责任;而最危险的误区,是把这个"等我履责"的状态,错误地归因为"对方的失职":一旦责任的归属判断错了,人就会一直向外找原因、一直不去做那件其实只有自己能做的事,于是这个交接点就成了永久的堵塞点,挂在上面的资源永远得不到释放;系统把流程停在这里、不替你往下走,恰恰是因为这一步的决定权和责任都在你——它在等你,而不是在替你犯错。这给了我一种看待"一切'卡住、不推进、资源不释放'之事"时的清醒:每当我发现一个协作流程卡住了、某些资源迟迟不释放、某个状态堆积不消时,要追问"这个卡住的状态,到底是在等对方,还是在等我自己履行某一步责任?这个'停顿'是不是一个'该我出手'的信号,而我把它误当成了别人的拖延"——先审视自己在这个协作里该尽而未尽的那一份责任,而不是急于把卡顿归咎于对方;"看清'等我履责'的状态、及时履行自己那一份释放/收尾的责任",是用对 TCP 连接、也是让一切协作流程不卡死的关键。认清 CLOSE_WAIT 是被动方等应用 close、堆积是自己漏 close、每条连接都要 defer 兜住 close——这,是我用一次"fd 耗尽、服务瘫痪"的事故,换来的、关于网络、也关于如何看清协作中责任归属的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次看到一堆 CLOSE_WAIT 时,先别急着怪对端、而是回头查查自己哪条代码路径漏了 close,并给每条连接都加上一个 defer close,那我对着那满屏 too many open files、一次次重启服务的那些天,就值了。
—— 别看了 · 2026