服务一边报 Too many open files,一边开始接不住新连接。有时最先出错的是日志,有时是 JDBC 或 HTTP 客户端。错误落在哪一行,只能告诉你哪次申请先失败。要找谁一直占着资源,先看 Java 进程已经打开的文件描述符。
文件描述符简称 FD。普通文件、TCP 连接、管道都能占用它。下面用 Linux、Bash、GNU find 和 iproute2 排查。先找到真正报错的 Java PID。服务跑在容器里,就在能看到它的 PID 和网络命名空间里取样。读取其他用户的进程详情可能需要相应权限。
先看还剩多少余量
把 12345 换成报错的 Java 进程 PID。第一条确认进程身份,第二条显示进程实际拿到的软、硬上限,最后一条数当前打开的 FD。
pid=12345
ps -p "$pid" -o pid,user,comm
awk 'NR == 1 || /^Max open files/' "/proc/$pid/limits"
(
set -o pipefail
find "/proc/$pid/fd" -mindepth 1 -maxdepth 1 -printf '.' | wc -c
)
重点对照 Max open files 的软上限和当时的 FD 数。Linux 的 EMFILE 指进程这一级的描述符限额耗尽。终端里的 ulimit -n 读到的是当前 shell 的上限,服务可能由 systemd 或容器运行时启动,实际值以报错进程为准。遇到 Permission denied,先把这次计数记为无法读取,别把输出的 0 当真。
故障已经过去,眼下只剩两三百个 FD,也有可能是峰值回落了。把报错时间和 FD 监控对齐,或者在同一 PID 上隔一小段时间取两三次样。看它是随流量上涨再回落,还是请求少了以后仍往上爬。进程重启后要换新 PID,重启前后的数分开看。
如果错误能明确定位到 ENFILE,或者同一宿主机上多个进程同时无法打开文件,再查看宿主机的 /proc/sys/fs/file-nr 与内核日志。file-nr 的第一列是已分配的系统文件句柄数,第三列是上限。第二列在现代 Linux 上通常是 0。这组数和单个 Java 进程的 FD 数属于两层限制。
看是连接多,还是文件多
/proc/<PID>/fd 里面每个条目都是一个 FD。先按链接目标粗分,找最大或增长最快的那一类。
(
set -o pipefail
find "/proc/$pid/fd" -mindepth 1 -maxdepth 1 -printf '%l\n' |
awk '
/^socket:\[/ { count["socket"]++; next }
/^pipe:\[/ { count["pipe"]++; next }
/^anon_inode:/ { count["anon_inode"]++; next }
/ \(deleted\)$/ { count["deleted"]++; next }
/^\// { count["path"]++; next }
{ count["other"]++ }
END { for (kind in count) printf "%8d %s\n", count[kind], kind }
' |
sort -nr
)
socket一直涨。 先按协议和对端分,再看入站连接、HTTP 客户端或数据库连接池。长连接本来会占 FD,判断时要带上并发和连接池配置。path或deleted一直涨。 抽几个 FD 看链接目标。大量指向同一目录时,从文件读取、上传临时文件、日志轮转和异常返回路径查。文件已经删掉但仍被打开时,最后一个引用关闭之前,相关磁盘空间可能还占着。pipe或anon_inode一直涨。 把增长和子进程、文件监听器、网络客户端的创建时机对齐。少量 epoll 等条目是正常的,持续新建却回不去才值得往下追。
如果主要是 TCP socket,可以看同一进程的连接和 CLOSE-WAIT。
ss -Htanp | grep -F "pid=$pid,"
ss -Htanp state close-wait | grep -F "pid=$pid,"
CLOSE-WAIT 表示对端已经发来关闭请求,本端还在等待应用关闭。偶尔出现几条很正常。相同对端的条目持续堆积,才值得顺着请求去找响应体、异常分支或取消路径。ss -p 在权限不足时可能看不到 PID,此时没有匹配输出也不能据此判断进程没有连接。
找到没收回的资源,就补上关闭路径
比如每次请求都用 Files.lines 读一遍本地文本。调用 count() 消费了流,文件还得随流一起关。用下面这种写法,正常返回和中途出错都会尝试关闭。示例使用 JDK 17/21,文件编码为 UTF-8。
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
final class LogCounter {
static long countErrorLines(Path path) throws IOException {
try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) {
return lines.filter(line -> line.contains("ERROR")).count();
}
}
}
HTTP 调用也有类似的坑。比如 JDK HttpClient 用 BodyHandlers.ofInputStream() 返回响应流,成功、非 2xx 和异常分支都要安排读完或关闭。排查时先找出谁负责收尾,再拿同一类请求复测。已经有 HTTP 超时症状的,可以对照Java 调用 HTTP 接口超时怎么排查。
如果 FD 会回落,再算上限该不该调
假如连接数与流量一起涨落,峰值属于业务需要,当前软上限确实太低,就看启动配置。以 systemd 管理的系统服务为例,先核对服务主进程和单元配置。若主进程是包装脚本,再找到实际的 Java PID 核对 /proc。
unit=orders.service # 换成实际服务名
systemctl show "$unit" -p MainPID -p LimitNOFILESoft -p LimitNOFILE
决定调整后,按服务发布流程为该单元添加 drop-in。下面仅演示软、硬上限的写法。数值按实测峰值、宿主机余量和依赖兼容性确定,尤其要留意仍依赖 select(2) 的原生组件。
[Service]
LimitNOFILE=32768:65536
单元配置保存后要让 systemd 重新加载配置,并在可控窗口重启服务,让新进程继承新值。对新 Java PID 再看一次 /proc/<PID>/limits。容器服务应检查容器启动配置及容器内的实际进程值。
最后用同样的并发、同样的调用路径复测一轮,包含之前容易出错的异常请求。看 FD 是否进入稳定范围、流量退下去后是否回落、原来的报错是否消失。只在重启后看一眼低水位,容易把暂时腾出来的空间当成修复。还需要对照线程、连接池和部署问题时,可回到Java 后端排障专题。
参考资料
Java 报 Too many open files,先看谁占满了 FD
https://java.li/archives/java-too-many-open-files
评论