2024 年,我们一个网关服务上线后,头几个小时一切风平浪静。可随着流量慢慢爬上来,日志里开始零星冒出一行扎眼的报错:Too many open files。一开始一小时几条,我没太当回事,后来越来越密,最后变成大面积的连接建立失败——新请求进不来,服务等于半死。我的第一反应是"文件没关好",于是去翻代码里所有读写文件的地方,逐个确认 close 调了没有,翻了半天,一个泄漏都没找到。可报错还是 Too many open files。真正让我愣住的是,当我顺着这个 "files" 往下查,才发现压垮服务的那"太多文件",压根不是什么磁盘上的文件——而是几千个网络连接。一个服务明明没怎么碰磁盘文件,却被 "open files" 太多给压垮了,这件事说明,我对 Linux 里 "文件" 这个词的理解,从一开始就太狭隘了。这件事逼着我把 Linux 的文件描述符、ulimit 资源限制、systemd 下的 limit 配置这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一个 Java 网关服务,被 systemd 管着
事故现象:
- 上线初期正常,流量爬升后开始报 Too many open files
- 越来越密,最后大面积连接建立失败
- 查代码,文件读写处的 close 都调了,没找到泄漏
现场排查:
# 1. 看服务进程当前打开了多少"文件"
$ ls /proc/9001/fd | wc -l
1024 # ★ 正好卡在 1024
# 2. ★ 看这个进程的文件描述符限制
$ cat /proc/9001/limits | grep -i 'open files'
Max open files 1024 1024 files # ★ 软硬都是 1024
# 3. 看这 1024 个"文件"到底是什么
$ ls -l /proc/9001/fd | head
... 8 -> socket:[38211] # ★ 是 socket!
... 9 -> socket:[38215] # ★ 还是 socket!
... 10 -> socket:[38240] # ★ 全是网络连接
# ★ 占满 1024 的根本不是磁盘文件,是几千个网络连接
# 4. 我在 unit 文件里没配 limit
$ cat /etc/systemd/system/gateway.service
[Service]
ExecStart=/opt/gateway/run
# ★ 没有 LimitNOFILE —— 服务吃的是 systemd 给的默认值
根因(后来想清楚的):
1. ★ Linux 里"一切皆文件":磁盘文件是文件,
一个网络连接(socket)、一个管道,也都算"打开的文件"。
2. 网关服务要同时维持大量并发连接,每个连接
都占用一个【文件描述符(fd)】。
3. ★ 每个进程能同时打开的 fd 数量是有【上限】的,
默认常常是 1024。连接数一爬过 1024,
再开新连接(新 fd)就失败 -> Too many open files。
4. ★ 我以为限制的是"磁盘文件",拼命查文件泄漏 ——
方向全错。真正爆的是"连接数 vs fd 上限"。
5. ★ 而且服务是 systemd 起的:它【不读】
/etc/security/limits.conf,光改那个文件没用,
要在 unit 文件里配 LimitNOFILE。
"open files"包含 socket,fd 上限要按并发连接量来调。
修复 1:文件描述符是什么——为什么连接也算"文件"
# === ★ 先破除误解:Linux 里的"文件",远不止磁盘文件 ===
# "一切皆文件"是 Linux 的核心设计:进程要访问任何资源,
# 都通过一个统一的东西 —— 文件描述符(fd)。
# === 什么东西会占用一个 fd ===
# - 打开一个磁盘文件 -> 占 1 个 fd
# - ★ 建立一个网络连接(socket) -> 占 1 个 fd
# - 一个管道(pipe) -> 占 fd
# - 标准输入/输出/错误 -> 就是 fd 0 / 1 / 2
# ★ 所以一个高并发网络服务,fd 的大头【几乎全是 socket】。
# === ★ 直接看一个进程都开了哪些 fd ===
$ ls -l /proc/9001/fd
lrwx------ 0 -> /dev/null
lrwx------ 1 -> socket:[38000]
lrwx------ 8 -> /opt/gateway/logs/app.log # 这才是磁盘文件
lrwx------ 9 -> socket:[38211] # ★ 这是网络连接
lrwx------ 10 -> pipe:[38300] # 这是管道
# /proc/PID/fd/ 里每一个软链接 = 一个打开的 fd。
# === 数一数总共开了多少 ===
$ ls /proc/9001/fd | wc -l
1024
# === ★ 按类型拆开看,fd 都耗在哪了 ===
$ ls -l /proc/9001/fd | awk '{print $NF}' | \
sed 's/\[.*\]//' | sort | uniq -c | sort -rn
980 socket: # ★ 980 个是网络连接
30 /opt/gateway/... # 磁盘文件其实没几个
...
# ★ 这一拆,真相立刻清楚:压垮服务的是 socket,不是文件。
# === 一句话 ===
# "Too many open files" 里的 files,是【文件描述符】,
# 它把磁盘文件和网络连接算在【同一个】配额里。
修复 2:ulimit——看与改文件描述符上限
# === ★ 每个进程能开多少 fd,由 ulimit 的 "open files" 项决定 ===
# === 看当前 shell 的 fd 上限 ===
$ ulimit -n
1024 # ★ 默认常常就是 1024
# === ★ 软限制 vs 硬限制 —— 必须分清 ===
$ ulimit -Sn # 软限制(soft):当前实际生效的值
1024
$ ulimit -Hn # 硬限制(hard):软限制能调到的天花板
4096
# - 软限制:真正生效的限制,普通用户可【自己调高】,
# 但【不能超过】硬限制。
# - 硬限制:天花板,普通用户只能【调低】不能调高,
# 调高硬限制需要 root。
# === 临时调高(只对当前 shell 及其子进程有效)===
$ ulimit -n 65536
$ ulimit -n
65536
# ★ 这个改动【不持久】:关掉这个 shell 就没了。
# ★ 而且只能调到不超过硬限制;要更高得 root 先抬硬限制。
# === 看其它资源限制 ===
$ ulimit -a # 列出所有资源限制
# -n 打开文件数 -u 进程数 -c core 大小 -s 栈大小 ...
# === ★ 关键认知:ulimit 是"谁启动进程,进程就继承谁的 ulimit" ===
# 你在 shell 里 ulimit -n 65536,然后在这个 shell 里
# 启动的程序,才会继承 65536。
# ★ 如果程序是别的方式拉起来的(systemd / 别的会话),
# 它继承的是【那个启动者】的 ulimit —— 跟你这个 shell 无关。
# 这正是下一节那个大坑的根源。
修复 3:limits.conf 与 systemd——这次踩的真正的坑
# === ★ 想让 fd 上限【持久】,传统办法是 limits.conf ===
$ vi /etc/security/limits.conf
* soft nofile 65536
* hard nofile 65536
appuser soft nofile 65536 # 也可只对某用户
appuser hard nofile 65536
# 格式: 域(用户/*) soft|hard nofile 数值
# 改完【重新登录】才生效(它由登录时的 PAM 模块加载)。
# === ★ 但这次的大坑:systemd 起的服务,根本不读 limits.conf ===
# /etc/security/limits.conf 是给【登录会话】用的 ——
# 它靠 PAM 在用户【登录】时加载。
# ★ 而 systemd 启动一个服务,【不经过登录、不经过 PAM】,
# 所以 limits.conf 里写的 nofile,对它【完全无效】!
# 我当时就是改了 limits.conf,重启服务,毫无效果 ——
# 方向又错了。
# === ★ systemd 服务的 fd 上限,要在 unit 文件里配 ===
$ vi /etc/systemd/system/gateway.service
[Service]
ExecStart=/opt/gateway/run
LimitNOFILE=65536 # ★ 就是这一行!
$ systemctl daemon-reload # 改 unit 必须 reload
$ systemctl restart gateway
# === 也可以给所有 systemd 服务配一个默认值 ===
$ vi /etc/systemd/system.conf
DefaultLimitNOFILE=65536
# 改完要重启系统或 systemctl daemon-reexec 才全局生效。
# === ★ 改完一定要【验证生效】—— 别想当然 ===
$ systemctl restart gateway
$ cat /proc/$(pgrep -f gateway)/limits | grep 'open files'
Max open files 65536 65536 files # ★ 生效了!
# ★ 永远去 /proc/PID/limits 确认进程【实际】拿到的值,
# 而不是看你改了哪个配置文件。
# === 一句话总结这个坑 ===
# 登录会话(ssh/su 进去手动跑)-> limits.conf 生效
# systemd 服务 -> 只认 unit 里的 LimitNOFILE
# ★ 改对地方,比改对数值更重要。
修复 4:查进程实际打开了多少 fd——定位泄漏
# === ★ 调高上限是"扩容";但要先确认是真不够、还是在泄漏 ===
# === 看某进程当前开了多少 fd ===
$ ls /proc/9001/fd | wc -l
1024
# === ★ 用 lsof 看某进程开的 fd 明细 ===
$ lsof -p 9001 | head
COMMAND PID USER FD TYPE ... NAME
java 9001 app 8u REG ... /opt/gateway/logs/app.log
java 9001 app 9u IPv4 ... TCP 10.0.0.5:8080->10.0.0.9:51234 (ESTABLISHED)
# 看清楚每个 fd 是文件还是连接、连到哪。
# === 统计某进程开了多少 fd ===
$ lsof -p 9001 | wc -l
# === ★ 哪些进程开的 fd 最多(全局排查)===
$ for p in /proc/[0-9]*; do \
echo "$(ls $p/fd 2>/dev/null | wc -l) ${p##*/}"; \
done | sort -rn | head
9988 9001 # ★ PID 9001 开了 9988 个
88 1
...
# === ★ 区分"真不够"还是"泄漏"——看曲线 ===
# 反复采样某进程的 fd 数:
$ watch -n5 'ls /proc/9001/fd | wc -l'
# - fd 数随并发上下【波动、能回落】 -> 是真高并发,扩容上限
# - fd 数【只涨不降、一路爬高】 -> ★ 是泄漏!代码里
# 打开了 socket/文件却没 close,迟早撑爆,扩容只是拖延。
# === 系统级:看全系统 fd 使用 ===
$ cat /proc/sys/fs/file-nr
12832 0 2097152
# ① 已分配 ② 已分配但空闲 ③ 系统级上限
# ★ 系统级上限由 fs.file-max 控制(sysctl),也别忘了它。
$ sysctl fs.file-max
修复 5:fd 泄漏——开了不关,迟早撑爆
# === ★ 调高上限治标;如果是泄漏,不修代码迟早还会爆 ===
# === 泄漏的典型信号 ===
# 1. 进程 fd 数【单调上涨】,从不回落(上一节的曲线)。
# 2. lsof -p 里看到大量【同一个对端】或同一文件的 fd。
$ lsof -p 9001 | awk '{print $9}' | sort | uniq -c | sort -rn | head
3201 10.0.0.50:3306 # ★ 三千多个连到 MySQL 的连接!
# 一个正常程序不该开三千个数据库连接 -> 连接池没复用/没关。
# === ★ 泄漏的常见来源 ===
# - 打开文件/连接后,异常路径上漏了 close
# (Java 要用 try-with-resources;Python 用 with)
# - HTTP 客户端、数据库连接每次新建却不关、不放回池
# - 连接池配置错误,只借不还
# === 看 socket 状态分布,辅助判断 ===
$ ss -s
TCP: 9300 (estab 9100, closed 100, ...)
# ★ estab 数量大得离谱,且持续涨 -> 连接没正常关闭。
$ ss -tn state established | wc -l # 数已建立的连接
# === ★ CLOSE_WAIT 堆积:一种很典型的泄漏 ===
$ ss -tan state close-wait | wc -l
3150 # ★ 大量 CLOSE_WAIT
# CLOSE_WAIT = 对方关了连接,但【我方代码没调 close】。
# 大量 CLOSE_WAIT 堆积,几乎实锤是代码漏了关连接。
# === 应急 vs 根治 ===
# 应急:LimitNOFILE 调高 + 重启,先把服务救活。
# ★ 根治:顺着 lsof / CLOSE_WAIT 的线索回到代码,
# 把漏关的 socket/文件补上 close。
# ——上限再高,也扛不住一个只涨不降的泄漏。
修复 6:文件描述符排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ "Too many open files" 不只是磁盘文件 ===
# fd 包含 socket、管道。高并发服务的 fd 大头是网络连接。
$ ls -l /proc/PID/fd | grep -c socket # 看有多少是连接
# === 2. ★ 看进程实际的 fd 上限和用量,认准 /proc ===
$ cat /proc/PID/limits | grep 'open files' # 实际上限
$ ls /proc/PID/fd | wc -l # 实际用量
# === 3. ★ 改限制要改对地方 ===
# 登录会话 -> /etc/security/limits.conf(重新登录生效)
# systemd 服务 -> unit 文件 LimitNOFILE(daemon-reload)
# 系统总上限 -> sysctl fs.file-max
# === 4. ★ 改完必须验证进程拿到的值 ===
$ cat /proc/PID/limits | grep 'open files'
# 看配置文件不算数,看进程实际继承到的值才算数。
# === 5. 区分"真不够"和"泄漏":看 fd 曲线 ===
$ watch -n5 'ls /proc/PID/fd | wc -l'
# 波动能回落=扩容;只涨不降=泄漏,要修代码。
# === 6. CLOSE_WAIT 堆积 = 代码漏了 close ===
$ ss -tan state close-wait | wc -l
# === 7. 排查 fd 问题的命令链 ===
$ ls /proc/PID/fd | wc -l # ① 开了多少 fd
$ cat /proc/PID/limits | grep open # ② 上限是多少
$ lsof -p PID | awk '{print $9}' | sort | uniq -c | sort -rn # ③ 耗在哪
$ ss -s # ④ 连接状态分布
$ ss -tan state close-wait | wc -l # ⑤ 有没有泄漏迹象
# 按这个顺序,fd 问题基本能定位。
命令速查
需求 命令
=============================================================
看当前 shell 的 fd 上限 ulimit -n
看软/硬限制 ulimit -Sn / ulimit -Hn
看进程实际的 fd 上限 cat /proc/PID/limits | grep 'open files'
看进程开了多少 fd ls /proc/PID/fd | wc -l
看进程 fd 明细 lsof -p PID
找开 fd 最多的进程 遍历 /proc/*/fd 计数排序
看系统级 fd 使用 cat /proc/sys/fs/file-nr
看 socket 状态分布 ss -s
数 CLOSE_WAIT 连接 ss -tan state close-wait | wc -l
systemd 服务调 fd 上限 unit 文件加 LimitNOFILE=65536
口诀:open files 含 socket,高并发服务的 fd 大头是连接
systemd 服务不读 limits.conf,要在 unit 里配 LimitNOFILE
避坑清单
- Too many open files 里的 files 是文件描述符,包含 socket 和管道,不只是磁盘文件
- 高并发网络服务的 fd 大头是网络连接,排查方向别只盯磁盘文件
- ulimit 分软限制和硬限制,软限制是实际生效值,普通用户调不过硬限制
- ulimit 改动不持久,且进程继承的是启动者的 ulimit,不是你当前 shell 的
- systemd 启动的服务不读 /etc/security/limits.conf,必须在 unit 文件配 LimitNOFILE
- 改完限制一定去 /proc/PID/limits 验证进程实际拿到的值,别只看配置文件
- limits.conf 改完要重新登录才生效,它由登录时的 PAM 加载
- fd 数只涨不降是泄漏,波动能回落才是真高并发,看曲线区分
- 大量 CLOSE_WAIT 堆积几乎实锤代码漏了 close,扩容上限治标不治本
- 系统级 fd 总上限由 fs.file-max 控制,进程级上限再高也受它约束
总结
这次"网关服务被 Too many open files 压垮"的事故,纠正了我一个非常基础、却一直没真正想透的认知——Linux 里的"文件"这个词,到底指的是什么。在这次之前,我看到 "open files" 这两个词,脑子里浮现的画面是确凿无疑的:磁盘上的一个个文件,被程序用 open 打开。所以当报错说"打开的文件太多",我的全部排查动作,就顺理成章地锁死在一个方向上——去代码里翻所有读写磁盘文件的地方,一个个确认 close 有没有调。我翻得很认真,也确实没找到任何文件泄漏,可报错丝毫没有缓解。我卡在这里很久,卡的原因不是我不够仔细,而是我打从一开始,就在一个错误的地图上找路。复盘到根上,我才真正理解了"一切皆文件"这句被我背了无数遍、却从未真正消化的 Linux 设计哲学。它的意思,不是"很多东西长得像文件",而是说:在 Linux 看来,一个进程想访问的几乎所有资源——磁盘上的文件、一个网络连接、一根管道、甚至标准输入输出——都被统一抽象成了同一种东西,进程通过一个叫"文件描述符"的小整数去握住它。磁盘文件占一个文件描述符,而一个网络连接,一个 socket,同样、平等地占用一个文件描述符。Linux 不区分它们,它把它们扔进【同一个】配额里一起计数。而每一个进程,能同时握住的文件描述符数量,是有一个明确上限的——默认常常就是那个朴素的 1024。我的网关服务,本职工作就是同时维持成千上万个并发连接,每一个连接都是一个 socket,都实实在在地占着一个文件描述符。流量一爬升,并发连接数越过 1024 这道线,进程再想为新连接申请一个文件描述符时,系统就一口回绝——而它回绝时吐出的那句话,正是 Too many open files。压垮服务的"太多文件",从头到尾没有一个是磁盘文件,它们是几千个网络连接;我对着磁盘文件查了那么久,查的是一个根本不存在问题的地方。看清了这一层,后面那个 systemd 的坑也就顺理成章——我一度以为改了 /etc/security/limits.conf 就万事大吉,却忘了那个文件是给"登录会话"用的、靠登录时的 PAM 加载,而 systemd 拉起一个服务根本不走登录这条路,于是那份配置对它视而不见,真正该改的是 unit 文件里的 LimitNOFILE。这次从一个"文件没关好"的错误假设出发,我最大的收获,是把"文件"这个词在脑子里彻底地扩容了。当 Linux 说 "open files" 时,它说的从来不只是磁盘上那些有名有姓的文件,而是这个进程此刻正握在手里的一切资源句柄——文件、连接、管道,一视同仁。一个几乎不碰磁盘的网络服务,完全可能被 "open files" 太多给压垮,因为它握着的几千个连接,在 Linux 眼里,和几千个打开的文件,是同一回事。排查一个问题,最怕的不是不够努力,而是带着一张画错了的地图在拼命赶路;而这次,是 Too many open files 这句报错,亲手帮我把"文件"这块地图上的疆域,重新画对了。
—— 别看了 · 2026