一个调用下游接口后忘了关闭连接的服务,在下游主动断开后留下了一大堆 CLOSE_WAIT,把文件描述符耗尽、再也建不了新连接:一次 CLOSE_WAIT 堆积的深度复盘

服务跑一段时间后大量报 too many open files、建不了新连接,netstat 一看成千上万个 CLOSE_WAIT 只增不减、把 fd 耗尽。根因是调下游接口后没正确 Close 响应体/连接:下游处理完主动关闭它那端发来 FIN,我方收到后进入 CLOSE_WAIT(等我方 close),而代码没 close(异常路径漏关、忘了 defer),连接就永远停在 CLOSE_WAIT、fd 泄漏。本文讲透 TCP 四次挥手与 CLOSE_WAIT 含义(及与 TIME_WAIT 的区别),给出拿到响应体立刻 defer Close 覆盖所有路径、读完 body 让连接可复用的正解,梳理连接/fd 管理常见坑,最后落到'CLOSE_WAIT 堆积=我方漏关、资源要成对开关、关闭连接是双方共同的责任'的认知。

一个调用下游接口后忘了关闭连接的服务,在下游主动断开后留下了一大堆 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 耗尽"的事故,换来了我做网络编程时,刻进骨子里的几条铁律:

  1. CLOSE_WAIT 表示"对端已关、我方未关"。大量出现几乎一定是我方代码漏关。
  2. 拿到连接/响应体立刻 defer Close。保证任何路径下都关闭。
  3. HTTP 响应体要读完再关。否则 keep-alive 连接不能复用。
  4. 异常路径、提前 return 最容易漏关。defer/finally 正是为覆盖这些路径。
  5. CLOSE_WAIT 不会自动消退、TIME_WAIT 会。分清两者、对症处理。
  6. too many open files 先查漏关的连接/文件。用 lsof 定位。
  7. 一切获取的资源都要成对释放、就近写好释放。

写在最后

回头看,这场由"连接用完没关"引发的、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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

两个并发事务因为以不同的顺序去更新两条记录,互相等着对方手里的锁,撞成了死锁、被 MySQL 强行回滚了一个:一次数据库死锁的深度复盘

2026-6-2 18:55:44

技术教程

一个 liveness 探针配置得又急又严、还去查下游的服务,在启动慢或下游抖动时被 Kubernetes 反复 kill 重启,陷入 CrashLoopBackOff:一次健康检查配置不当的深度复盘

2026-6-2 19:06:19

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