一个调用下游接口后忘了关闭连接的服务,在下游主动断开后留下了一大堆 CLOSE_WAIT,把文件描述符耗尽、再也建不了新连接:一次 CLOSE_WAIT 堆积的深度复盘
那次故障的现象是"建不了新连接":我们一个服务跑了一段时间后,开始大量报 too many open files(打开的文件太多),新的网络连接建不了、新文件也打不开,服务基本瘫痪。我 SSH 上去 netstat 一看,触目惊心:有成千上万个连接处于 CLOSE_WAIT 状态,且只增不减。这些 CLOSE_WAIT 连接,每一个都占着一个文件描述符(fd),把进程的 fd 配额耗尽了。我顺着这条线索排查,才终于看清 CLOSE_WAIT 的含义和成因,后背发凉:CLOSE_WAIT 这个状态,意思是"对方已经关闭了连接(发来了 FIN),而我方还没有关闭自己这一端"。TCP 关闭连接是"四次挥手"——对方发 FIN 表示"我不发数据了",我方应该也调用 close() 把自己这端关掉、完成挥手。可我们的代码,在调用下游接口、下游处理完主动关闭连接(发 FIN)后,没有正确地 close() 我方持有的那个连接(比如没读完响应体就不管了、或异常路径上漏了关闭)。于是我方的连接就一直停留在 CLOSE_WAIT 状态——它在等我方调用 close,但我方永远不调;这个连接、以及它占的那个文件描述符,就永远不释放。每次这种情况发生,就泄漏一个 CLOSE_WAIT 连接和一个 fd;日积月累,把 fd 耗尽,服务就建不了任何新连接了。问题的根,是我方作为"被动关闭方",收到对端 FIN 后没有履行"关闭自己这端"的责任。这篇就把这次"CLOSE_WAIT 堆积"的坑,从头到尾复盘一遍。
故障现场:连接用完没关,堆在 CLOSE_WAIT
问题代码,是一个调用下游、却没确保关闭连接/响应体的写法:
// ✗ 出问题的代码: 调下游接口, 但没正确关闭响应(连接)
func callDownstream(url string) (string, error) {
resp, err := http.Get(url) // 发起请求
if err != nil {
return "", err
}
// ✗ 忘了 defer resp.Body.Close()!
if resp.StatusCode != 200 {
return "", fmt.Errorf("status %d", resp.StatusCode)
// ✗ 这条return路径直接走了, resp.Body没关 → 连接没释放!
}
data, _ := io.ReadAll(resp.Body)
return string(data), nil
// ✗ 即使正常路径, 也忘了 resp.Body.Close()
}
// 为什么堆CLOSE_WAIT:
// - HTTP响应的 resp.Body 没有Close → 底层TCP连接没有被正确关闭/归还;
// - 当下游处理完、主动关闭它那端(发FIN)后:
// - 我方收到FIN, TCP状态进入 CLOSE_WAIT(意思: 对方关了, 等我方也close);
// - 但我方代码【没有close这个连接】(Body没关) → 永远停在CLOSE_WAIT;
// - → 这个连接和它占的文件描述符(fd)永远不释放 → 泄漏;
// - 每次调用泄漏一个; 积累到把进程的fd配额(ulimit)耗尽 → too many open files。
// TCP四次挥手 与 两个易混的状态:
// - 主动关闭方(先发FIN的): 最后会进入 TIME_WAIT(等2MSL确保对方收到ACK, 数十秒后释放);
// - 被动关闭方(收到FIN的): 收到后进入 CLOSE_WAIT, 【等自己调close】才继续挥手、释放;
// - → CLOSE_WAIT堆积 = 我方收到了对端的FIN, 却迟迟不close(代码bug, 连接没关);
// TIME_WAIT堆积 = 我方主动关了太多短连接(见HttpClient那类问题), 是另一回事。
// 关键: CLOSE_WAIT表示"对端已关, 我方未关"; 大量CLOSE_WAIT = 我方代码没正确关闭连接(没Close
// 响应体/socket); 连接和fd泄漏, 最终too many open files。
第一次搞懂 CLOSE_WAIT 时,我又恍然又懊恼:"原来 CLOSE_WAIT 一多,几乎一定是我自己的代码没关连接——这是个'指向自己'的强信号。"这个坑最有价值的地方,正是 CLOSE_WAIT 这个状态本身就是一条精准的诊断线索:大量 CLOSE_WAIT,几乎板上钉钉地说明"我方收到了对端的关闭、却没有 close 自己这端"——也就是我方代码漏关了连接;它不像有些问题成因模糊,CLOSE_WAIT 几乎直接告诉你"去找你代码里没 close 的连接"。而它又是慢性的(每次漏关泄漏一个、慢慢耗尽 fd),潜伏期长。下面就来拆解 TCP 挥手和怎么根治。
第一件事:搞懂 TCP 四次挥手、CLOSE_WAIT 的含义
我认真重学了 TCP 的连接关闭,才彻底理解 CLOSE_WAIT。
TCP 四次挥手 与 CLOSE_WAIT 的含义
【核心: 关连接要双方各自close(四次挥手); 收到对端FIN后进CLOSE_WAIT, 等"我方close"; 我方不close就一直堆在CLOSE_WAIT、泄漏fd】
1. TCP 关闭连接是"四次挥手"(双方都要关自己这端):
- 一方(主动关闭方)发 FIN: "我没有数据要发了";
- 另一方(被动关闭方)回 ACK, 然后【自己也发 FIN】: "我也没数据了";
- 主动方回 ACK, 连接关闭。
- → 关键: 连接是【全双工】的, 两个方向, 双方都要各自关闭自己的发送方向。
2. CLOSE_WAIT 状态(被动关闭方的):
- 被动关闭方【收到对端的FIN、回了ACK】后, 就进入 CLOSE_WAIT;
- 它在【等待本方应用程序调用 close()】, 来发出自己的FIN、继续挥手;
- → CLOSE_WAIT 的含义就是: "对方已经关了, 在等【我方】关";
- → 如果我方应用【迟迟不调close】(代码没关连接/响应体), 就一直卡在CLOSE_WAIT。
3. 大量CLOSE_WAIT = 我方代码bug:
- 正常情况: 收到FIN后, 应用很快close, CLOSE_WAIT一闪而过;
- 大量、持续的CLOSE_WAIT = 应用【没有及时/正确地close连接】 → 几乎一定是代码漏关;
- → 每个没关的连接占一个文件描述符(fd); 堆多了耗尽fd → too many open files。
4. 对比 TIME_WAIT(别搞混):
- TIME_WAIT 是【主动关闭方】关闭后的状态, 等2MSL(数十秒)自动释放(防旧包串扰);
- 大量TIME_WAIT 通常是"主动关了太多短连接"(如频繁新建HttpClient), 会自动消退;
- CLOSE_WAIT 是【被动方没close】, 【不会自动消退】(等你close), 是代码bug。
5. 根因与责任:
- CLOSE_WAIT堆积的根因, 几乎总是: 我方拿到的连接(尤其HTTP响应体)用完没Close;
- 连接是要"成对地开和关"的资源; 收到对端关闭后, 我方有责任关闭自己这端。
一句话: TCP关连接要双方各自close(四次挥手); 收到对端FIN后进CLOSE_WAIT、等我方close;
我方代码漏关连接(响应体没Close)就一直堆在CLOSE_WAIT、泄漏fd→too many open files。
这套机制,是整个坑的根。TCP 关闭是四次挥手:连接是全双工的、双方都要各自关闭自己的发送方向(一方发 FIN、另一方回 ACK 后也发 FIN)。CLOSE_WAIT 状态(被动关闭方的):收到对端 FIN、回 ACK 后进入,在等本方应用调用 close() 来发自己的 FIN——含义就是"对方已关,在等我方关",我方迟迟不 close 就一直卡在 CLOSE_WAIT。大量 CLOSE_WAIT = 我方代码 bug:正常情况收到 FIN 后应用很快 close、CLOSE_WAIT 一闪而过;大量持续的 CLOSE_WAIT 几乎一定是代码漏关连接,每个占一个 fd、堆多了 too many open files。对比 TIME_WAIT 是主动关闭方的状态、等 2MSL 自动释放(频繁新建短连接导致、会自动消退);CLOSE_WAIT 是被动方没 close、不会自动消退(等你 close)、是代码 bug。根因:几乎总是我方拿到的连接(尤其 HTTP 响应体)用完没 Close;连接是要"成对开关"的资源、收到对端关闭后我方有责任关自己这端。一句话:TCP 关连接要双方各自 close(四次挥手);收到对端 FIN 后进 CLOSE_WAIT、等我方 close;我方代码漏关连接(响应体没 Close)就一直堆在 CLOSE_WAIT、泄漏 fd→too many open files。
第二件事:正解——用完一定关闭(defer Close)、读完响应体、处理好异常路径
搞懂了原理,正解就清晰了:拿到连接/响应体就立刻 defer Close(确保任何路径都关)、读完响应体让连接能复用、复用连接池、处理好所有异常/提前返回的路径。
// ✓ 正解: 拿到响应立刻 defer Close, 确保任何路径都关闭
func callDownstream(url string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close() // ★ 拿到resp立刻defer Close, 任何return路径都会关!
// (Go里这是铁律: 拿到响应体马上defer关闭)
if resp.StatusCode != 200 {
// 即使这条提前return的路径, defer也保证Body被关 → 不泄漏
io.Copy(io.Discard, resp.Body) // ★ 把剩余响应体读完(丢弃), 让底层连接能被复用
return "", fmt.Errorf("status %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body) // 正常路径: 读完body(读完+Close, 连接可复用)
return string(data), err
}
// → defer resp.Body.Close(): 保证函数从任何路径返回时, 响应体(连接)都被关闭 → 不堆CLOSE_WAIT。
// io.Copy(io.Discard, body): 读完剩余body, 让keep-alive连接能放回连接池复用(不读完可能不复用)。
# ====== 防CLOSE_WAIT/连接泄漏的几条原则 ======
# 1. 拿到连接/响应体, 立刻 defer Close(Go)/try-with-resources(Java)/using(C#):
# 把"关闭"和"获取"绑在一起, 保证【任何路径(正常/异常/提前return)】都会关闭。
# 2. 读完响应体再关: HTTP keep-alive下, body没读完连接可能不能复用; 读完(或io.Discard掉)再Close。
# 3. 异常路径别漏关: 提前return、抛异常的路径最容易漏关闭——defer/try-finally正是为了覆盖这些路径。
# 4. 用连接池并正确归还: 用HTTP客户端的连接池(复用连接); 确保用完归还(Close就是归还)。
# 5. 设超时: 连接/读写设超时, 别让连接因对方不响应而长时间挂着。
# 6. 监控: 监控进程的fd使用数、CLOSE_WAIT连接数; 持续增长就是泄漏的强信号。
# - lsof -p | wc -l 看fd数; netstat | grep CLOSE_WAIT | wc -l 看CLOSE_WAIT数。
# ====== 排查口诀 ======
# too many open files / 大量CLOSE_WAIT → 几乎一定是【代码漏关了连接/响应体/文件】 → 查Close。
# 用 lsof 看进程开了哪些fd(指向哪些连接/文件), 定位泄漏源。
# 核心: 拿到连接/响应体立刻defer Close(覆盖所有路径)、读完body让连接可复用、处理好异常路径、
# 用连接池、设超时、监控fd和CLOSE_WAIT数; CLOSE_WAIT堆积=我方漏关, 去代码里找没Close的地方。
修复的核心,是"拿到连接就保证它在任何路径下都会被关闭"。正解:拿到 resp 立刻 defer resp.Body.Close()(保证函数从任何路径返回时响应体都被关闭、不堆 CLOSE_WAIT);io.Copy(io.Discard, body) 读完剩余响应体让 keep-alive 连接能放回连接池复用(不读完可能不复用)。原则:拿到就 defer Close/try-with-resources/using(把关闭和获取绑在一起、覆盖所有路径)、读完响应体再关、异常路径别漏关、用连接池并正确归还、设超时、监控 fd 和 CLOSE_WAIT 数。排查口诀:too many open files/大量 CLOSE_WAIT 几乎一定是代码漏关连接,用 lsof 看进程开了哪些 fd 定位泄漏源。归根结底:拿到连接/响应体立刻 defer Close(覆盖所有路径)、读完 body 让连接可复用、处理好异常路径、用连接池、设超时、监控 fd 和 CLOSE_WAIT 数;CLOSE_WAIT 堆积=我方漏关,去代码里找没 Close 的地方。
第三件事:连接/文件描述符管理的其他常见坑
排查后我把连接/fd 管理相关的其他常见坑也系统梳理了一遍。
连接 / 文件描述符管理的其他常见坑
# 1. CLOSE_WAIT堆积(本文): 收到对端FIN没close。→ defer Close覆盖所有路径。
# 2. HTTP响应体没读完: keep-alive连接不能复用, 每次新建。→ 读完body(io.Discard)再Close。
# 3. TIME_WAIT堆积: 主动关太多短连接。→ 用长连接/连接池复用(同HttpClient那类)。
# 4. 文件没close: 打开文件没关, fd泄漏。→ defer/using关闭。
# 5. 连接池配置不当: 池太小排队、连接没设最大空闲时间/存活时间。→ 合理配连接池参数。
# 6. fd配额(ulimit)太低: 高并发服务fd上限默认1024太低。→ 调大ulimit -n。
# 7. 异常路径漏关: 只在happy path关了, 异常/提前return漏关。→ defer/finally保证关闭。
# 8. 数据库连接没关/没归还: 同理, 连接泄漏导致连接池耗尽。→ 用完归还(close归还到池)。
# 共同根源: "连接/文件/fd"都是【有限的、需成对开关的资源】; 只开不关(尤其异常路径漏关),
# 就会泄漏、最终耗尽资源(too many open files / 连接池耗尽)——和goroutine/内存泄漏同一类。
# 核心: 一切"获取的资源"(连接/响应体/文件/fd)都要保证关闭——用defer/try-finally/using覆盖所有路径;
# 读完响应体让连接可复用、用连接池、调大fd配额、监控fd和连接状态; CLOSE_WAIT多就查漏关。
排查让我把连接/fd 管理的其他坑也梳理清了。一、CLOSE_WAIT 堆积(本文)。二、HTTP 响应体没读完(连接不能复用)。三、TIME_WAIT 堆积(用连接池)。四、文件没 close。五、连接池配置不当。六、fd 配额太低(调大 ulimit)。七、异常路径漏关(defer/finally)。八、数据库连接没归还。它们的共同根源是:"连接/文件/fd"都是有限的、需成对开关的资源;只开不关(尤其异常路径漏关)就会泄漏、最终耗尽资源——和 goroutine/内存泄漏同一类。核心是:一切"获取的资源"都要保证关闭——用 defer/try-finally/using 覆盖所有路径;读完响应体让连接可复用、用连接池、调大 fd 配额、监控 fd 和连接状态;CLOSE_WAIT 多就查漏关。下面这张图,是这次 CLOSE_WAIT 坑的成因与解法:
第四件事:CLOSE_WAIT vs TIME_WAIT 对比表
这次踩坑后,我把最容易混淆的 CLOSE_WAIT 和 TIME_WAIT 对比成一张表。
| 维度 | CLOSE_WAIT(本文) | TIME_WAIT |
|---|---|---|
| 出现在哪方 | 被动关闭方(收到FIN的) | 主动关闭方(先发FIN的) |
| 含义 | 对方关了, 等我方close | 我方关了, 等2MSL确保对方收到ACK |
| 会自动消退吗 | ✗ 不会(等你close) | ✓ 会(数十秒后) |
| 大量出现说明 | 我方代码漏关连接(bug) | 主动关了太多短连接 |
| 解法 | 代码里补上Close | 用长连接/连接池复用 |
这张表把两个易混状态钉清了。核心是:CLOSE_WAIT 在被动关闭方、是"对方关了我没关"、不会自动消退、大量出现=我方代码漏关(bug);TIME_WAIT 在主动关闭方、是"我关了等确认"、会自动消退、大量出现=主动关了太多短连接;两者出现的一方、成因、解法都不同。它给我的最大启发是:排查问题时,"状态/现象本身,往往就编码了重要的诊断信息"——看到 CLOSE_WAIT 还是 TIME_WAIT,就能直接推断出"是我方没关"还是"我方关多了",从而精准地指向不同的根因和解法;很多系统的"状态名、错误码、日志级别"都是设计者精心编码的诊断线索,读懂它们能让排查事半功倍。这让我养成一个习惯:遇到一个状态/错误,不要只把它当"出问题了"的笼统信号,而要去精确理解"这个具体的状态/错误码,到底意味着什么、是哪个环节、哪一方的什么情况"——CLOSE_WAIT 精确地指向"被动方没 close"、ECONNREFUSED 指向"对方没监听"、ETIMEDOUT 指向"没响应";"读懂状态和错误码携带的精确信息",是高效排查网络/系统问题的关键功夫——这些信息是前人为帮你诊断而精心设计的。分清 CLOSE_WAIT 与 TIME_WAIT、读懂状态码携带的精确诊断信息——是这个坑带给我的排查认知。
第五件事:这个坑再次印证"资源要成对开关"
这次又是一个"开了没关"的资源泄漏。我把各种"成对开关"的资源整理成表。
| 获取(开) | 释放(关) | 漏关的后果 |
|---|---|---|
| http.Get拿到resp | resp.Body.Close() | CLOSE_WAIT堆积/连接泄漏(本文) |
| 打开文件 | file.Close() | fd泄漏 |
| 获取数据库连接 | 归还连接池 | 连接池耗尽 |
| 加锁 | 解锁 | 死锁/别人永久阻塞 |
| 起goroutine | 保证它退出 | goroutine泄漏 |
| 分配内存/订阅事件 | 释放/退订 | 内存泄漏 |
这张表道出了一条贯穿始终的规律。核心是:CLOSE_WAIT 堆积,和文件 fd 泄漏、连接池耗尽、死锁、goroutine 泄漏、内存泄漏,本质都是同一条铁律的违反:"有获取就必须有释放,且要在所有路径下都释放";凡是"成对的资源操作"(开/关、加/解、申请/释放、订阅/退订),漏了"关"的那一半,就会泄漏、最终耗尽。它给我的深刻启发是:"有借有还、有开有关、有始有终"是编程里一条极其普适、却又极易被违反的纪律——违反它的方式高度一致:正常路径记得关,异常/提前返回的路径忘了关;所以各语言都发明了专门的机制来强制保证"关"一定执行:Go 的 defer、Java 的 try-with-resources、C# 的 using、Python 的 with、RAII。这给了我一种近乎肌肉记忆的编码习惯:每当我"获取"一个需要释放的资源(连接、文件、锁、goroutine),就在那一行的紧下一行,立刻用 defer/using/with 写好它的"释放"——不等到"用完了再回头补"(往往就忘了、或漏了某条路径);"获取与释放配对地、就近地写,用语言机制保证释放一定执行",是杜绝一切资源泄漏(包括 CLOSE_WAIT)的根本习惯。再次印证"资源要成对开关"、用 defer/using 就近保证释放——是这个 CLOSE_WAIT 坑又一次强化的认知。
第六件事:调用下游/打开资源时,我现在的检查习惯
现在每当我调用下游接口、或打开任何连接/文件,我都会按这张图先想一想:
这张图的精髓,是"拿到资源立刻写关闭、覆盖所有路径、响应体读完"。拿到连接/文件立刻 defer Close/try-finally/using、确保异常和提前 return 路径也关、HTTP 响应体读完让连接可复用、监控 fd 和 CLOSE_WAIT 数。这套习惯,让我从"用完再想着关"变成了"拿到就立刻写好关闭"——核心始终是:获取连接/资源就立刻 defer 关闭、覆盖所有路径,CLOSE_WAIT 堆积是漏关的强信号。
我立下的几条规矩
这场"CLOSE_WAIT 堆积、fd 耗尽"的事故,换来了我做网络编程时,刻进骨子里的几条铁律:
- CLOSE_WAIT 表示"对端已关、我方未关"。大量出现几乎一定是我方代码漏关。
- 拿到连接/响应体立刻 defer Close。保证任何路径下都关闭。
- HTTP 响应体要读完再关。否则 keep-alive 连接不能复用。
- 异常路径、提前 return 最容易漏关。defer/finally 正是为覆盖这些路径。
- CLOSE_WAIT 不会自动消退、TIME_WAIT 会。分清两者、对症处理。
- too many open files 先查漏关的连接/文件。用 lsof 定位。
- 一切获取的资源都要成对释放、就近写好释放。
写在最后
回头看,这场由"连接用完没关"引发的、CLOSE_WAIT 堆积耗尽 fd 的事故,真正教给我的,远不止"defer resp.Body.Close()"这一个技巧。它让我对"关闭一个连接,是双方共同的责任;对方尽了它的责任(发了 FIN),我却没尽我的(没 close),协作就卡在了我这一半",有了一次刻骨的体会。我栽跟头,根源在于我对"关闭连接"的理解是单方面的:我以为"连接关闭"是一个动作、一方做了就完了。可 TCP 的连接是全双工的、关闭是四次挥手——它本质上需要通信的双方各自关闭自己的那一端,是一件需要双方协作完成的事;下游(对方)已经尽职地发来了 FIN("我这端关了"),把"该我方关了"这个球传到了我这边;可我方的代码没有接住这个球(没 close),于是整个关闭流程就卡在我这一半——连接停在 CLOSE_WAIT,既没关成、又占着资源。这让我领悟到一个关于"协作流程"的深刻认知:很多事情的"完成",需要多方各自履行自己那一部分责任——一方做了它该做的,不代表事情就完成了;如果另一方没履行它的那一份,整个流程就卡在没履责的那一方,前面做的也白费;TCP 挥手要双方都 close、握手要双方都确认、事务要各方都提交、协议要双方都遵守——这些"协作完成"的流程,任何一方掉链子,整体就不成立。这给了我一种协作流程中的责任自觉:当我处在一个"需要多方协作完成"的流程里时,要清楚地知道"我这一方该履行什么责任、在什么时候履行",并切实地把它履行到位——"对方把球传给我了,我要接住并传下去"(收到 FIN 就 close、收到请求就响应、收到消息就 ack);"认清并履行自己在协作流程中的那一份责任",不掉链子,才能让需要多方配合的事情真正走完——这在网络协议、分布式协作、团队配合里都一样。认清关闭连接是双方共同的责任、履行好自己在协作流程中的那一份——这,是我用一次 CLOSE_WAIT 堆积的事故,换来的、关于网络、也关于如何在一切协作流程中尽责的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次调完下游接口时,顺手就 defer resp.Body.Close(),那我对着那满屏 CLOSE_WAIT 排查的这段时间,就值了。
—— 别看了 · 2026