ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CLOSE_WAIT 堆到几千:谁没关 socket 的 3 步定位

CLOSE_WAIT 堆到几千:谁没关 socket 的 3 步定位

我不会起名字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)。现象是:

  1. 图片间歇性丢失,丢几分钟又好了,重启服务 A 立刻恢复,过一阵又犯;
  2. 服务 A 的日志里有Connection Error;
  3. netstat里看到连到 B 的 8080 端口有 6 万条连接,状态全是 CLOSE_WAIT;
  4. 浏览器访问服务 B 也是"无法连接",但从别的机器访问 B 完全正常;
  5. 同机上另一个 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()没写。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进