2664 字
约 8 分钟
13
S15-A27 · 文件描述符:连接泄漏与句柄泄漏是两笔账

一台机器上的服务反复报 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. 自测

  1. /proc/<pid>/fd 里的 4,000 个句柄可能由哪三类资源构成?哪一类不该被当成泄漏?
  2. 为什么改 limits.conf 对 systemd 服务无效?正确的位置是哪两处?
  3. 三种泄漏曲线(线性不回落 / 峰值回落 / 阶梯上涨)分别对应什么原因?
  4. "调大上限"能推迟多久?这个时间由什么决定?
  5. 为什么"先限流止血、再发版根治"的顺序比反过来安全?
  6. df 满但 du 找不到空间,fd 层面发生了什么?

9. 一句话总结

too many open files 是生命周期问题,不是容量问题:一个计数底下混着 socket、文件、epoll 三类资源,只有前两类会泄漏,而 epoll 那部分与线程数同阶、属稳态。限制又是一层层继承的——shell、PAM、systemd、docker、内核各管一段,改错层等于没改。判据只有一条:曲线单调不回落才是泄漏(修代码),与合法并发成正比的是容量需求(调上限)。

S15-A27 · 文件描述符:连接泄漏与句柄泄漏是两笔账
https://www.clxhxhhr.top/posts/6729/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。