一台机器上的服务反复报 too many open files,重启就好,两天后再犯。运维第一反应是调大 ulimit -n,从 1024 调到 65535,撑了五天。第二次调大时才发现 /proc/<pid>/fd 里挂着 4,000 多句柄,而业务连接池只有 200——问题从来不是上限太小,是每请求漏一个。
too many open files 是个"被归错类"的错误:它看起来像容量问题,实际是生命周期问题。而且它底下至少藏三种不同的东西(socket、普通文件、管道/eventfd),三种泄漏的形状完全不同,修法也不同。
这篇把账拆成三层:一个数字底下是什么、限制是怎么一层层继承的、以及"该调上限还是该修泄漏"的判据。
1. 一个数字底下有三种资源
ls /proc/<pid>/fd | wc -l 数的是一个混合池。同样是 4,000,含义可能完全不同。
| 类型 | 典型来源 | 泄漏形态 | 危险度 |
|---|---|---|---|
| socket | 数据库连接、HTTP 客户端、RPC | 每请求一条,单调增长 | 高(连带端口耗尽) |
| 普通文件 | 日志、临时文件、上传 | 慢泄漏,几天涨几千 | 中(先撑爆磁盘再撑爆 fd) |
| 管道 / eventfd / epoll | 线程池、异步框架、Netty | 跟线程数或 event loop 数同阶 | 低(通常稳定) |
| 内存映射 / 匿名 inode | mmap、大文件切片 | lsof 显示 deleted |
中(节点上不可见) |
第三行的"跟线程数同阶"很关键:Netty 的每个 event loop 会持有一个 epoll fd,几百个线程就是几百个 fd——这部分是稳态,不该被算进泄漏。把稳态当泄漏去"修",会白花一周。
// 一次典型的 4,000 句柄构成(服务重启 48 小时后)
socket 3,120 78% ← 上游连接池 200 + 泄漏 2,920
普通文件 640 16% ← 上传临时文件未删
epoll/eventfd 240 6% ← 240 个 event loop,稳态
// 判断:真正的问题是 socket,第二项是慢泄漏,第三项不该动
2. 限制是一层层继承的,改哪层常被搞错
ulimit -n 只是当前 shell 的限制。守护进程的限制来自别处,而且 systemd 会覆盖 PAM 的设置。
| 层 | 在哪看 | 影响谁 | 常见误区 |
|---|---|---|---|
| shell | ulimit -n |
当前 shell 派生的进程 | 以为改了就全局生效 |
| PAM | /etc/security/limits.conf |
登录会话 | 对 systemd 服务无效 |
| systemd | LimitNOFILE= |
该 unit 的所有进程 | 不写就用默认(常是 1024) |
| docker | --ulimit nofile= / compose |
容器内所有进程 | 不写就继承 daemon 默认 |
| 内核 | fs.file-max、fs.nr_open |
全系统合计 | 与每进程上限混为一谈 |
第二行就是那个经典陷阱:在 limits.conf 里写了 * soft nofile 65535,用 systemctl start 起的服务仍然是 1024——因为 systemd 不走 PAM。改完必须到进程里核一次:
// 核对"这个进程实际拿到多少",而不是"我改了多少"
$ cat /proc/$(pgrep -f app.jar | head -1)/limits | grep -i "open files"
Max open files 1024 4096 files
// 三列分别是 soft / hard / 单位;第一列小于预期就是没生效
$ systemctl show myapp -p LimitNOFILE
LimitNOFILE=65535
// 若这里对了而进程里不对,说明 unit 没重载(daemon-reload)或进程不是它拉起的
// 三处必须一起写,缺一处就会出现"改了没用"
[Service]
LimitNOFILE=65535 // 该 unit 的进程
// /etc/security/limits.conf 只对登录会话有效,别只改那里
// docker-compose 里对应的是
// deploy: { resources: ... } 之外还要 ulimits: { nofile: {soft: 65535, hard: 65535} }
3. 泄漏的三种形状:怎么从曲线区分
同一个错误信息,三种完全不同的曲线。判据是时间与回落,不是绝对值。
| 形状 | 曲线特征 | 含义 | 修法 |
|---|---|---|---|
| 每请求 +1 | 与 QPS 线性、从不回落 | 真泄漏(忘 close/release) |
修代码,别调上限 |
| 峰值后回落 | 涨到峰值,闲时下降 | 连接池正常 | 无需处理 |
| 阶梯式上涨 | 每小时跳一次 | 定时任务/日志轮转漏 | 查定时器 |
| 平台期缓慢爬 | 每天 +50 | 慢泄漏(临时文件) | 查 deleted 句柄 |
第三行常被误判为"正常增长":每小时跳一次的台阶,往往是某个 @Scheduled 任务打开了文件没关。第四行的 deleted 句柄要专门看——文件已经删了,句柄还开着,df 不降、du 找不到,这是磁盘"莫名满了"的经典成因。
// 三步定性:计数、分类、看增长
$ ls /proc/$PID/fd | wc -l // 总数
$ ls -l /proc/$PID/fd 2>/dev/null | awk '{print $NF}' | sed 's/.*->//' | sort | uniq -c | sort -rn | head
// 输出形如:3120 socket:[12345] / 640 /data/upload/tmp-xxx (deleted)
$ for i in 1 2 3; do ls /proc/$PID/fd | wc -l; sleep 60; done
// 三次采样单调上升 = 泄漏;持平或回落 = 池化
4. 为什么"调大上限"总是先奏效后失效
调大上限把失效点往后推,且推的距离可算:泄漏速率 r(个/小时)与上限 N 的关系是 可用时间 ≈ (N − 稳态) / r。所以它只是把"两天一次"变成"五天一次"。
| 处置 | 效果 | 代价 | 什么时候对 |
|---|---|---|---|
| 调大 ulimit | 推迟 2–5 天 | 无(改配置) | 应急止血,且已定位泄漏 |
| 修 close 缺失 | 根治 | 一次发版 | 每请求 +1 型 |
| 加连接池上限 | 抑制 | 可能拒绝合法请求 | 上游连接不可控时 |
| 清理临时文件 | 缓解 | 加定时任务 | 慢泄漏且短期无发版窗口 |
第二行与第三行的差别很实际:修 close 要发版,加池上限只改配置——在生产上,先限流止血、再发版根治是唯一安全的顺序。相反的顺序(先发版)会让事故窗口变长。
// 一次事故的处置顺序(已验证)
1 限:把连接池 max 从 200 降到 120 → 增长速率降 40%
2 监:加一个 fd 计数告警(> 上限 70% 报警)
3 查:抓 3 次 /proc/fd 分类快照,定位到具体调用点
4 修:补 try-with-resources / finally close
5 撤:泄漏消失后,把连接池上限调回
5. 哪些"泄漏"其实是设计如此
不是所有增长都该修。有些架构天然持有大量句柄,把上限调到够用才是正解。
| 场景 | 句柄量级 | 结论 |
|---|---|---|
| 长连接网关(WebSocket) | 每连接 1 fd,1 万连接 = 1 万 fd | 合法,必须调上限 |
| 单机多实例 + 大连接池 | 实例数 × 池大小 | 合法,但要算总量 |
| 每请求新建 HttpClient | 与 QPS 同阶,且 TIME_WAIT 堆积 | 不合法,应复用连接池 |
| 日志按天轮转但未关旧流 | 天数 × 文件数 | 不合法,轮转要关旧句柄 |
| 上传服务落临时文件 | 与并发上传数同阶 | 半合法,需 TTL 清理 |
第一行是最容易被误判的:WebSocket 网关的 fd 数就该等于连接数,这个数字大是功能,不是缺陷。判断标准是"它是否与'同时存在的合法资源'成正比",而不是"它是否在涨"。
6. 排错:五种"以为改了上限"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 改了 limits.conf 没效果 | systemd 不走 PAM | 写 LimitNOFILE 并 daemon-reload |
| 容器里还是 1024 | 未设 ulimits,继承 daemon |
compose 里显式声明 |
| 调大后仍报错 | 撞 fs.file-max / nr_open |
查系统级上限与总量 |
| 重启就好,两天再犯 | 泄漏未修,只是清零计数 | 按第 4 节顺序处置 |
df 满、du 找不到 |
deleted 句柄占着空间 |
找持有者,重启或让其关闭 |
第五行值得单独记住:已删除但被打开的文件仍然占磁盘。lsof +L1 能列出这类句柄,这也是"删了日志磁盘没降"的根因。
7. 边界:什么情况不该调上限
| 场景 | 为什么不该 | 该做什么 |
|---|---|---|
| 每请求 +1 且从不回落 | 调上限只是推迟崩溃 | 修 close |
| 句柄数与 QPS 同阶 | 连接没复用 | 上连接池 |
单机 fd 总量接近 fs.file-max |
系统级瓶颈,不是进程级 | 拆分实例或换机型 |
| 泄漏导致内存也涨 | 句柄只是症状 | 查对象生命周期 |
| 已定 SLA 的共享主机 | 调上限会挤占邻居 | 隔离到专属节点 |
判断口径一句话:看曲线而不是看数字——单调不回落才是泄漏,与"合法并发数"成正比的是容量需求。前者调上限只会把事故从两天推迟到五天,后者不调上限就是配置错误。
8. 自测
/proc/<pid>/fd里的 4,000 个句柄可能由哪三类资源构成?哪一类不该被当成泄漏?- 为什么改
limits.conf对 systemd 服务无效?正确的位置是哪两处? - 三种泄漏曲线(线性不回落 / 峰值回落 / 阶梯上涨)分别对应什么原因?
- "调大上限"能推迟多久?这个时间由什么决定?
- 为什么"先限流止血、再发版根治"的顺序比反过来安全?
df满但du找不到空间,fd 层面发生了什么?
9. 一句话总结
too many open files 是生命周期问题,不是容量问题:一个计数底下混着 socket、文件、epoll 三类资源,只有前两类会泄漏,而 epoll 那部分与线程数同阶、属稳态。限制又是一层层继承的——shell、PAM、systemd、docker、内核各管一段,改错层等于没改。判据只有一条:曲线单调不回落才是泄漏(修代码),与合法并发成正比的是容量需求(调上限)。