我不会起名字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 提交给另一台机器上的服务 B(Tomcat)。现象是:
- 图片间歇性丢失,丢几分钟又好了,重启服务 A 立刻恢复,过一阵又犯;
- 服务 A 的日志里有
Connection Error; netstat里看到连到 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|grep"open files"# 看这些 fd 具体连的哪(socket inode 对应关系)lsof-p<pid>|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()类型 B:HTTP 响应体没关
这是 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,也必须关。更隐蔽的变体是提前 return:
resp,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()没写。