服务器与 Linux 排查 列表全部文章
服务器与 Linux 排查

Too many open files:一个没关的 HttpClient,攒出了三万个 CLOSE_WAIT

接口突然大面积报 Too many open files。先分清 ulimit、limits.conf、systemd LimitNOFILE 与容器的差异,再用 lsof、ss 统计连接状态,定位到 CLOSE_WAIT 堆积,根因是 HttpClient 响应没关闭导致连接泄漏。

更多

某个周二的中午,正在食堂排队打饭,手机里的告警像下雨一样落下来:订单服务,大量 java.net.SocketException: Too many open files,紧接着是连接数据库失败、调第三方接口失败、甚至连日志都写不出来。我端着餐盘对着手机站了十分钟,最后决定——先吃完,再回去排查。(开玩笑的,饭没吃完。)

“Too many open files”这句话,翻译成人话是:这个进程手里握着的文件描述符(fd)数量,已经顶到了上限。而“文件”在 Linux 里是个很宽泛的概念:普通文件、目录、socket、管道、epoll 实例,统统占一个 fd。所以它从来不仅仅是“打开了太多文件”,绝大多数情况下是连接泄漏。这篇就是这次排查的备忘录。

一、现象

应用日志里的报错模式很有代表性:

java.net.SocketException: Too many open files
    at java.base/sun.nio.ch.Net.socket0(Native Method)
    at java.base/sun.nio.ch.Net.socket(Net.java:...)
    ...
java.io.IOException: Too many open files
    at java.base/sun.nio.ch.ServerSocketChannelImpl.accept0(Native Method)
    ...
org.apache.catalina.core.StandardServer ... Socket accept failed
java.io.FileNotFoundException: /data/logs/app.log (Too many open files)

注意最后一条——连日志文件都打不开了。这说明 fd 已经耗尽到“任何 open/socket/accept 系统调用都会失败”的程度,对应的 errno 是 EMFILE(进程级)。getrlimit 手册对 RLIMIT_NOFILE 的定义是“比该进程能打开的最大文件描述符编号大 1 的值”,超过就返回 EMFILE。

二、排查路线图

① 上限是多少?/proc/PID/limits ② 现在用了多少?ls /proc/PID/fd | wc -l ③ 都是什么 fd?lsof -p PID 按类型统计 ④ 哪种连接状态?ss -ant 统计 上限太小(默认 1024)调 LimitNOFILE,治标 CLOSE_WAIT 堆积本端没 close:连接泄漏 TIME_WAIT 很多短连接过多,用连接池 / 长连接 先止血(调上限 / 重启),再找泄漏源(谁没有 close)

三、动手

第 1 步:这个进程的 fd 上限到底是多少

最容易踩的坑:你在 shell 里 ulimit -n 看到的数字,不一定是那个 Java 进程实际生效的数字。要看进程真实的限制,读 /proc/PID/limits:

$ cat /proc/8231/limits | grep -E 'Limit|open files'
Limit                     Soft Limit           Hard Limit           Units
Max open files            1024                 4096                 files

$ ls /proc/8231/fd | wc -l
1021

当前 1021 个,上限(软限制)1024,只剩 3 个空位。软限制是内核实际执行的值,硬限制是软限制能被调到的天花板(非特权进程只能在软硬之间调整)。

为什么是 1024?因为这个服务是用 systemd 起的,而运维之前在 /etc/security/limits.conf 里配了 65535——但那个文件是 PAM 在“登录会话”时才读的,systemd 拉起的服务根本不走 PAM 登录,所以配置完全没生效。这是这类问题里最经典的“我明明配了呀”。

第 2 步:几种“配置入口”的差异

把各种启动方式的 fd 限制来源理一遍,这是本文最值得收藏的一张表:

启动方式在哪里配验证方式
登录 shell 手工启动/etc/security/limits.conf 或 limits.d/*.conf(* soft nofile 65535),需要重新登录ulimit -n
systemd 服务unit 里的 LimitNOFILE=65535(或全局 DefaultLimitNOFILE),不读 limits.confsystemctl show 服务名 -p LimitNOFILE、/proc/PID/limits
Docker 容器docker run --ulimit nofile=65535:65535,或 daemon.json 的 default-ulimits;Compose 里的 ulimits容器内 ulimit -n,或宿主机上看容器进程 /proc/PID/limits
Kubernetes继承容器运行时(containerd/docker)默认值,需在运行时配置层调整,Pod 规格本身没有直接的 ulimit 字段Pod 内 cat /proc/1/limits

systemd 手册里对 LimitNOFILE= 写得很清楚:可以写单个值(软硬都设为它),也可以写 soft:hard 的形式,例如 1024:65536;手册没有提到 limits.conf,因为它就是 systemd 自己的一套机制,只作用于 systemd 管理的服务。另外,手册提醒说不要把软限制盲目调到 1024 以上以至于影响 select(2)(select 处理不了编号大于 1023 的 fd),不过现代 Java 用的是 epoll,通常无碍。

验证 systemd 服务的实际值:

$ systemctl show order.service -p LimitNOFILE -p LimitNOFILESoft
LimitNOFILE=4096
LimitNOFILESoft=1024

第 3 步:看看 1021 个 fd 都是什么

$ lsof -p 8231 | wc -l
1118

# 按类型统计(TYPE 在第 5 列)
$ lsof -p 8231 | awk '{print $5}' | sort | uniq -c | sort -rn
    943 IPv4
     58 REG
     41 unix
     ...

# 更细:看 TCP 连接的状态分布(lsof 的 NAME 列尾部带状态)
$ lsof -p 8231 -a -i TCP | awk '{print $NF}' | sort | uniq -c | sort -rn
    902 (CLOSE_WAIT)
     31 (ESTABLISHED)
      2 (LISTEN)

lsof -p PID | wc -l 是我最常用的“数一数”,它会比 /proc/PID/fd 稍多,因为 lsof 还列出了 cwd、txt(可执行文件)、mem(内存映射库)等不占 fd 的行——所以精确数 fd 用 ls /proc/PID/fd | wc -l。

关键信息出来了:943 个 fd 都是 IPv4 socket,其中 902 个处于 CLOSE_WAIT。这不是“文件打得多”,而是“连接没关”。

第 4 步:用 ss 统计整机连接状态

要看整机的 TCP 状态分布,ss 比老掉牙的 netstat 快得多(连接多时 netstat 可能卡很久):

$ ss -ant | awk 'NR>1 {s[$1]++} END {for (k in s) print k, s[k]}' | sort -k2 -rn
CLOSE-WAIT 902
ESTAB 47
TIME-WAIT 12
LISTEN 8

# 只看 CLOSE-WAIT,并带上所属进程
$ ss -tnp state close-wait | head -5
Recv-Q Send-Q  Local Address:Port    Peer Address:Port  Process
     1      0     10.0.1.15:48210    10.0.5.30:8080     users:(("java",pid=8231,fd=312))
     1      0     10.0.1.15:48214    10.0.5.30:8080     users:(("java",pid=8231,fd=313))
     ...

# 按对端地址聚合,找“泄漏到哪个下游”
$ ss -tn state close-wait | awk 'NR>1 {print $4}' | sort | uniq -c | sort -rn | head
    899 10.0.5.30:8080

ss 手册里的状态过滤语法是 state 加上状态名,如 ss -t -n -p -a state close-wait,多个状态可以用 or 连接。这里的输出说明:899 个 CLOSE_WAIT 全都指向同一个下游 10.0.5.30:8080(示例地址,是我们调用的一个内部“库存服务”)。凶手锁定在“调用库存服务的那段代码”。

四、根因:CLOSE_WAIT 是“对方挂了电话,你没挂”

先回忆 TCP 四次挥手:

下游服务(主动关闭方) 我方 Java 应用(被动关闭方) ① FIN(我不发了) ② ACK(收到) 我方进入 CLOSE_WAIT,等应用调用 close() ③ FIN(永远不会发出,因为没 close) 对方停在 FIN_WAIT_2

对方先发 FIN,内核自动回 ACK,我方 socket 进入 CLOSE_WAIT。此后必须由应用程序调用 close(),内核才会发出我方的 FIN,完成挥手。如果应用永远不 close,这条连接就永远停在 CLOSE_WAIT,占着一个 fd,不会超时消失(CLOSE_WAIT 本身没有内核超时,只有在开启 keepalive 且探测失败时才可能被清理)。 所以口诀:CLOSE_WAIT 大量堆积 = 我方应用没有关闭连接 = 代码 bug(连接泄漏),不是网络问题,调内核参数也治不了。而 TIME_WAIT 是主动关闭的一方在挥手结束后停留的状态,是正常现象。

回到代码。库存服务的调用是这样写的(已脱敏改写),用的是 Apache HttpClient 4.x:

public Stock query(String sku) throws IOException {
    HttpGet get = new HttpGet("http://stock.demo.example.com/api/stock?sku=" + sku);
    CloseableHttpResponse resp = httpClient.execute(get);
    if (resp.getStatusLine().getStatusCode() != 200) {
        return null;                       // 非 200 时直接返回:响应没有关闭!
    }
    String body = EntityUtils.toString(resp.getEntity());
    return JSON.parseObject(body, Stock.class);   // 也没有 resp.close()
}

两个问题:一是非 200 分支提前 return,响应实体没消费也没关闭;二是正常分支虽然 EntityUtils.toString 会读完并释放连接回池,但没有在 finally 里 close,一旦解析抛异常就漏了。连接池(PoolingHttpClientConnectionManager)里的连接被借走后没归还,池子里的连接很快用光,而下游服务在超时后主动关闭连接(发 FIN),我方 socket 就变成了孤儿 CLOSE_WAIT。日子一久,fd 耗尽。

那天库存服务恰好发布重启,一批返回 503 的响应全走了“非 200 直接 return”的分支,泄漏速度陡增,才把长期缓慢泄漏的隐患一次引爆。

五、止血与修复

止血

  1. 先重启(或滚动重启)泄漏的实例:fd 全部释放,服务立刻恢复。重启前用 lsof -p PID、ss -tnp state close-wait 保存证据;
  2. 如果重启不方便,可以临时调高上限争取时间(对运行中的进程用 prlimit):
    $ sudo prlimit --pid 8231 --nofile=65535:65535
    $ cat /proc/8231/limits | grep 'open files'
    Max open files            65535                65535                files
    这只是延后爆炸时间,泄漏没修,早晚还会满。
  3. systemd 服务把上限永久调大:
    $ sudo systemctl edit order.service
    [Service]
    LimitNOFILE=65535
    
    $ sudo systemctl daemon-reload && sudo systemctl restart order.service

根治

public Stock query(String sku) throws IOException {
    HttpGet get = new HttpGet("http://stock.demo.example.com/api/stock?sku=" + sku);
    // try-with-resources:无论怎么返回、是否异常,都会 close,连接归还连接池
    try (CloseableHttpResponse resp = httpClient.execute(get)) {
        if (resp.getStatusLine().getStatusCode() != 200) {
            EntityUtils.consumeQuietly(resp.getEntity());  // 消费掉实体,连接才能复用
            return null;
        }
        String body = EntityUtils.toString(resp.getEntity());
        return JSON.parseObject(body, Stock.class);
    }
}

// 连接池与超时也要配全,避免“泄漏 + 无限等待”叠加
RequestConfig cfg = RequestConfig.custom()
        .setConnectTimeout(1000)
        .setSocketTimeout(3000)
        .setConnectionRequestTimeout(500)   // 从池里借连接的超时
        .build();
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
cm.setMaxTotal(200);
cm.setDefaultMaxPerRoute(50);
CloseableHttpClient client = HttpClients.custom()
        .setConnectionManager(cm)
        .setDefaultRequestConfig(cfg)
        .evictExpiredConnections()
        .evictIdleConnections(30, TimeUnit.SECONDS)
        .build();

通用原则:凡是“借”来的资源(连接、流、响应),都用 try-with-resources;能用 OkHttp/RestTemplate 之类封装好的客户端就用,但也要看清它有没有“必须手动关闭 body”的坑(OkHttp 的 Response 就是)。

六、顺便:TIME_WAIT 多了怎么办

另一种经常被混为一谈的情况:ss -ant 里 TIME-WAIT 几千上万。它是主动关闭连接的一方在四次挥手结束后停留的状态(Linux 上固定 60 秒),作用是防止旧连接的延迟报文干扰新连接。少量 TIME_WAIT 完全正常,它不占用应用的 fd(fd 早随 close 释放了),只占内核的一个小结构和一个本地端口四元组。

只有在本机作为客户端疯狂发起短连接时,才会因为可用临时端口(net.ipv4.ip_local_port_range,默认约 3 万个)被 TIME_WAIT 占满,报 Cannot assign requested address。处理办法优先级:

  1. 使用连接池/HTTP keep-alive,把短连接改成长连接,这是正解;
  2. 扩大 ip_local_port_range;
  3. 在确实是客户端发起连接的场景下,开启 net.ipv4.tcp_tw_reuse(tcp(7) 手册描述为“在协议角度安全时允许为新连接重用 TIME_WAIT 套接字”,同时提醒没有专家建议不要随意改)。它只对主动向外发起的连接生效,且依赖 TCP 时间戳;
  4. 不要碰 tcp_tw_recycle:它在 NAT 环境下会导致连接异常,并且已在 Linux 4.12 中被移除。

七、复盘

做法为什么
看 /proc/PID/limits,别信 shell 的 ulimit -n进程实际生效值取决于启动方式(PAM 登录、systemd、容器各有各的入口)
systemd 服务用 LimitNOFILE=,不指望 limits.conflimits.conf 只在登录会话由 PAM 读取
ss -ant | awk 统计状态分布,ss -tnp state close-wait 看归属先判断是 CLOSE_WAIT(我方泄漏)还是 TIME_WAIT(正常/短连接过多)
CLOSE_WAIT 堆积 → 查“谁没 close”CLOSE_WAIT 只有应用调用 close() 才会推进,改内核参数无用
HttpClient 响应一律 try-with-resources,非 200 分支也要消费实体否则连接不归还连接池,下游一断就变孤儿 socket
配置连接超时、池大小、空闲连接清理泄漏往往和“无限等待”联手出事
监控进程 fd 使用率(打开数 / 上限)与 CLOSE_WAIT 数量泄漏是慢性病,超过 70% 就该告警,别等报错

一句话备忘:Too many open files → /proc/PID/limits 看上限、ls /proc/PID/fd | wc -l 看用量、ss -ant 看状态;CLOSE_WAIT 一多就是我方没 close,修代码,不要只调 ulimit。

那天晚上我在值班手册里加了一行:“对方挂了电话,你也得挂。”——不然线路一直占着,别人打不进来。感情里也是这个道理,虽然我没有对象来验证。

参考资料

  • getrlimit(2) 手册:RLIMIT_NOFILE 的定义,超限返回 EMFILE,以及软/硬限制的关系。
  • systemd.exec(5) 手册:LimitNOFILE= 的语法(单值与 soft:hard),以及资源限制默认取自 systemd 的 DefaultLimit* 配置。
  • ss(8) 手册:state 状态过滤语法(如 state close-wait,可用 or 组合)及 -t -n -p -a 选项。
  • tcp(7) 手册:tcp_tw_reuse 的语义(协议安全时允许复用 TIME_WAIT 套接字)及使用提醒。
— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人