文件描述符耗尽导致服务假死:一次 fd 泄漏排查实录

在 Linux 生产环境里,文件描述符(fd)耗尽 是一种极其隐蔽的高并发事故:服务进程并没有崩溃,CPU 和内存也看似正常,但接口开始“抽风”——时而响应极慢,时而直接 502,重启后能好一阵子又复发。更麻烦的是,这类问题往往在流量高峰才暴露,平时压测又复现不了,很容易被当成“偶发网络抖动”草草忽略,直到某次大促直接挂掉才被重视。本文复盘一次 服务假死 的真实排查,把“怎么发现 fd 泄漏、根因是什么、如何止血与长效防护”讲清楚,避免你踩同样的坑。相关思路也与 慢 SQL 拖垮生产库:一次 CPU 100% 排查实录Java 内存泄漏排查复盘:从堆飙升到根因定位 的线上排查一脉相承。

一、故障现场:接口时而超时、时而 502

某天凌晨告警:核心服务 P99 从 80ms 飙到 8s,部分请求直接 502。登上机器看,进程还活着,CPU 不到 30%,堆内存平稳,但 `curl localhost:port/health` 偶尔要等十几秒才返回。第一反应是“是不是 GC 或慢 SQL”,可 慢 SQL 拖垮生产库:一次 CPU 100% 排查实录 的套路排查了一圈都没复现。直到看到应用日志里频繁出现 `java.net.SocketException: Too many open files`,才意识到方向错了——这是 fd 打满了。补充一个判断技巧:如果 top 看 CPU、内存都正常,`dmesg` 里也没有 OOM,应用却开始拒绝新连接,就该优先把 fd 纳入怀疑清单,而不是先去翻 GC 日志。

二、什么是文件描述符(fd)

Linux 把一切皆文件:普通文件、socket、管道、设备,统统用 fd 这个非负整数来引用。每个进程能同时打开的 fd 数量有上限(受 `nofile` 限制,默认常是 1024)。一旦打开的 fd 超过了上限又没释放,新的连接、日志写盘、甚至读配置都会失败——表现就是服务“假死”而非“崩溃”。

# 进程当前打开的 fd 数量
ls -l /proc/<pid>/fd | wc -l

# 进程级的 fd 上限(软/硬限制)
cat /proc/<pid>/limits | grep "Max open files"
ulimit -n

# 系统级已分配与上限
cat /proc/sys/fs/file-nr
# 输出如 12345   0   65536  => 已用 / 空闲 / 上限

`file-nr` 的第三列是系统总上限,第二列是已分配;单个进程的已用数则看 `/proc//fd`。高并发服务把默认 1024 的上限用满,往往只需几分钟。

三、定位:是谁在疯狂吃 fd

确认方向后,要快速锁定“泄漏点”。核心思路只有一句话:看这个进程到底持有了多少 fd,以及它们是什么类型。

# 按类型统计该进程持有的 fd
ls -l /proc/<pid>/fd | awk '{print $NF}' | sed 's/.*\///' | sort | uniq -c | sort -rn | head

# 看是否大量处于 ESTABLISHED 的 socket
ss -antp | grep <pid> | grep -c ESTAB
# 看是否存在大量 CLOSE-WAIT(对方不关,我方也不关)
ss -antp | grep <pid> | grep -c CLOSE-WAIT

# 找到持有最多 fd 的进程
lsof -n | awk '{print $2}' | sort | uniq -c | sort -rn | head

如果 `CLOSE-WAIT` 数量持续累积,基本可以断定“对方关了连接,我方代码却没关”,连接一直挂在半关闭状态占用 fd。常见来源对照如下:

泄漏来源典型表现排查命令
HTTP 客户端未关闭CLOSE-WAIT / ESTAB 暴涨ss -antp | grep pid | grep -c CLOSE-WAIT
数据库连接池配置过大或泄漏大量 socket 到 3306/5432lsof -p pid | grep -c “:3306”
日志/文件流未 close普通文件 fd 累积ls /proc/pid/fd | wc -l 持续增长
管道/子进程未回收pipe/fork 残留ls -l /proc/pid/fd 中 pipe: 增多

本次事故里,`CLOSE-WAIT` 每分钟涨几百,根因呼之欲出:出向 HTTP 调用没正确释放。还有一个容易忽略的点:fd 泄漏往往和“连接池配置”是孪生问题。池子设得过大(比如 `maxConnections=10000` 却只有 4 核),并发一来瞬间把 fd 占满;池子设得过小又会排队超时。定位时务必把“连接池上限 × 实例数”和“nofile 上限”放在一起算笔账,二者谁先到顶谁就是瓶颈。

四、根因:被遗忘的 HTTP 连接没释放

翻代码发现,团队封装的 HTTP 工具类每次请求都 `HttpClients.createDefault()` new 一个客户端,调用完却没 `close()`。高并发下每个请求都开一对 socket、占用 fd,用完不归还,fd 自然只增不减。对比 Linux 内核与系统参数调优实战 里强调的“系统参数只是下限,代码不释放一样白搭”,这里恰恰是代码侧漏了。

// 反例:每次调用 new 一个 HttpClient 且不关闭,连接/socket 一直累积
CloseableHttpClient client = HttpClients.createDefault();
HttpResponse r = client.execute(new HttpGet(url));   // 用完没 close()
// 高并发下 fd 持续上涨,直到触达 nofile 上限

// 正例:复用连接池 + try-with-resources 确保释放
try (CloseableHttpClient client = HttpClients.custom()
        .setMaxConnTotal(200).setMaxConnPerRoute(50).build();
     CloseableHttpResponse r = client.execute(new HttpGet(url))) {
    // 自动关闭,连接归还池子,不再泄漏 fd
}

修复原则很简单:连接池要复用,资源要释放。优先用单例的 `CloseableHttpClient` + 合理 `setMaxConnTotal/PerRoute`,并配合 try-with-resources 确保 `close()` 必达。

五、止血与修复

线上先止血,再根治。止血靠两件事:临时调大 `nofile`,以及重启释放已被占满的 fd;根治才是改代码。

# 临时止血:调大当前 shell/进程的 nofile(需 root)
prlimit --pid <pid> --nofile=65535:65535
# 或 systemd 服务文件加上(永久生效)
# [Service]
# LimitNOFILE=65535

# Nginx 全局连接上限(worker 数 × 单 worker 上限)
# nginx.conf: worker_rlimit_nofile 65535;
# 每个 worker 的 events: worker_connections 10240;

注意:单纯 `ulimit -n` 只对当前 shell 生效,要让上限在进程重启后依然有效,必须在 systemd 的 `LimitNOFILE` 或 Nginx 的 `worker_rlimit_nofile` 里写死。结合 Nginx 反向代理完整配置:负载均衡 + HTTPS + 缓存优化 的反向代理实践,网关侧也要留足 fd 余量,否则前端 502 会反噬业务服务。

六、长效防护:把 fd 纳入监控

靠“出了问题再查”太被动。建议把 fd 使用率做成核心指标:用 node_exporter 的 `process_open_fds`、或自研脚本采集 `/proc//fd` 数量,配一条“使用率 > 70% 持续 5 分钟即告警”的规则。再配合 GitHub Actions 实战:从零搭建 CI/CD 流水线 在 CI 里加一条静态检查(禁止在循环里 new 客户端、强制 close),把泄漏挡在合并之前。特别建议在告警里区分“已用/上限”的比例而非绝对值——不同机器的 `nofile` 配置不同,绝对值会误报;真正该盯的是使用率趋势:只要曲线持续爬升且不回落,不管当前多低都该提前介入。

小结

fd 耗尽 不是玄学:现象是 服务假死、日志报 `Too many open files`;定位靠 `ls /proc/pid/fd` 与 `ss` 看 CLOSE-WAIT;根因多半是“资源开了不关”;止血靠调大 `nofile` + 重启,根治靠连接池复用与 try-with-resources,长效靠把 fd 指标接入监控。把它和慢 SQL、内存泄漏一起,列入线上排查的“三板斧”,下次凌晨告警你就能睡个安稳觉。

上一篇 慢 SQL 拖垮生产库:一次 CPU 100% 排查实录
下一篇 前端跨域 CORS 完整指南:从预检到生产落地