问题背景
我们有一个对外提供接口的 Java 服务(Spring Boot,跑在一台 CentOS 7 的物理机上,直接用 systemd 拉起,没有走容器),日常 QPS 不高,长期稳定运行。某次发版后,服务运行三四天一切正常,但到了第五天早上,监控开始告警:接口 5xx 陡增、健康检查探测失败、部分请求直接连不上。
奇怪的是,服务进程还活着,CPU、内存、磁盘都不满,网络也通。运维同事第一反应是"重启一下试试",重启后确实恢复了,但过了几天同样的现象又准时出现——这种"跑一段时间就崩、重启就好、周期性复发"的症状,几乎可以肯定是某种资源在缓慢泄漏。这篇就记录一下把它从现象定位到根因、再到根治的完整过程。
故障现象
从应用日志里翻,很快找到了一批扎眼的报错,几乎刷屏:
|
|
同时系统日志 /var/log/messages 里也有内核层面的记录:
|
|
现象可以归纳为几点:
- 报错关键词是
Too many open files,对应系统调用返回EMFILE(进程级文件描述符耗尽)或ENFILE(系统级)。 - 现象随运行时间推移逐渐恶化:新建 socket、打开文件、accept 新连接全部失败,但已有连接大多还能用。
- 重启进程立刻恢复,然后 fd 数量又开始从低位缓慢往上爬。
- 业务侧表现为间歇性 5xx 和健康检查失败,容易被误判成"网络抖动"或"下游超时"。
这类报错的本质很明确:进程持有的文件描述符(file descriptor,简称 fd)数量触到了上限。Linux 里"一切皆文件",socket、普通文件、pipe、epoll 实例都占 fd,所以"文件描述符耗尽"往往不是真的打开了太多文件,而是连接或句柄泄漏。
排查过程
第一步,确认到底占了多少 fd。 先拿到进程 PID,然后数它当前打开的 fd 数量:
|
|
第二步,看进程自己的限制是多少。 不要只看 ulimit -n(那是当前 shell 的),一定要看目标进程实际生效的 limits:
|
|
真相浮现了一半:这个进程的软硬限制都只有 4096,而它当前已经占到 4102(数字略有出入是统计时刻差异),已经顶到天花板。很多人以为改了 /etc/security/limits.conf 就万事大吉,但 systemd 拉起的服务根本不读 limits.conf,它只认 unit 文件里的 LimitNOFILE,不写就用一个偏小的默认值。这是第一层坑。
第三步,判断是"用量合理但上限太小"还是"句柄泄漏"。 上限小只是诱因,如果 fd 会无限增长,那一定还有泄漏。我按类型统计一下这 4000 多个 fd 都是什么:
|
|
3600 个 socket,这个比例极不正常——服务并发没那么高。再用 lsof 看这些 socket 连到哪里、处于什么状态:
|
|
大量 CLOSE_WAIT 是决定性证据。CLOSE_WAIT 意味着对端已经发了 FIN 关闭连接,但本端应用一直没有调用 close(),连接卡在半关闭状态,对应的 fd 也就一直不释放。这几乎百分百是代码里连接/流没有正确关闭。
第四步,定位泄漏点。 结合 lsof 看到这些 CLOSE_WAIT 大多连向同一个下游 HTTP 服务,去翻调用那个下游的代码,果然发现问题:有一处用 HttpURLConnection 拿响应流,但在异常分支里没有关闭连接,也没有 try-with-resources。平时下游正常返回时走的是正常分支会关闭,但下游偶尔超时/报错时就漏关一个连接,日积月累就攒出了几千个 CLOSE_WAIT。
至此链条闭环:下游异常 → 连接漏关 → CLOSE_WAIT 堆积 → fd 缓慢增长 → 触到 4096 上限 → Too many open files → 新连接全废。
解决方案
分两步走,先止血再根治。
止血:调大 fd 上限并让 systemd 生效。 编辑 unit 文件(不要图省事去改 limits.conf,对 systemd 无效):
|
|
如果系统级 file-max 也报限制(VFS: file-max limit reached),再调内核层:
|
|
根治:修复句柄泄漏。 把漏关连接的代码改成自动关闭,确保任何分支都会释放:
|
|
更彻底的做法是引入连接池化的 HTTP 客户端(如 Apache HttpClient / OkHttp)并复用,从根上避免这种手工管理连接漏关的问题。上线后持续观察 fd 曲线,确认稳定在低位、不再单调上涨。
根因分析
这次故障是两层原因叠加才爆发的:
- 诱因(上限太小):服务用 systemd 拉起,没显式配置
LimitNOFILE,实际生效值只有 4096,天花板本就很低。 - 根因(句柄泄漏):应用代码在下游异常分支漏调
close(),连接卡在 CLOSE_WAIT 不释放,fd 单调增长只是时间问题。
只有上限小、没有泄漏,一般不会崩;只有泄漏、上限很大,也只是把爆发时间往后推。两者叠加,才形成了这种"稳定运行几天后周期性崩溃"的迷惑现象。CLOSE_WAIT 堆积是这类泄漏最典型的指纹——它明确指向"应用层没关连接",而不是网络或内核的问题。
预防措施
- 服务上线前就设置合理的
LimitNOFILE(systemd 用 unit 文件,容器用--ulimit nofile),别用默认值裸奔。 - 给 fd 使用量加监控:采集
/proc/<pid>/fd数量与/proc/<pid>/limits的比值,超过 70%~80% 就告警,能在崩溃前几天就发现泄漏趋势。 - 代码规范:所有 socket、流、连接一律用
try-with-resources/defer/with等语言级机制自动关闭;HTTP 调用统一走连接池化客户端并复用。 - 定期巡检 CLOSE_WAIT:
ss -s或lsof看长期存在的 CLOSE_WAIT,是句柄泄漏的早期信号。 - 压测覆盖异常路径:泄漏常藏在下游超时、异常分支里,压测时要故意让下游报错,才能暴露漏关连接的问题。
总结
Too many open files 看着吓人,排查路径其实很固定:先 cat /proc/<pid>/limits 看上限、ls /proc/<pid>/fd | wc -l 看用量、再用 lsof 按类型和状态拆解占用。如果看到大量 CLOSE_WAIT,基本可以锁定是应用层连接漏关,而不是上限的锅——这时候光调大 LimitNOFILE 只是把爆发时间推后,真正要修的是代码里的句柄泄漏。记住一个判断口诀:上限决定何时崩,泄漏决定必定崩。把监控加在 fd 使用率上,让问题在告警里暴露,而不是在凌晨的 5xx 里。