某个周二的中午,正在食堂排队打饭,手机里的告警像下雨一样落下来:订单服务,大量 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。
二、排查路线图
三、动手
第 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.conf | systemctl 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:8080ss 手册里的状态过滤语法是 state 加上状态名,如 ss -t -n -p -a state close-wait,多个状态可以用 or 连接。这里的输出说明:899 个 CLOSE_WAIT 全都指向同一个下游 10.0.5.30:8080(示例地址,是我们调用的一个内部“库存服务”)。凶手锁定在“调用库存服务的那段代码”。
四、根因:CLOSE_WAIT 是“对方挂了电话,你没挂”
先回忆 TCP 四次挥手:
对方先发 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”的分支,泄漏速度陡增,才把长期缓慢泄漏的隐患一次引爆。
五、止血与修复
止血
- 先重启(或滚动重启)泄漏的实例:fd 全部释放,服务立刻恢复。重启前用
lsof -p PID、ss -tnp state close-wait保存证据; - 如果重启不方便,可以临时调高上限争取时间(对运行中的进程用
prlimit): 这只是延后爆炸时间,泄漏没修,早晚还会满。$ sudo prlimit --pid 8231 --nofile=65535:65535 $ cat /proc/8231/limits | grep 'open files' Max open files 65535 65535 files - 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。处理办法优先级:
- 使用连接池/HTTP keep-alive,把短连接改成长连接,这是正解;
- 扩大
ip_local_port_range; - 在确实是客户端发起连接的场景下,开启
net.ipv4.tcp_tw_reuse(tcp(7) 手册描述为“在协议角度安全时允许为新连接重用 TIME_WAIT 套接字”,同时提醒没有专家建议不要随意改)。它只对主动向外发起的连接生效,且依赖 TCP 时间戳; - 不要碰
tcp_tw_recycle:它在 NAT 环境下会导致连接异常,并且已在 Linux 4.12 中被移除。
七、复盘
| 做法 | 为什么 |
|---|---|
看 /proc/PID/limits,别信 shell 的 ulimit -n | 进程实际生效值取决于启动方式(PAM 登录、systemd、容器各有各的入口) |
systemd 服务用 LimitNOFILE=,不指望 limits.conf | limits.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 套接字)及使用提醒。
一起聊聊