线上 Java 服务跑了 7 天突然集体崩溃,日志一片 Too many open files。重启后又能跑 7 天再崩,周期性发病。最后定位是一个第三方 SDK 没 close,每个请求泄漏 1 个 fd,每天泄漏 ~9000 个,撑 7 天打满 65535。本文写实记录排查过程,把 fd 泄漏的诊断方法整理清楚。
故障现象
2026-02-21 03:14:12 ERROR FileNotFoundException: ... (Too many open files)
2026-02-21 03:14:12 ERROR SocketException: Too many open files
2026-02-21 03:14:12 ERROR Connection refused
... 持续几千条同样错误 ...
服务彻底崩溃,所有接口 500,前端 504
监控:CPU 正常 / 内存正常 / 网络正常
唯一异常:进程的 open files 数量 = 65535
fd 是什么
Linux 上一切皆文件:
- 磁盘文件 → fd
- Socket 连接 → fd
- 管道 → fd
- 设备 → fd
- 事件循环用的 epoll / inotify → fd
Linux 限制单进程能同时打开多少 fd,这就是 ulimit -n。打满了之后所有需要 fd 的操作都失败。
第一步:确认是 fd 泄漏
# 看进程的 fd 数量
$ pid=12345
$ ls /proc/$pid/fd | wc -l
58234
# 看 ulimit
$ cat /proc/$pid/limits | grep "open files"
Max open files 65535 65535 files
# 监控 fd 使用增长
$ while true; do
echo "$(date +%H:%M:%S) $(ls /proc/$pid/fd | wc -l)"
sleep 30
done
10:00:00 58234
10:00:30 58241 # 半分钟涨 7 个
10:01:00 58249
10:01:30 58256
# 持续增长 → 确认泄漏
第二步:定位泄漏类型
# 看 fd 都是什么
$ ls -la /proc/$pid/fd/ | head -20
lrwx------ 1 app app 64 Feb 21 10:00 0 -> /dev/null
lrwx------ 1 app app 64 Feb 21 10:00 1 -> pipe:[123456]
lrwx------ 1 app app 64 Feb 21 10:00 2 -> pipe:[123457]
lrwx------ 1 app app 64 Feb 21 10:00 3 -> /var/log/app.log
lrwx------ 1 app app 64 Feb 21 10:00 4 -> socket:[789012]
lrwx------ 1 app app 64 Feb 21 10:00 5 -> /tmp/cache/abc.tmp
...
# 统计 fd 类型分布
$ ls -la /proc/$pid/fd/ | awk '{print $11}' | sort | uniq -c | sort -rn | head
41234 socket:[xxx] ← 大量 socket
8500 /tmp/cache/xxx.tmp ← 大量临时文件
150 /var/log/xxx.log
20 pipe:[xxx]
10 anon_inode:[eventpoll]
# 看 socket 的对端
$ ss -tnp | grep $pid | awk '{print $5}' | sort | uniq -c | sort -rn | head
35000 10.0.0.99:6379 ← 全是连 Redis 的
100 10.0.0.50:3306
50 8.8.8.8:443
结论:35000 个 Redis 连接没关。明显泄漏。
第三步:Java 应用层定位
# 用 jcmd 看 NIO 文件 fd
$ jcmd 12345 PerfCounter.print | grep "java.nio"
java.nio.dchannels.opened=58234
java.nio.dchannels.closed=22000
# 差 = 36234 个 NIO channel 没关!
# arthas 看具体哪段代码持有 socket
$ arthas-boot
[arthas@12345]$ watch java.net.Socket <init> '{thread, throwable.getStackTrace()}' -n 100
# 看新建 Socket 的栈
# heapdump 离线分析
$ jmap -dump:format=b,file=heap.bin 12345
$ # 用 MAT 打开,看 Socket / SocketChannel 实例数和 GC root
$ # 通常会看到一堆 Socket 被 List / Map 引用,但 close 没调
真实案例:第三方 SDK 未关流
// 出问题的 SDK 代码(伪代码,真实代码已经向 SDK 团队报 bug)
public class ThirdPartySDK {
public String getData(String key) {
Socket socket = new Socket("api.example.com", 8080);
OutputStream out = socket.getOutputStream();
InputStream in = socket.getInputStream();
out.write(("GET " + key).getBytes());
// 读到第一行就 return,后续 close 全忘了
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
return reader.readLine();
// ← 没 close socket / out / in / reader,GC 也不会立即回收
}
}
修法选一种:
// 1. 让 SDK 修(根本解法)
// 向 SDK 团队提 PR / issue
// 2. 业务方包一层,用 try-with-resources
public String getDataSafe(String key) {
try (Socket socket = new Socket("api.example.com", 8080);
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {
socket.getOutputStream().write(("GET " + key).getBytes());
return reader.readLine();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
// 3. 全局兜底:JVM 退出前 close(只能救命,不能根治)
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
sdk.shutdown();
}));
常见 fd 泄漏模式
// 模式 1:FileInputStream 没关
// 错
FileInputStream fis = new FileInputStream(file);
byte[] data = fis.readAllBytes();
// 没 close,fd 等 GC,可能很久才释放
// 对
try (FileInputStream fis = new FileInputStream(file)) {
byte[] data = fis.readAllBytes();
}
// 模式 2:Files.list() 不 close
// 错
Files.list(path).forEach(System.out::println);
// Files.list 返回的 Stream 持有一个 DirectoryStream(fd),需要 close
// 对
try (Stream<Path> stream = Files.list(path)) {
stream.forEach(System.out::println);
}
// 模式 3:Connection 池配置错
// 错:不设最大连接数,理论无限
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(Integer.MAX_VALUE);
// 对:合理上限,leak detection
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(50);
config.setLeakDetectionThreshold(60_000); // 借出 60 秒不还报警
// 模式 4:线程池不 shutdown
// 错
ExecutorService exec = Executors.newCachedThreadPool();
// ... 用完忘了 shutdown,JVM 退出时线程仍持有 fd
// 对
try {
// ... 业务 ...
} finally {
exec.shutdown();
exec.awaitTermination(5, TimeUnit.SECONDS);
}
Go 同样的问题
// 模式 1:HTTP response body 没 close
// 错
resp, _ := http.Get(url)
data, _ := io.ReadAll(resp.Body)
// resp.Body 没 close,连接不会回连接池,后续都是新连接 → fd 泄漏
// 对
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close() // ← 必须
// 模式 2:os.Open 没 close
// 错
f, _ := os.Open(path)
data, _ := io.ReadAll(f)
// 对
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
// 检测工具:go vet 不能检测这个,得用 staticcheck
$ staticcheck ./...
file.go:23:9: should check returned error before deferring resp.Body.Close()
诊断神器:lsof / strace / bpftrace
# lsof 看进程所有 fd 详情
$ lsof -p 12345 | head
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
java 12345 app cwd DIR 8,1 4096 1234567 /app
java 12345 app txt REG 8,1 123456 1234568 /usr/lib/jvm/java/bin/java
java 12345 app 0r CHR 1,3 0t0 6 /dev/null
java 12345 app 3r REG 8,1 1234567 1234569 /var/log/app.log
java 12345 app 4u IPv4 1234567890 0t0 TCP host:54321->redis:6379 (ESTABLISHED)
# 统计某进程占用的 socket 状态
$ lsof -p 12345 | grep TCP | awk '{print $NF}' | sort | uniq -c | sort -rn
35000 (ESTABLISHED)
120 (CLOSE_WAIT)
50 (TIME_WAIT)
# bpftrace 跟踪 open/close 调用次数
$ bpftrace -e 'tracepoint:syscalls:sys_enter_openat /pid == 12345/ { @[comm] = count(); }'
$ bpftrace -e 'tracepoint:syscalls:sys_enter_close /pid == 12345/ { @[comm] = count(); }'
# open 远多于 close → 泄漏
# strace 看具体哪个 fd 没关(开销大,只在测试环境)
$ strace -e trace=openat,close -p 12345 -f 2>&1 | tail -100
系统层防御
# 1. 监控 fd 使用率
$ cat /proc/sys/fs/file-nr
4096 0 65535
# 当前 4096 / 已分配 0 / 系统上限 65535
# Prometheus 指标(node_exporter)
node_filefd_allocated{instance="..."} 4096
node_filefd_maximum{instance="..."} 65535
# 告警:用率 > 70% 提醒
# 2. 调高单进程上限(不是根治,但能撑过去)
# /etc/security/limits.conf
* soft nofile 100000
* hard nofile 200000
# systemd service
[Service]
LimitNOFILE=200000
# 3. 系统级 fd 上限
# /etc/sysctl.conf
fs.file-max = 2097152
压测验证
# 压测后看 fd 是否回收
# 1. 压测前
$ ls /proc/$pid/fd | wc -l
58
# 2. wrk 压测 30s
$ wrk -t 4 -c 200 -d 30s http://localhost:8080/api/data
# 3. 压测结束后等 5 分钟
$ ls /proc/$pid/fd | wc -l
62 # 多了几个常规的,合理
# 如果还是 5000+,说明泄漏
团队规范
- 所有 IO / Socket / Stream 必须 try-with-resources 或 defer close
- Connection pool 必须设 maximumPoolSize 和 leak detection
- CI 加 SpotBugs / staticcheck 检测未关闭资源
- 监控加 fd 使用率告警(70% / 90%)
- 三方 SDK 引入前必须做 fd 泄漏测试(5 万次调用看是否回收)
- 压测脚本必须包含 fd 数量回归检查
- 新人 onboarding 必看 Effective Java 第 9 条 / Go gopl 第 5 章
fd 泄漏是个"看起来低级实际坑爹"的问题,每次都是周期性发病,排查链路很长。把这套监控 + CI 静态检查 + 规范做好,这类事故能从每月 1-2 次降到一年 0-1 次。第三方 SDK 不靠谱时,业务方再包一层 try-with-resources 兜底,代价小收益大。
—— 别看了 · 2026