我的服务跑着跑着就再也连不上下游了,报 too many open files,netstat 一看几千个 CLOSE_WAIT 堆在那,我对着忘记关闭 HTTP 响应体导致连接泄漏这个坑排查了大半天的复盘
这是一个让我对"关闭资源"这件事彻底敬畏的网络坑。它的可怕,在于它是一个缓慢累积、定时爆发的雷:服务刚启动时一切正常,可运行几个小时甚至几天后,会突然像被掐住喉咙一样,再也无法建立任何新的网络连接,整个服务陷入瘫痪。
需求很常见:我的服务需要频繁调用一个下游 HTTP 接口拿数据。我用 Go 写了个简单的调用函数,逻辑清晰、看起来毫无问题:
// 调用下游接口拿数据(有问题的版本)
func fetchData(url string) ([]byte, error) {
resp, err := http.Get(url) // 发起 HTTP 请求
if err != nil {
return nil, err
}
// ★★★ 致命问题: 我忘了 resp.Body.Close()! ★★★
if resp.StatusCode != 200 {
return nil, fmt.Errorf("status %d", resp.StatusCode)
// ↑ 这里直接 return, 更没机会关 Body 了
}
body, err := io.ReadAll(resp.Body)
return body, err
// 函数结束, resp.Body 始终没被 Close
}
这个函数,在测试时、在服务刚上线时,跑得好好的。可上线运行一段时间后,告警就来了——服务开始大量报错:
监控告警与日志:
dial tcp 10.0.1.5:8080: socket: too many open files
↑ 无法建立新连接: 打开的文件(描述符)太多了!
# 现象:
# - 服务刚启动: 一切正常
# - 运行几小时后: 调用下游开始间歇性失败
# - 再过一阵: 完全无法建立新连接, 服务瘫痪
# - 重启服务 → 又好了, 然后过几小时又复发(典型的"资源泄漏"特征)
# 上服务器 netstat 一看, 触目惊心:
$ netstat -an | grep CLOSE_WAIT | wc -l
4823
↑ 4823 个连接卡在 CLOSE_WAIT 状态!
我盯着那 4823 个 CLOSE_WAIT 和 too many open files,心里大概有数了——这是典型的"资源泄漏":连接只建不关,像水池只进水不放水,迟早溢出。可我明明没有"故意不关"什么东西啊?而且,为什么是 CLOSE_WAIT 这个状态?它到底意味着什么?
第一件事:看清真相——CLOSE_WAIT 是"对方已关、等我关",我不关它就一直耗着 fd
我去复习了 TCP 的四次挥手(连接关闭)流程,再结合代码,终于把这条因果链彻底理清了——CLOSE_WAIT 状态,意思是"对方已经关闭了连接,正等着我这边也关闭";而我忘了 Close(),连接就永远停在这个状态,死死占着一个文件描述符(fd)。
CLOSE_WAIT 与连接泄漏的真相
# 1. TCP 关闭连接要"四次挥手", 双方都要发 FIN、都要确认:
# 对方发 FIN(我要关了) → 我回 ACK(收到) → [我这边进入 CLOSE_WAIT]
# → 我也发 FIN(我也关了) → 对方回 ACK → 连接彻底关闭
#
# 2. CLOSE_WAIT 是什么:
# - 当【对方】先关闭连接, 给我发了 FIN, 我的内核回了 ACK 后,
# 我这一端的连接就进入 CLOSE_WAIT 状态。
# - 含义: "对方已经关了, 现在【等我这边的应用程序】也调用 close() 来关闭"
# - ★ 关键: 从 CLOSE_WAIT 走到"彻底关闭", 必须由【我的应用代码主动 close()】触发!
# 内核不会自动帮我关——它在等我的程序说"我也关好了"。
#
# 3. 我的代码做了什么:
# - http.Get 拿到 resp, 用完后【没有 resp.Body.Close()】
# - 下游(或连接空闲)关闭了连接, 给我发 FIN → 我进入 CLOSE_WAIT
# - 但我的代码永远不调 Close() → 连接永远停在 CLOSE_WAIT!
# - 每停一个, 就占用一个文件描述符(fd)
#
# 4. 为什么最终 "too many open files":
# - Linux 每个进程能打开的 fd 数有上限(ulimit, 如 1024)
# - 每个 socket 连接占一个 fd
# - CLOSE_WAIT 的连接不释放, fd 只增不减
# - 累积到上限 → 无法再创建新 socket → "too many open files" → 连不上下游!
#
# 5. 为什么重启就好、过会儿复发:
# - 重启进程, 所有 fd 释放归零 → 暂时正常
# - 但泄漏的代码还在, 又开始一个个累积 → 几小时后再次耗尽
# 核心: CLOSE_WAIT 表示"对方已关、等我的应用主动close()"; 忘记关闭连接/响应体,
# 连接就卡在CLOSE_WAIT、永久占用fd; 累积到fd上限就"too many open files"、无法建新连接。
真相彻底清晰了,我后背发凉。原来 CLOSE_WAIT 的含义是"对方已经关闭了连接、正等着我这边的应用程序也调用 close()";而从 CLOSE_WAIT 走到"连接彻底关闭",必须由我的应用代码主动 close() 来触发——内核不会自动帮我关,它在等我的程序说"我也关好了"。可我的代码,http.Get 拿到 resp 后从没调用 resp.Body.Close()!于是:下游关闭连接、给我发 FIN,我进入 CLOSE_WAIT,然后……就一直停在那里,因为我永远不会去 Close()。每一个这样泄漏的连接,都死死占着一个文件描述符(fd);而 Linux 每个进程能打开的 fd 是有上限的(ulimit),CLOSE_WAIT 的连接只增不减,累积到上限,就再也无法创建新 socket——这就是 too many open files、就是连不上下游的根源。也完美解释了"重启就好、过几小时复发":重启让 fd 归零、暂时正常,但泄漏的代码还在,又开始一个个累积。
第二件事:正解——确保响应体一定被关闭,并读完它以复用连接
搞懂了原理,正解就清晰了:拿到响应后,用 defer 确保 Body 一定被 Close,并且要把 Body 读完(或显式丢弃),这样连接才能被正确关闭或复用。
// ====== 正解(Go): defer resp.Body.Close() + 读完Body ======
func fetchData(url string) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close() // ★ 关键: defer 确保函数无论从哪return, Body都会被Close!
if resp.StatusCode != 200 {
// 即使这里提前return, 上面的 defer 也会执行 Close ✓
io.Copy(io.Discard, resp.Body) // (可选)把body读完丢弃, 利于连接复用
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body) // 读完body
return body, err
}
// 关键点:
// 1. defer resp.Body.Close() 紧跟在 err 检查之后写, 保证一定关闭
// 2. 读完 Body(ReadAll 或 io.Copy到Discard): 没读完的连接无法被复用, 只能关掉
// ====== 为什么要"读完"Body? ======
// Go 的 http 客户端有连接池, 会复用 TCP 连接(keep-alive)以提高性能。
// 但只有"Body被完整读取 且 Close了"的连接, 才能被放回池子复用;
// 否则连接状态不干净, 只能被关闭, 复用失效(性能下降, 但不至于泄漏)。
// ====== 其他语言同理: 资源一定要在 finally/using/with 里关 ======
// Java: try (var resp = client.execute(req)) { ... } // try-with-resources 自动关
// Python: with requests.get(url) as resp: ... // with 自动关
// 或 用 session 复用连接
// C#: using var resp = await client.GetAsync(url); // using 自动 Dispose
// ====== 配套: 设置超时、复用 client、限制连接数 ======
client := &http.Client{
Timeout: 5 * time.Second, // ★ 一定设超时! 否则慢响应会一直占着连接
}
// 复用同一个 client(它内部有连接池), 不要每次请求都 new 一个 client
// 配置 Transport 的 MaxIdleConns、MaxIdleConnsPerHost 控制连接池大小
// 核心: 拿到响应立刻 defer Close()(保证任何分支都关), 并读完Body以复用连接;
// 用 finally/using/with/defer 这类机制确保资源一定释放; 配套设超时、复用client。
修复的核心,是"用 defer 保证响应体一定被关闭,并读完它"。正解(Go):defer resp.Body.Close()——紧跟在 err 检查之后写,这样无论函数从哪个分支 return,Body 都一定会被 Close(包括 StatusCode != 200 提前 return 的分支)。还要读完 Body:Go 的 http 客户端有连接池、会复用 TCP 连接(keep-alive),但只有"Body 被完整读取且 Close 了"的连接才能放回池子复用;没读完的只能关掉(复用失效但不泄漏)。其他语言同理——资源一定要在 finally/using/with 里关(Java 的 try-with-resources、Python 的 with、C# 的 using)。配套还要:设置超时(否则慢响应会一直占着连接)、复用同一个 client(它内部有连接池,别每次 new)、配置连接池大小。归根结底:拿到响应立刻 defer Close()(保证任何分支都关)并读完 Body 以复用连接;用 finally/using/with/defer 确保资源一定释放;配套设超时、复用 client。
第三件事:网络/资源泄漏相关的其他常见坑
排查后我把网络编程里"资源泄漏"相关的其他常见坑也系统梳理了一遍。
网络/资源泄漏的其他常见坑
# 1. HTTP响应体没关(本文): CLOSE_WAIT堆积、fd耗尽。→ defer Close()。
# 2. 没设超时, 连接永久挂起: 下游不响应, 请求一直等
# → 连接被长期占用, 线程/连接池耗尽, 服务雪崩。→ 必须设 connect/read 超时。
# 3. 每次请求new一个client/连接, 不复用:
# → 频繁建连/断连, 浪费; 还可能产生大量 TIME_WAIT。→ 复用client/连接池。
# 4. TIME_WAIT 大量堆积: 主动关闭方会进入TIME_WAIT(等2MSL)
# → 短连接高并发下大量TIME_WAIT占端口。→ 用长连接/连接池, 调内核参数。
# 5. 数据库连接/文件句柄不关: 和HTTP一样, 不关就泄漏。
# → defer conn.Close()/rows.Close(); 用连接池并设最大连接数。
# 6. goroutine泄漏: 启动的goroutine因channel阻塞等永不退出
# → 累积占内存。→ 用context控制生命周期、确保有退出路径。
# 7. ulimit太低: 即使代码没泄漏, 高并发下fd上限也可能不够
# → 合理调高 ulimit -n; 但前提是先排除泄漏(别用调ulimit掩盖泄漏)。
# 共同根源: 网络连接、文件、数据库连接等都是【有限的系统资源】, "申请了必须释放";
# 任何"只申请不释放"的路径(尤其异常/提前return分支), 都会导致资源缓慢泄漏、最终耗尽。
# 核心: 网络/IO/连接都是有限资源, 申请就必须确保释放(defer/finally/using);
# 设超时防永久占用、复用连接、控制池大小; 资源泄漏是缓慢累积定时爆发, 重启只能暂时缓解。
排查让我把资源泄漏的其他坑也梳理清了。一、HTTP 响应体没关(本文)。二、没设超时连接永久挂起(下游不响应、请求一直等,线程/连接池耗尽、服务雪崩,必须设超时)。三、每次 new client 不复用(频繁建断连、产生 TIME_WAIT)。四、TIME_WAIT 大量堆积(主动关闭方进入,短连接高并发下占端口)。五、数据库连接/文件句柄不关。六、goroutine 泄漏(因 channel 阻塞永不退出)。七、ulimit 太低(但别用调 ulimit 掩盖泄漏)。它们的共同根源是:网络连接、文件、数据库连接等都是有限的系统资源,"申请了必须释放";任何"只申请不释放"的路径(尤其异常/提前 return 分支),都会导致资源缓慢泄漏、最终耗尽。核心是:申请就必须确保释放(defer/finally/using),设超时防永久占用、复用连接;资源泄漏是缓慢累积定时爆发,重启只能暂时缓解。下面这张图,是这次 CLOSE_WAIT 堆积的成因与解法:
第四件事:TCP 连接状态速查表
这次踩坑后,我把常见的 TCP 连接状态及其含义整理成一张表,排查网络问题时对照看。
| 状态 | 含义 | 大量堆积通常意味着 |
|---|---|---|
| ESTABLISHED | 连接已建立, 正常通信 | 正常(除非远超预期) |
| CLOSE_WAIT | 对方已关, 等我方应用 close | 我方代码忘记关连接(本文) |
| TIME_WAIT | 我方主动关后等 2MSL | 短连接太多, 我方频繁主动关 |
| SYN_SENT | 已发SYN, 等对方响应 | 连不上对方/网络不通 |
| SYN_RECV | 收到SYN, 半连接 | 可能遭 SYN flood 攻击 |
| FIN_WAIT | 我方已发FIN, 等对方 | 对方迟迟不关 |
这张表把 TCP 状态读懂了。核心区别是:CLOSE_WAIT 堆积是"我方(应用代码)的锅"——对方已经关了、在等我关,我不关(忘记 close);而 TIME_WAIT 堆积是"我方主动关得太频繁"——通常是短连接高并发、没复用连接。它给我的最大启发是:这些看似晦涩的 TCP 状态,其实是一份"诊断系统健康的体检报告"——每一种状态的异常堆积,都精确地指向一类问题(CLOSE_WAIT→忘关连接、TIME_WAIT→短连接过多、SYN_RECV→可能被攻击);读懂它们,你就能从"现象(服务连不上了)"快速定位到"根因(哪一类资源/逻辑出了问题)"。这其实揭示了一个重要的排查能力:很多底层系统(操作系统、网络栈、JVM、数据库)都会通过各种"状态、计数器、指标"把自己的内部运行情况暴露出来;一个高水平的工程师,会主动去学习和读懂这些"系统自述",把它们当成排查问题的"仪表盘";而不是把底层当成完全不可见的黑盒,出了问题只会重启、只会抓瞎。学会读懂底层系统暴露的状态指标,把它们当成诊断的仪表盘——是从"应用层码农"向"能驾驭全栈问题的工程师"进阶的关键一步。
第五件事:为什么资源泄漏这类 bug 格外危险
这次也让我深刻反思了"资源泄漏"这类 bug 独特的危险性。
| 特性 | 说明 | 导致的后果 |
|---|---|---|
| 缓慢累积 | 每次泄漏一点, 短期看不出 | 测试/灰度发现不了 |
| 延迟爆发 | 累积到上限才崩 | 上线几小时/几天后才出事 |
| 重启假愈 | 重启释放资源暂时正常 | 误以为修好了, 反复复发 |
| 现象与根因远 | "连不上"的报错离"忘记close"很远 | 排查方向容易跑偏 |
| 压力下才显现 | 低流量时累积慢, 不易现 | 高峰期/大促时集中爆发 |
这张表道出了资源泄漏 bug 的"阴险"。核心是:资源泄漏不像普通 bug 那样"一触发就报错",而是"缓慢累积、延迟爆发、重启假愈、现象离根因很远"——这一系列特性,让它能轻松躲过测试和灰度,潜伏到生产环境的高峰期才集中爆发,且爆发时的现象(连不上)会把你的排查引向歧途。它给我的深刻启发是:有一类 bug,其危险性不在于"逻辑多复杂",而在于"它的时间特性"——它在"小规模、短时间"下完全不显现,只在"大规模、长时间、高压力"的真实生产条件下才暴露;这类 bug(资源泄漏、内存泄漏、慢速增长的性能退化、并发竞态)是最难在开发期发现、却最能在生产期致命的。这让我对"测试和上线"有了更敬畏的认识:"功能测试通过"远不等于"可以安心上生产";对于服务端长期运行的程序,还必须考虑"时间维度"和"规模维度"的正确性——做长时间运行的稳定性测试(soak test)、压力测试,并上线后持续监控关键资源(fd、内存、连接数、goroutine 数)的增长趋势;因为只有让程序在"接近真实的时间和压力"下跑过,这类潜伏的累积型 bug 才会现形。用"时间和规模"的眼光审视服务端程序、监控资源的增长趋势——是这个 CLOSE_WAIT 坑教给我的、关于"如何对长期运行的系统负责"的宝贵一课。
第六件事:写涉及资源的代码时,我现在的判断习惯
现在每当我写涉及"申请资源"的代码(连接、文件、锁……),我都会按这张图先想清楚:
这张图的精髓,是"申请资源时第一时间就想好它在哪释放,用 defer/finally/using 覆盖所有分支"。没有保证释放就立刻补上 defer/finally/using;确保所有 return 分支(尤其异常、提前 return)都会释放。是网络/IO 资源还要:加超时、复用/连接池、设上限,并在上线后监控资源增长趋势。这套习惯,让我写涉及资源的代码时,从"申请了就用、忘了关"变成了"申请的同时就安排好释放"——核心始终是:资源申请即安排释放,用 defer/finally 覆盖所有分支;网络资源加超时复用,上线监控增长。
我立下的几条规矩
这场"CLOSE_WAIT 堆积、服务瘫痪"的事故,换来了我做网络编程时,刻进骨子里的几条铁律:
- 申请资源,第一时间就 defer/finally 关。拿到 resp 立刻 defer Body.Close()。
- 释放要覆盖所有分支。尤其异常、提前 return,defer 最稳妥。
- HTTP 响应体要读完再关。没读完的连接无法复用。
- 网络调用必须设超时。否则慢响应永久占用连接、拖垮服务。
- 复用 client/连接池,别频繁建断连。避免 TIME_WAIT、提升性能。
- 看懂 TCP 状态。CLOSE_WAIT 是我方没关,TIME_WAIT 是我方关太频繁。
- 监控资源增长趋势。fd、连接数、内存、goroutine,泄漏要尽早发现。
附:一个排查连接泄漏的实战命令清单
这次踩坑后,我把排查"连接/fd 泄漏"的常用命令整理成了一份清单,保存在手边,下次再遇到类似问题就照着一步步查:
# ========== 排查连接/fd 泄漏的实战命令清单 ==========
# 1. 看各种TCP状态的连接数量分布(最常用的第一步)
netstat -an | awk '/^tcp/ {state[$6]++} END {for(s in state) print s, state[s]}'
# 或 ss(更快): ss -ant | awk 'NR>1 {state[$1]++} END {for(s in state) print s, state[s]}'
# → 如果 CLOSE_WAIT 数量很大且持续增长, 基本就是连接泄漏!
# 2. 看具体是哪个进程、连到哪里的 CLOSE_WAIT
netstat -anp | grep CLOSE_WAIT
ss -anp | grep CLOSE-WAIT
# → 能看到 pid 和对端地址, 定位是哪个服务、连哪个下游泄漏
# 3. 看某个进程打开了多少个fd, 是否接近上限
ls /proc//fd | wc -l # 该进程当前打开的fd数
cat /proc//limits | grep "open files" # 该进程的fd上限
ulimit -n # 当前shell的fd上限
# 4. 看fd具体都是些什么(socket占了多少)
ls -l /proc//fd | grep socket | wc -l
# 5. 持续观察增长趋势(关键: 泄漏的特征是"只增不减")
watch -n 2 "ss -ant | grep -c CLOSE-WAIT" # 每2秒看一次CLOSE_WAIT数量
# → 如果这个数字随时间单调上涨、从不下降, 实锤泄漏
# 6. Go程序还可以看goroutine数(goroutine泄漏)
# 暴露 /debug/pprof/, 然后 go tool pprof 看 goroutine 数是否暴涨
# 排查思路总结:
# 现象(连不上/too many open files)
# → netstat/ss 看状态分布(发现CLOSE_WAIT堆积)
# → grep 定位是哪个进程、哪个下游
# → watch 确认是"持续增长"(泄漏)而非"短时波动"
# → 回到代码, 找对应的"打开了却没关闭"的地方(常是漏了Close/异常分支没关)
# 核心: 排查连接泄漏 = 用netstat/ss看状态分布找CLOSE_WAIT堆积 → 定位进程/下游 →
# watch确认持续增长 → 回代码找漏关的Close; 关键特征是资源"只增不减"。
这份命令清单,是我这次踩坑后最实用的沉淀之一。它把"排查连接泄漏"这件原本让我手忙脚乱的事,变成了一套有章可循的、从现象到根因的标准流程:先用 netstat/ss 看 TCP 状态分布、发现 CLOSE_WAIT 堆积,再 grep 定位是哪个进程连哪个下游,然后用 watch 确认这个数字是"持续增长"(泄漏的铁证)而非"短时波动",最后回到代码里找那个"打开了却没关闭"的地方。这正是我想用这份清单,留给自己也分享给你的核心价值:排查线上问题,最忌讳的是"凭感觉乱猜、东改一下西试一下";而最有效的,是建立一套"从可观测的现象出发,用工具一步步收敛到根因"的方法论;而像这样把排查某类问题的"命令 + 思路"沉淀成清单,就是在把"一次踩坑的经验",升级成"一套可复用、可传承的排查能力"。因为线上问题往往发生在最紧张的时刻(服务挂了、用户在投诉),那时候你最需要的,不是临场去想"该用什么命令",而是有一份现成的、冷静的、被验证过的"作战手册",让你能有条不紊地定位问题;把每一次艰难排查的过程,沉淀成下一次的清单和手册,是一个工程师不断变强、也让团队不断变强的方式。把排查经验沉淀成可复用的命令清单和方法论——这,是我用一次连接泄漏的事故,换来的、关于"如何系统性地应对线上问题"的、最实在的工程财富。
写在最后
回头看,这场由"一行忘写的 Close()"引发的、服务定时瘫痪的事故,真正教给我的,远不止"记得关响应体"这一个技巧。它让我对"资源的生命周期管理",以及"对长期运行的系统负责"这件事,有了一次深刻的体会。我栽跟头,是因为我写代码时,只关注了"获取和使用资源"这个"正向"的、能立刻看到效果的过程("发请求、拿数据"),而忽略了"释放资源"这个"收尾"的、当下看不到任何效果、却至关重要的过程。在我"用完即走"的视角里,那个没被关闭的连接仿佛凭空消失了;可在操作系统的视角里,它真实地、持续地占用着一份有限的资源,直到把系统拖垮。这让我领悟到一个深刻的认知:编程中,"申请一个资源"和"释放一个资源",是一对必须配对出现、不可分割的操作;每当你写下"获取"(打开连接、打开文件、加锁、分配内存)时,你就同时背上了"必须释放"的责任——而这个责任,必须用确定性的机制(defer/finally/using/RAII)来保证履行,而不能依赖"我记得";因为"记得"在面对复杂的控制流(异常、多个 return 分支、并发)时,是极不可靠的。这其实是一种贯穿系统编程的、关于"有限资源"的根本纪律:我们的程序运行在一个资源有限的真实世界里(fd、内存、连接、句柄都有上限);对这些有限资源,"有借有还"不是一种美德,而是一种必须严格遵守的契约——任何"只借不还"的疏忽,都会以"资源耗尽"的形式,在未来的某个时刻、以最猝不及防的方式,让整个系统付出代价。对每一个申请的资源都心怀"必须归还"的责任、并用确定的机制保证归还——这,是我用一次服务瘫痪的事故,换来的、关于网络编程、也关于一切资源管理的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写下 http.Get 或任何"打开"操作时,手指顺势就补上那行 defer Close(),那我对着那几千个 CLOSE_WAIT 排查的这大半天,就值了。
—— 别看了 · 2026