服务器出现CLOSE_WAIT过多怎么办?应用程序连接释放排查指南

监控告警里跳出"CLOSE_WAIT 连接数超过阈值",或者文件描述符快被占满、新请求开始报错,一查发现某个进程挂着几千上万个 CLOSE_WAIT 连接。这不是网络问题,也不是防火墙问题,而是应用程序自身没有正确关闭连接——CLOSE_WAIT 堆积几乎可以百分之百地指向代码缺陷或资源泄漏。

这篇文章的目标很明确:教你把 CLOSE_WAIT 从"现象"追到"进程"再到"具体代码位置"。先讲清 CLOSE_WAIT 是怎么产生的,为什么它能证明是应用侧的问题;再讲怎么用 ss、lsof、/proc 快速定位到是哪个进程、哪个文件描述符;接着列出最常见的几类泄漏代码场景和对应的修复思路;最后给出临时止血和根治的完整方案,并对比其他几种连接状态堆积的不同含义。

文中命令区分 RHEL/CentOS/Rocky 与 Debian/Ubuntu,涉及重启服务、调整参数的操作都会给出风险提示和回滚方式。定位这类问题需要读一点应用日志或源码,但方向一旦找准,处理起来并不复杂。

一、CLOSE_WAIT 是怎么产生的

要理解 CLOSE_WAIT,得先回忆 TCP 的四次挥手。假设客户端主动关闭连接:客户端发 FIN,服务端内核回 ACK,此时服务端这条连接进入 CLOSE_WAIT 状态。注意,进入 CLOSE_WAIT 的是服务端,而且这个动作是内核自动完成的——收到对方 FIN 就回 ACK 并置为 CLOSE_WAIT。

接下来,服务端要真正关闭连接,必须由应用程序调用 close()(或 shutdown())主动发出自己的 FIN,连接才会从 CLOSE_WAIT 走到 LAST-ACK,最终关闭。问题就出在这里:如果应用程序迟迟不调用 close(),这条连接就永远卡在 CLOSE_WAIT,内核回不了任何东西,因为球在应用手里。

所以结论非常干脆:CLOSE_WAIT 堆积 = 对端已经关了连接,但本端应用没有关。它和 TIME-WAIT 有本质区别——TIME-WAIT 是内核正常回收机制的必经阶段,会自己消失;CLOSE_WAIT 不会自己消失,只能等应用动手或进程退出。这就是为什么 CLOSE_WAIT 一多就必须查应用。

常见的触发场景包括:用了连接池却没正确归还连接;网络请求异常路径(catch 分支)里忘了 close();流、Socket、数据库连接没有放进 try-with-resources 或 finally;事件循环里注册了可读事件但没处理 EOF(读到 0 字节就应关闭)。

二、先量化:定位到具体进程

第一步永远是量化,别上来就改代码。用 ss 把 CLOSE_WAIT 的连接按进程聚合出来。

# 统计各状态连接数量,先确认 CLOSE_WAIT 到底有多少
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn

# 只看 CLOSE_WAIT,需要 root 才能看到进程名
sudo ss -tanp state close-wait

# 按进程聚合 CLOSE_WAIT 数量(最有用的一条命令)
sudo ss -tanp state close-wait | \
  grep -oP 'users:\(\("\K[^"]+' | sort | uniq -c | sort -rn | head

# 另一种聚合:按进程名统计
sudo ss -tanp state close-wait | awk '{print $NF}' | \
  sort | uniq -c | sort -rn | head

如果 ss 的输出里进程信息不好抓,用 lsof 更直接:

# RHEL/CentOS/Rocky 安装
yum install lsof
# Debian/Ubuntu 安装
apt install lsof

# 统计某进程的 CLOSE_WAIT 连接数
sudo lsof -p  | grep -c CLOSE_WAIT

# 找出所有持有 CLOSE_WAIT 的进程
sudo lsof -nP | awk '$0 ~ /CLOSE_WAIT/ {print $1}' | sort | uniq -c | sort -rn

# 看某进程打开的 fd 总数(是否接近 ulimit)
sudo ls /proc//fd | wc -l
cat /proc//limits | grep 'open files'

拿到 PID 后,还有两个关键动作:

# 1. 看这个进程的 fd 上限,判断是不是快撑爆了
cat /proc//limits | grep 'Max open files'

# 2. 看进程建立连接的对端分布,判断是连的谁没释放
sudo ss -tanp state close-wait | grep "" | awk '{print $5}' | \
  cut -d: -f1 | sort | uniq -c | sort -rn | head

对端分布很关键:如果这些 CLOSE_WAIT 连接集中指向某几个对端地址,说明是该应用与那几个下游服务之间的连接没释放;如果对端来源五花八门,那更可能是"服务器作为服务端、客户端断连后本端没关"的典型泄漏。

三、追到代码层:常见泄漏场景

定位到进程后,就要回到代码里找原因。下面几类是导致 CLOSE_WAIT 的"常客"。

第一类:连接池配置或使用不当。 连接池本身是为了复用,但如果代码从池里借了连接却在异常路径上没归还(没 close() 回到池),连接就会卡住。典型表现是"高峰过后 CLOSE_WAIT 缓慢累积",因为只有在特定异常分支才泄漏。

第二类:异常路径漏掉关闭。 这是新手最容易犯的:

# 反例(伪代码):正常路径关了,异常路径没关
sock = connect(host)
resp = send(sock, req)     # 这里抛异常,下面 close 就执行不到
close(sock)

# 正解:用 try-finally / defer / with 保证无论如何都关
try:
    sock = connect(host)
    resp = send(sock, req)
finally:
    close(sock)

第三类:只 shutdown 写方向却没 close。 有些人调用了 shutdown(SHUT_WR) 就以为关完了,实际这只会发 FIN,本端读方向还开着,若随后不 close(),连接可能停在 CLOSE_WAIT 或 FIN-WAIT。

第四类:可读事件到达 EOF 没处理。 在 epoll/select 模型里,对端关闭时套接字变为可读且 read() 返回 0,如果代码忽略了这个 0 而继续等待可读事件,连接就永远不关。

第五类:超时设置缺失。 HTTP 客户端、数据库连接、Redis 客户端如果没设连接超时和空闲超时,对端断开后本端可能长时间不感知,连接挂在 CLOSE_WAIT。

排查时可以借助工具辅助:Java 应用用 jstack 导出线程栈,看是否有大量线程阻塞在 socket 读;用 JDK 自带的 jcmd VM.native_memory 或 netstat 关联;Go 程序可以用 pprof 看 goroutine 是否泄漏。核心思路是把"连接数持续增长"和"某个代码路径没有释放资源"对应起来。

四、临时止血与根治

处理的正确顺序是:先临时止血保住业务,再从容改代码根治。不要以为重启进程就万事大吉——重启确实能把 CLOSE_WAIT 一次性清掉,但过一段时间又会重新堆积,因为它只是清除了症状。

临时止血手段:

# 1. 平稳重启应用(优先 reload/滚动重启,避免中断业务)
systemctl reload nginx
systemctl restart myapp    # 极端情况下才用 restart

# 2. 调大进程 fd 上限,给修复争取时间(临时生效)
# 查看当前
ulimit -n
cat /proc//limits | grep 'Max open files'

# 3. 系统级 fd 上限(写入 /etc/security/limits.conf 或 systemd 单元)
# /etc/systemd/system/myapp.service
# [Service]
# LimitNOFILE=65535
systemctl daemon-reload && systemctl restart myapp

根治手段是修代码,把上面第三类问题逐个消除:

  • 所有网络/数据库资源统一走 try-finally、defer、with 或连接池的归还逻辑,确保异常路径也会释放。
  • 为所有客户端设置连接超时、读写超时、空闲超时(例如 HTTP 客户端 3-5 秒连接超时、30-60 秒空闲超时,经验值,按业务调)。
  • 可读事件处理里显式判断 EOF(读返回 0)并关闭连接。
  • 定期对连接池做健康检查(validation query 或 keepalive),剔除失效连接。
  • 补上监控:对进程的 CLOSE_WAIT 数量、fd 使用量设告警,下次泄漏一冒头就能发现。

五、几种连接状态堆积的对比

分清各种"连接堆积"的含义,能避免把 CLOSE_WAIT 的锅甩错地方:

状态 谁的问题 是否会自动消失 典型成因 处理方向
CLOSE_WAIT 应用(本端不关) 否,需应用 close 代码未释放连接、连接池泄漏 改应用代码
TIME-WAIT 内核机制(正常) 是,2MSL 后回收 主动关闭方短连接多 调端口范围、复用连接
FIN-WAIT-2 对端不关 半自动,靠超时 对端应用僵死 查对端、设超时
SYN-RECV 内核/网络 靠队列和超时 SYN Flood、握手无响应 调队列、限速防御
LAST-ACK 本端等最后 ACK 靠超时 本端已 close 但对端不回 ACK 一般无需处理

一句话记忆:CLOSE_WAIT 是应用的锅,TIME-WAIT 是正常机制。看到 CLOSE_WAIT 别想着调内核参数,那没用;看到 TIME-WAIT 别急着改代码,那多半本来就这样。

修改 fd 上限、重启服务属于有一定影响的操作,做之前请确认:systemctl reload 不会中断已有连接但 restart 会;改 LimitNOFILE 后必须 daemon-reload 再重启才生效;生产环境优先滚动重启灰度的方式验证。回滚方式很简单——把 limits.conf 或 systemd 单元里的数值改回原值、daemon-reload、重启即可,重启前记录原来的 ulimit -n 数值。

常见问题

Q:CLOSE_WAIT 会自己消失吗?

不会。CLOSE_WAIT 状态下,内核在等应用程序调用 close()。只要应用不关,连接就一直留着,直到进程退出或系统重启。所以 CLOSE_WAIT 堆积必须从应用侧解决。

Q:重启服务能解决吗?

能临时解决,重启后立即归零。但如果代码没改,过一段时间会重新堆积。把它当作"止血"手段可以,当作"治疗方案"不行。

Q:为什么只有高峰期才出现 CLOSE_WAIT 堆积?

因为泄漏通常发生在特定路径上(如超时、异常、特定下游返回),平常流量小、触发概率低,泄漏速度慢于系统回收速度,看不出来;高峰期触发频率高,泄漏速度超过回收速度才会显性堆积。

Q:ss 显示的进程名是 -,看不到进程怎么办?

需要 root 权限才能看到别的用户进程的连接。用 sudo ss -tanp。另外如果连接已经被应用 close() 但 fd 还被别的地方引用,ss 也可能抓不到,这时用 lsof -nP 交叉验证。

Q:调大了 ulimit -n 是不是就不用改代码了?

不行。调大 fd 上限只是把"撑爆"的时间往后推,泄漏依旧存在,最终还是会撞上限,而且换来的是一段更长的"看不见的恶化期"。正确做法是改代码,调上限只是配套的临时缓冲。

总结

  • CLOSE_WAIT 的本质是"对端发了 FIN、本端内核已回 ACK,但应用程序没调用 close()",堆积百分之百指向应用侧的连接泄漏,不是网络问题。
  • 定位路径:ss -tan 看总量 → sudo ss -tanp state close-wait 聚合进程 → 看对端分布和 fd 使用量 → 结合 jstack/lsof 追到代码路径。
  • 常见泄漏原因:连接池未归还、异常路径漏 close()、只 shutdown 未 close、忽略 EOF、缺超时设置,修复核心是保证资源在 finally/defer 中释放。
  • 处理顺序是"先止血后根治":reload 或重启清空连接、临时调大 fd 上限,但根治必须改代码,否则必然复发。
  • 牢记 CLOSE_WAIT 与 TIME-WAIT 的区别:前者是应用 bug 不会自愈,后者是内核正常机制会自愈,不要用调内核参数去治 CLOSE_WAIT。