2024 年,一个 Java 服务上线后,头两天一切正常,第三天开始报一个错:Too many open files——打开的文件太多了。报错一出,新来的请求大面积失败,服务几乎不可用。我第一反应是:这服务又没读写多少文件,哪来的"打开的文件太多"?我登上服务器,以为是磁盘满了或者 inode 满了,df 一看,空间和 inode 都富余得很。我又猜是不是哪个日志文件被打开了没关,可翻了一遍代码,文件读写都规规矩矩用了 try-with-resources。我一时没了头绪。后来我隐约记得有个命令叫 ulimit,跟"打开文件数"有关,就 ulimit -n 看了一下,显示 1024。我心想找到了——上限才 1024,太小了,我直接 ulimit -n 65535 把它调大,然后重启服务。结果第二天,Too many open files 又来了。我盯着这个又一次出现的报错想了很久,最后才反应过来:我犯了两个错。第一,我改的那个 ulimit,根本不是这个服务进程身上的限制;第二,就算我把上限调到天上去,也只是把"撑爆"这件事往后推迟了——因为真正的问题不是上限太低,是这个服务在【不停地占用 fd 却从不归还】。这件事逼着我把 Linux 的文件描述符、ulimit、systemd 资源限制、fd 泄漏这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一个 Java 服务,由 systemd 管理
事故现象:
- 服务上线头两天正常,第三天起报 Too many open files
- 报错后新请求大面积失败,服务几乎不可用
- ★ 磁盘没满、inode 没满;改大 ulimit -n 重启后第二天又复发
现场排查:
# 1. 看服务进程当前打开了多少 fd
$ ls /proc/9400/fd | wc -l
4096 # ★ fd 数量很高
# 2. ★ 看这个进程【真正生效】的 fd 上限(不是 shell 的)
$ cat /proc/9400/limits | grep 'open files'
Max open files 4096 4096 files # ★ 上限就是 4096,已顶满
# 3. 我在 shell 里 ulimit -n 看到的
$ ulimit -n
65535 # ★ 我改的是 shell 的,服务的还是 4096
# 4. ★ 看服务进程打开的 fd 都是些什么
$ ls -l /proc/9400/fd | awk '{print $NF}' | sed 's/[0-9]//g' \
| sort | uniq -c | sort -rn
3600 socket:[] # ★ 绝大多数是 socket!
300 /opt/app/logs/applog
...
# 5. ★ 隔 10 分钟再数一次 fd
$ ls /proc/9400/fd | wc -l
4096 ->(10分钟后)-> 4096(顶满不掉) # ★ 只涨不掉 = 泄漏
根因(后来想清楚的):
1. ★ fd(文件描述符)不只是"文件"。进程每打开一个
文件、每建一条 socket、每开一个管道,都占用一个
fd。Too many open files 里的 "files",其实是 fd。
2. 我的服务大量是 socket 类型的 fd —— 它在不停地
建立连接、用完【却没关闭】,fd 只涨不跌 = fd 泄漏。
3. ★ 我第一次"修复"改的 ulimit -n,改的是【我登录
shell】的限制。服务进程是 systemd 拉起来的,
它的限制【跟我的 shell 毫无关系】。
4. systemd 启动的服务,fd 上限由 service 单元里的
LimitNOFILE 决定,默认就比较小 —— 不读它,光改
/etc/security/limits.conf 或 shell 的 ulimit,
对这个服务【一点用都没有】。
5. ★ 就算上限改对、改大了,也只是【推迟】爆炸:
只要 fd 泄漏没修,涨到天花板只是时间问题。
fd 耗尽 = 上限可能太低 + 更可能是 fd 泄漏,两者要分开治。
修复 1:Too many open files——fd 是什么,什么都占 fd
# === ★ 先纠正最核心的误解:"open files" 不只是文件 ===
# === 报错里的 "files" 到底指什么 ===
# Too many open files —— 字面看像是"打开的文件太多"。
# ★ 但这里的 "file",是 Linux 的一个【广义】概念。
# 在 Linux 里,"一切皆文件":
# - 你打开的普通文件 -> 占 1 个 fd
# - 你建立的每一条 socket -> ★ 占 1 个 fd
# - 你开的管道 pipe -> 占 1 个 fd
# - epoll、eventfd、定时器fd -> 都占 fd
# ★ 所以一个"几乎不读写文件"的网络服务,照样能把
# fd 用爆 —— 因为它的每一条网络连接,都是一个 fd。
# === fd 是什么 ===
# fd(file descriptor,文件描述符)是一个【小整数】。
# 进程每打开一样东西,内核就给它发一个 fd 当"号牌",
# 之后进程靠这个号牌来读写它。
# ★ 进程能同时持有多少个 fd,是【有上限】的。
# 到了上限再想开新的,就报 Too many open files。
# === ★ 看一个进程当前持有多少 fd ===
# 进程的每个 fd,在 /proc//fd/ 下都有一项:
$ ls /proc/9400/fd | wc -l
4096
# ★ 这个目录下有几项,进程就占了几个 fd。
# === ★ 看这些 fd 都是些什么类型 ===
$ ls -l /proc/9400/fd | head
lrwx------ ... 10 -> socket:[298113] # ★ socket
lrwx------ ... 11 -> socket:[298114]
l-wx------ ... 12 -> /opt/app/logs/applog # 普通文件
lr-x------ ... 13 -> pipe:[12345] # 管道
# ★ 一眼就能看出:这个进程的 fd,大头是不是 socket。
# 是 socket 占大头 -> 多半是连接没关(见修复 4)。
# === 用 lsof 看得更清楚 ===
$ lsof -p 9400 | awk '{print $5}' | sort | uniq -c | sort -rn
3600 IPv4 # ★ 3600 个网络连接
300 REG # 300 个普通文件
# ★ lsof -p 列出某进程打开的全部"文件",
# 按类型一聚合,什么占大头清清楚楚。
# === 一句话认知 ===
# ★ Too many open files = 这个进程持有的 fd 到顶了。
# 排查它,不能只盯着"文件",socket 往往才是大头。
修复 2:ulimit -n 改了为什么不生效——你改的不是服务的 limit
# === ★ 第二个大坑:ulimit 改了,服务却没变 ===
# === ulimit 是"谁的"限制 ===
# ulimit 设置的限制,是【当前这个 shell 进程】的,
# 并且会被它【启动的子进程】继承。
$ ulimit -n
1024
$ ulimit -n 65535 # ★ 只改了"我这个 shell"
$ ulimit -n
65535
# === ★ 关键:限制是"继承"来的,不是"全局"的 ===
# 一个进程的 fd 上限,是它【被启动的那一刻】,从
# 【它的父进程】那里继承来的。之后就固定在它身上。
# ★ 所以"谁启动了这个服务",决定了这个服务的上限。
# === 我的错:我和服务,是两条不同的"血脉" ===
# - 我登录 SSH -> 我的 shell -> 我手动 ./start.sh
# 这条链路启动的进程,继承【我 shell】的 ulimit。
# - ★ 但我的服务不是我手动起的,是【systemd】拉起的:
# systemd(PID 1) -> 我的服务进程
# 它继承的是【systemd】给的限制,跟我的 shell
# 一毛钱关系都没有。
# ★ 我在 SSH 里 ulimit -n 65535,改的是我自己这条
# 血脉;服务在 systemd 那条血脉上,纹丝不动。
# === ★ 唯一可信的:直接看进程自己的 limits ===
# 别猜、别看 shell 的 ulimit,直接读进程的真实限制:
$ cat /proc/9400/limits | grep -i 'open files'
Limit Soft Limit Hard Limit Units
Max open files 4096 4096 files
# ★ /proc//limits 是这个进程【此刻真正生效】的
# 限制,不会骗人。我看到这里还是 4096,就该明白:
# 我那个 ulimit 65535,根本没作用到它身上。
# === soft limit vs hard limit ===
# - soft limit:当前实际生效的上限。
# - hard limit:soft 能被调到的天花板。
# 普通用户:soft 可在 0~hard 之间自己调,但【调不过
# hard】;hard 只有 root 能往上抬。
# === /etc/security/limits.conf 又是管谁的 ===
$ cat /etc/security/limits.conf
* soft nofile 65535
* hard nofile 65535
# ★ 注意:limits.conf 是给【PAM 登录会话】用的 ——
# 它影响的是"用户登录"产生的 shell 及其子进程。
# ★ systemd 启动的服务【不走 PAM 登录】,所以
# limits.conf 对 systemd 服务【基本不生效】!
# 这是最容易踩的坑:改了 limits.conf 以为万事大吉,
# 服务的 limit 其实没动。
修复 3:systemd 启动的服务,fd 上限看 LimitNOFILE
# === ★ systemd 服务的 fd 上限,只认 LimitNOFILE ===
# === 正确的地方:service 单元的 LimitNOFILE ===
# 一个由 systemd 管理的服务,它的 fd 上限,由它的
# .service 单元文件里的 LimitNOFILE= 决定。
$ systemctl cat myapp.service
[Service]
ExecStart=/opt/app/bin/run
# ★ 如果这里【没写】LimitNOFILE,就用 systemd 的
# 默认值 —— 不同版本不一样,常常并不大。
# === ★ 改对的做法:给 service 加 LimitNOFILE ===
# 不要直接改发行版自带的单元文件,用 override:
$ systemctl edit myapp.service
# 在打开的编辑器里写:
[Service]
LimitNOFILE=65535
# ★ 保存后会生成 /etc/systemd/system/myapp.service.d/override.conf
# === 让 systemd 重新加载,再重启服务 ===
$ systemctl daemon-reload
$ systemctl restart myapp.service
# === ★ 验证:一定要回到 /proc 里确认 ===
$ pid=$(systemctl show -p MainPID --value myapp.service)
$ cat /proc/$pid/limits | grep -i 'open files'
Max open files 65535 65535 files # ★ 这才算真的改对了
# ★ 改完【必须】这样验证。看 /proc//limits,
# 不看别的 —— 它是唯一的事实。
# === systemd 全局默认值(影响所有服务)===
$ cat /etc/systemd/system.conf | grep DefaultLimitNOFILE
#DefaultLimitNOFILE=
# ★ 也可以在这里设 DefaultLimitNOFILE 抬高所有服务的
# 默认值。但更推荐【按服务】单独设 LimitNOFILE。
# === ★ 小结:fd 上限"改哪里"对照表 ===
# 服务怎么起的 改哪里才有效
# --------------------------------------------------
# systemd 管理的服务 ★ service 单元的 LimitNOFILE
# 用户登录后手动起的 /etc/security/limits.conf + 重新登录
# 当前 shell 临时起的 ulimit -n
# ★ 搞错了对象,改一百遍都不生效。
修复 4:区分"上限太低"和"fd 泄漏"——两种病
# === ★ fd 耗尽,其实是两种不同的病 ===
# === 病 A:上限确实太低 ===
# 服务本来就需要同时持有很多 fd(比如一个高并发
# 网关,要同时维持几万条连接),而 LimitNOFILE
# 配得太小。
# ★ 特征:fd 数量【涨到某个值就稳定了】,不再涨,
# 只是那个稳定值【超过了上限】。
# 解法:把 LimitNOFILE 调到合理的大小(见修复 3)。
# === ★ 病 B:fd 泄漏(我中的就是这个)===
# 程序打开了 fd(开文件、建连接),用完【忘了关】。
# 于是 fd 像水池只进不出,【只涨不跌】,迟早撑爆。
# ★ 特征:fd 数量【随时间持续、单调地往上涨】,
# 不管上限设多大,都只是【晚一点】爆。
# === ★ 一招分辨是哪种病:盯着 fd 数量看趋势 ===
$ while true; do
echo "$(date '+%T') fd=$(ls /proc/9400/fd | wc -l)"
sleep 60
done
14:00:00 fd=2100
14:01:00 fd=2160 # ★ 一直涨
14:02:00 fd=2225 # ★ 还在涨
14:03:00 fd=2290 # ★ 涨势不停 = 泄漏(病 B)
# ★ 判断标准:
# - fd 数涨到一个值就【平了】 -> 病 A(上限太低)
# - fd 数【持续往上爬】不回落 -> 病 B(fd 泄漏)
# === ★ 泄漏时:从 fd 类型反推泄漏点 ===
$ ls -l /proc/9400/fd | grep -oP 'socket|pipe|/\S+' \
| sed 's/[0-9]*$//' | sort | uniq -c | sort -rn | head
3600 socket:[] # ★ 泄漏的是 socket
50 /opt/app/conf/db.properties # ★ 这个配置文件被打开 50 次!
# ★ 看哪类 fd 多得离谱:
# - socket 巨多 -> 多半是 HTTP 客户端/连接没关闭
# - 某个普通文件被打开几十上百次 -> 这个文件的
# 读取代码,每次开了没关 —— 泄漏点就在那段代码。
# === 看 socket 类 fd 具体连去哪 ===
$ lsof -p 9400 -a -i | awk '{print $9}' | sort | uniq -c | sort -rn | head
# ★ 大量连同一个地址 -> 对那个下游的连接在泄漏。
# === ★ 关键认知 ===
# 病 A 调上限就能根治;病 B 调上限【只是拖延】。
# 我第一次只做了"调上限",还调错了对象 —— 既没
# 治 B,连 A 都没改到。两头落空。
修复 5:正确解法——上限调对,泄漏修掉
# === ★ 解法:上限调对(治 A)+ 修掉泄漏(治 B)===
# === 第一步:把上限调对、调够(应对病 A)===
# 按服务的真实需要,给 systemd 服务设 LimitNOFILE:
$ systemctl edit myapp.service
[Service]
LimitNOFILE=65535
$ systemctl daemon-reload
$ pid=$(systemctl show -p MainPID --value myapp.service)
# ★ 先别急着重启 —— 重启会把"泄漏现场"清掉,
# 现场还有用,见下一步。
# === ★ 第二步(关键):趁现场还在,定位泄漏点 ===
# 重启服务能让它"暂时好起来",但泄漏的代码还在,
# 过两天照样复发。所以重启【之前】,先抓现场:
$ ls -l /proc/$pid/fd | awk '{print $NF}' | sort | uniq -c \
| sort -rn | head -20
# ★ 看哪类 fd 离谱地多。我这次是 socket 占了 3600 个。
# === 第三步:从泄漏的 fd 类型,回到代码 ===
# - socket 泄漏:检查 HTTP 客户端 / DB 连接的使用。
# * HTTP 客户端没复用、每次 new 一个,且没 close
# * 没有用连接池,或连接池配置 / 归还有 bug
# * 异常路径上漏了 close —— try 里开,catch 没关
# - 文件泄漏:检查文件读写,确保:
# * Java:用 try-with-resources,异常也会自动关
# * 别在循环里反复 open 同一个文件还不 close
# ★ 修复的核心永远是:谁打开的,就要保证谁关掉,
# 而且【异常路径上也要关】。
# === 第四步:加监控,别再靠"出事了才发现" ===
# 把进程 fd 数变成一个常态监控指标:
$ cat > /usr/local/bin/fd_check.sh <<'EOF'
#!/bin/bash
pid=$(systemctl show -p MainPID --value myapp.service)
cur=$(ls /proc/$pid/fd 2>/dev/null | wc -l)
max=$(awk '/Max open files/{print $4}' /proc/$pid/limits)
echo "$(date '+%F %T') fd=$cur/$max"
# ★ cur 接近 max 就告警,别等 Too many open files 才知道
EOF
# 挂进 crontab,fd 用量逼近上限就提前告警。
# === ★ 第五步:确认修好了 ===
# 修完泄漏、重新上线后,再盯一段时间的趋势:
$ while true; do echo "$(date +%T) $(ls /proc/$(systemctl show \
-p MainPID --value myapp.service)/fd|wc -l)"; sleep 300; done
# ★ fd 数应该【涨到一个稳定值就不动了】,
# 而不是继续单调上爬 —— 那才说明泄漏真的修掉了。
# === 解法优先级 ===
# 1. ★ 修掉 fd 泄漏 —— 根治,必须做
# 2. 把 LimitNOFILE 设到合理值 —— 应对真实高并发需要
# 3. 加 fd 用量监控 —— 下次能提前发现,而不是被报错告知
# ★ 只调上限不修泄漏,等于给漏水的池子换个大池子。
修复 6:文件描述符排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ Too many open files 里的 file 是 fd,socket 也算 ===
# 不读写文件的网络服务,照样能因 socket 把 fd 用爆。
# === 2. ★ 看进程真实 fd 上限,只信 /proc//limits ===
$ cat /proc/PID/limits | grep 'open files'
# 别看 shell 的 ulimit —— 那不是服务的限制。
# === 3. ★ systemd 服务的 fd 上限改 LimitNOFILE ===
# limits.conf 对 systemd 服务不生效,ulimit 也管不着它。
$ systemctl edit 服务 设 LimitNOFILE,daemon-reload
# === 4. 分清两种病:上限太低 vs fd 泄漏 ===
# fd 数涨到平了 = 上限低;持续上爬 = 泄漏。
# === 5. ★ 泄漏:看 /proc//fd 里哪类 fd 离谱地多 ===
$ ls -l /proc/PID/fd | awk '{print $NF}' | sort | uniq -c | sort -rn
# === 6. 修泄漏的核心:谁打开谁关闭,异常路径也要关 ===
# === 7. ★ 重启前先抓现场,重启会冲掉泄漏证据 ===
# === 8. 排查"Too many open files"的步骤链 ===
$ ls /proc/PID/fd | wc -l # ① 当前用了多少 fd
$ cat /proc/PID/limits | grep 'open files'# ② 真实上限是多少
$ 隔几分钟再数一次 # ③ 涨势 -> 判断泄漏 or 上限低
$ ls -l /proc/PID/fd 看类型分布 # ④ 泄漏的是哪类 fd
$ systemctl edit 设 LimitNOFILE + 修代码 # ⑤ 调上限 + 修泄漏
# 按这个顺序,fd 耗尽基本能定位、能根治。
命令速查
需求 命令
=============================================================
看进程当前打开的 fd 数 ls /proc/PID/fd | wc -l
看进程真实的 fd 上限 cat /proc/PID/limits | grep 'open files'
看 fd 都是些什么类型 ls -l /proc/PID/fd | awk '{print $NF}' | sort | uniq -c
列出进程打开的所有文件 lsof -p PID
看系统级 fd 总上限 cat /proc/sys/fs/file-max
看 systemd 服务的限制配置 systemctl cat 服务名
改 systemd 服务的 fd 上限 systemctl edit 服务名 设 LimitNOFILE
改完让 systemd 重载 systemctl daemon-reload
拿到服务主进程 PID systemctl show -p MainPID --value 服务名
看 shell 自己的 fd 上限 ulimit -n
口诀:open files 里的 file 是 fd,socket 也算;真实上限只看 /proc/PID/limits
systemd 服务改 LimitNOFILE,fd 持续上爬是泄漏,调上限不修泄漏只是拖延
避坑清单
- Too many open files 里的 file 是广义 fd,socket、管道、epoll 都占 fd
- 不读写文件的网络服务也能把 fd 用爆,因为每条 socket 连接都占一个 fd
- 进程真实的 fd 上限只能信 /proc/PID/limits,shell 里的 ulimit -n 不是服务的限制
- fd 上限是进程启动时从父进程继承的,谁启动的服务就决定了它的上限
- systemd 启动的服务不走 PAM 登录,/etc/security/limits.conf 对它基本不生效
- systemd 服务的 fd 上限要改 service 单元的 LimitNOFILE,再 daemon-reload 重启
- fd 耗尽分两种病,fd 数涨到平了是上限太低,持续单调上爬是 fd 泄漏
- 调大上限只能推迟泄漏型的爆炸,不修泄漏等于给漏水的池子换个大池子
- 修泄漏的核心是谁打开谁关闭,尤其异常路径上也必须关,用 try-with-resources
- 重启服务前先抓 fd 现场,重启会冲掉泄漏证据,之后加 fd 用量监控提前告警
总结
这次"服务跑了三天就报 Too many open files"的事故,纠正了我两个层层叠叠的误解,而它们最终指向的,是同一个思维上的毛病。第一个误解很表层:我看到 "open files",就真的以为是"打开的文件"太多了,于是我去查磁盘、查 inode、翻代码里的文件读写——我整个排查的起点,就找错了方向。后来我才知道,在 Linux 的世界里,"文件"是一个宽得多的概念,一条网络连接、一个管道,在内核眼里都是"文件",都占用一个叫文件描述符的号牌。我那个"几乎不碰文件"的服务,真正在疯狂消耗号牌的,是它建立的成千上万条 socket 连接。这个误解,源于我把一个技术术语,按它的中文字面意思去理解了。第二个误解要命得多。当我终于知道问题和 ulimit 有关,我做了一件让我至今都觉得有点羞愧的事:我在我的 SSH 终端里敲下 ulimit -n 65535,然后重启服务,然后心满意足地以为自己解决了问题。我完全没有意识到,我和那个服务,根本就活在两条互不相干的"血脉"里。我的终端是我登录时由系统派生出来的,我在它身上做的任何设置,只能流淌进它自己、以及由它亲手启动的子进程。可那个服务,根本不是我启动的——它是开机时由 systemd 这个系统的总管拉起来的,它的每一项资源限制,都是从 systemd 那里继承来的,和我那个孤零零的 SSH 终端没有半点关系。我对着自己的终端调高了限制,然后期待这个调整能"传导"到一个跟我毫无血缘关系的进程身上——这就像我在自己家里把空调开大,然后疑惑为什么邻居家还是热的。我改了,我重启了,我以为我做了功课,可那个服务进程身上的限制,自始至终是 4096,纹丝未动。而比这两个误解加起来还更深的,是第三件事:就算我把限制改对了对象、改到了天上去,我也根本没有解决问题。因为这个服务真正的病,不是"它需要的太多",而是"它拿了之后从不归还"。它每建一条连接,就占一个号牌,然后用完连接,却忘了把号牌交回去。号牌只出不进,无论这个池子有多大,被这样日复一日地消耗,撑爆都只是一个时间问题——我调大限制,顶多是让它从"撑三天"变成"撑三十天",我推迟了灾难,却把它伪装成了"解决了灾难"。这次最大的收获,是我看清了自己身上一个反复出现的思维毛病:我太急于"做点什么",以至于我跳过了"确认这件事是不是真的做对了"。我看到一个限制值,我就去改它——我没有先问,我改的这个值,到底是不是那个服务正在用的值?我看到一个上限被顶满,我就去调高它——我没有先问,它是因为天生就需要这么多,还是因为它在不停地泄漏?我的每一个动作,单独看都不算错,可它们全都建立在一个未经验证的假设之上,于是我忙活了半天,做的全是无用功,甚至是有害功——因为那个虚假的"已解决",让我错过了真正去找泄漏点的时机。所以现在,我给自己定下了一条近乎笨拙的纪律:在我动手"修改"任何一个东西之前,我必须先用一种不会骗我的方式,亲眼确认两件事。第一,我即将修改的这个对象,是不是问题真正所在的那个对象——对于进程的限制,这个"不会骗我"的地方,就是 /proc/<pid>/limits,它是那个进程此刻的、真实的、唯一的事实。第二,我即将做的这个修改,是在根治这个问题,还是仅仅在推迟它——一个会持续增长的曲线,调高它的天花板永远不是答案,我必须去找到那只让曲线不断上扬的、看不见的手。先看清,再动手;先验证对象,再验证疗效。这听起来很慢,可我这次用三天一次的线上事故、和两次徒劳的"修复"才换来的教训是:在一个错误的对象上、用一个错误的疗法,动手得再快,也只是在原地打转。
—— 别看了 · 2026