我不会起名字322· 后端 / 算法 / 数据库 技术栈 力扣 Hot100 Go 项目 Redis MySQL文章目录CLOSE_WAIT 堆到几千谁没关 socket 的 3 步定位一、CLOSE_WAIT 和 TIME_WAIT 完全不是一回事二、一个真实的排查故事三、3 步定位是谁没关第 1 步确认是 CLOSE_WAIT并锁定对端第 2 步找到持有这些 socket 的进程和 fd第 3 步定位到代码里哪一处漏了 close四、怎么确认修好了五、带走物CLOSE_WAIT 堆到几千谁没关 socket 的 3 步定位一、CLOSE_WAIT 和 TIME_WAIT 完全不是一回事先说清楚它和 TIME_WAIT 的区别TIME_WAIT 在绝大多数情况下无害它是 TCP 协议收尾流程的一部分内核会自己清理。CLOSE_WAIT 不一样。CLOSE_WAIT 基本等于 bug。状态含义先说清楚TIME_WAIT主动关闭方发完最后一个 ACK 之后的状态是我已经关干净了在等收尾内核 60 秒后自动清理CLOSE_WAIT被动关闭方收到了对端的 FIN、回了 ACK但自己还没发 FIN——也就是对端要关了我这边还没关。CLOSE_WAIT停留在这一层的唯一原因是本地应用层没有调用close()。内核不会替你关。只要你的程序不主动关这个 fd这条连接就会一直停在 CLOSE_WAIT直到进程退出。所以看到几千条 CLOSE_WAIT答案只有一个——你的代码或者你依赖的库漏了关闭。而且它是会累积的每条 CLOSE_WAIT 都占一个文件描述符。堆到几千很可能已经接近ulimit -n接着就是新连接全部失败。二、一个真实的排查故事有一类和直觉相悖的现象值得单独讲因为它坑过很多人。某台服务器上跑着服务 A它不断把图片通过 HTTP POST 提交给另一台机器上的服务 BTomcat。现象是图片间歇性丢失丢几分钟又好了重启服务 A 立刻恢复过一阵又犯服务 A 的日志里有Connection Errornetstat里看到连到 B 的 8080 端口有 6 万条连接状态全是 CLOSE_WAIT浏览器访问服务 B 也是无法连接但从别的机器访问 B 完全正常同机上另一个 Java 应用报java.net.SocketException: No buffer space available。第 4 条最迷惑人为什么别的机器能访问就这台不行答案是这台机器自己出问题了——6 万条 CLOSE_WAIT 把它自己的本地资源socket 缓冲、端口、句柄耗光了所以从这台机器上发起的任何连接都会失败包括浏览器和数据库客户端。不是 B 挂了是 A 把自己搞死了。排查的转折点是抓包Wireshark 里看到 A 和 B 之间的 TCP 流结尾没有完整的 FIN 过程有些流只有两个包——一个 FIN一个 ACK。推导出来的原因Tomcat 侧开了 keepalive并且有 keepalive 超时。连接空闲超过超时时间后Tomcat服务端主动发 FIN 关闭连接。A 作为客户端收到 FIN 后回了 ACK进入 CLOSE_WAIT——但 A 的代码没有关闭这个 socket于是连接就永久停在 CLOSE_WAIT。每提交一张图片开一个线程、建一条连接、用完不关并发一高几万条 CLOSE_WAIT 就堆起来了。三、3 步定位是谁没关第 1 步确认是 CLOSE_WAIT并锁定对端# 按状态统计ss-tan|awk{print $1}|sort|uniq-c|sort-rn# 看 CLOSE_WAIT 连的是谁、本地端口分布ss-tanstate close-wait|awk{print $3, $4}|sort|uniq-c|sort-rn|head重点看对端 IP:端口。如果对端是某个固定的下游数据库、Redis、某个 HTTP 服务范围就缩小了。第 2 步找到持有这些 socket 的进程和 fd# 找出 CLOSE_WAIT 数量最多的进程ss-tanpstate close-wait|awk{print $NF}|sort|uniq-c|sort-rn|head# 拿到 pid 之后看它开了多少 fdls/proc/pid/fd|wc-lcat/proc/pid/limits|grepopen files# 看这些 fd 具体连的哪socket inode 对应关系lsof-ppid|grep-cCLOSE_WAIT如果 fd 数已经接近ulimit -n这就是事故的即时原因。第 3 步定位到代码里哪一处漏了 close这一步要回到代码。按连接池/客户端类型分类型 A自己写的裸 socket / net.Dial// 错误忘了 defer Closeconn,err:net.Dial(tcp,addr)iferr!nil{returnerr}// ... 用完没关函数返回后 fd 泄漏// 正确conn,err:net.Dial(tcp,addr)iferr!nil{returnerr}deferconn.Close()类型 BHTTP 响应体没关这是 Go 里最常见的泄漏没有之一// 错误resp.Body 没关连接不会归还连接池resp,err:http.Get(url)iferr!nil{returnerr}body,_:io.ReadAll(resp.Body)returnhandle(body)// 正确读完必须关resp,err:client.Get(url)iferr!nil{returnerr}deferresp.Body.Close()body,_:io.ReadAll(resp.Body)即使你不读 body也必须关。更隐蔽的变体是提前 returnresp,err:client.Get(url)iferr!nil{returnerr}deferresp.Body.Close()ifresp.StatusCode!200{returnerrors.New(bad status)// 有 defer没问题}// 但如果 body 只读了一半就返回也要保证 Close 被调用io.CopyN(io.Discard,resp.Body,1024)// 丢弃剩余内容连接才能复用类型 C连接池泄漏用了连接池但借了不还// 错误从池里取了连接异常路径没还回去conn:pool.Get()iferr:doSomething(conn);err!nil{returnerr// conn 没归还池子越来越小}pool.Put(conn)// 正确conn:pool.Get()deferpool.Put(conn)iferr:doSomething(conn);err!nil{returnerr}类型 D数据库rows没关rows,err:db.QueryContext(ctx,sql)iferr!nil{returnerr}deferrows.Close()// 必须forrows.Next(){// 提前 break / return 时也要关defer 保证了}// rows.Err() 也要检查否则连接不能正常归还iferr:rows.Err();err!nil{returnerr}四、怎么确认修好了修完不要只看 CLOSE_WAIT 数量掉没掉还要确认fd 水位稳定# 持续观察 30 秒foriin$(seq16);doecho$(date%T)close_wait$(ss-tanstate close-wait|wc-l)fd$(ls/proc/pid/fd|wc-l)sleep5done健康的曲线是fd 数在业务高峰上升、高峰过后回落。如果是一条只涨不落的斜线说明还有一处没堵上。另外建议直接加一条监控告警比事后排查有用得多告警规则 close_wait_connections 1000 持续 5 分钟 → P2 process_open_fds / process_max_fds 0.8 → P1五、带走物一个可以复用的判定流程1. ss -tan state close-wait → 确认数量 锁定对端 IP:PORT 2. ss -tanp state close-wait → 定位到 pid 3. ls /proc/pid/fd | wc -l → 确认 fd 是否逼近 ulimit 4. 回到代码按四类逐项检查 ├─ 裸 socket 有没有 defer Close() ├─ resp.Body 有没有 Close()Go 最常见 ├─ 连接池有没有 defer Put() └─ db.Rows 有没有 Close() 检查 Rows.Err() 5. 修完看 fd 水位在高峰后是否回落最后一句结论TIME_WAIT 是内核在替你收尾CLOSE_WAIT 是内核在等你收尾。看到几千条 CLOSE_WAIT不用怀疑网络直接去查代码里哪个Close()没写。