线上服务周期性 Too many open files:fd 泄漏完整排查实录

服务跑 7 天集体崩,日志全是 Too many open files。本文写实记录排查:/proc/fd + lsof + ss + bpftrace + arthas + heapdump 定位到第三方 SDK 没 close,每天泄漏 9000 个 fd。附 Java/Go 4 种泄漏模式 + 系统层防御 + CI 检测规范。

线上 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+,说明泄漏

团队规范

  1. 所有 IO / Socket / Stream 必须 try-with-resources 或 defer close
  2. Connection pool 必须设 maximumPoolSize 和 leak detection
  3. CI 加 SpotBugs / staticcheck 检测未关闭资源
  4. 监控加 fd 使用率告警(70% / 90%)
  5. 三方 SDK 引入前必须做 fd 泄漏测试(5 万次调用看是否回收)
  6. 压测脚本必须包含 fd 数量回归检查
  7. 新人 onboarding 必看 Effective Java 第 9 条 / Go gopl 第 5 章

fd 泄漏是个"看起来低级实际坑爹"的问题,每次都是周期性发病,排查链路很长。把这套监控 + CI 静态检查 + 规范做好,这类事故能从每月 1-2 次降到一年 0-1 次。第三方 SDK 不靠谱时,业务方再包一层 try-with-resources 兜底,代价小收益大。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

gRPC 全链路 deadline 传播实战:从下游被卡死到 5 分钟定位

2026-5-19 11:32:26

技术教程

线上 TCP CLOSE_WAIT 堆积排查实录:5 个方法定位到应用层 bug

2026-5-19 11:39:30

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索