文件描述符耗尽、连接全挂?四步排查

20 阅读4分钟

凌晨告警:服务日志突然开始刷 Too many open files,新连接全被拒,accept failed 一行接一行。多数人第一反应是 ulimit -n 改大重启——但线上服务改了没反应,或者过两天又炸。这篇文章把"句柄(文件描述符)到底被谁吃光"这件事拆成四步,照着查就能定到根因。

一、第 1 步:先确认"上限到底是多少"

大多数人在这里就走错了:拿 ulimit -n 去看线上服务的上限。它只反映当前 shell 会话的限制,对已经运行的进程、对 systemd 托管的服务都无效。

进程真正的天花板要看它自己:

# 只对当前 shell 生效,别用它判断线上服务
ulimit -n

# 进程真实生效的上限(这才是它撞的那堵墙)
cat /proc/<PID>/limits | grep -i 'open files'

输出里的 Soft Limit 是实际生效值,Hard Limit 是软限能调整到的天花板。要往大调,soft 必须 ≤ hard,且改 hard 需要 root。

线上服务还有一种情况:limits.conf 里明明写了 65535,/proc/<PID>/limits 却还是 1024 —— 因为 systemd 服务不读 limits.conf,它只认 unit 里的 LimitNOFILE。

二、第 2 步:数清"已经用了多少"

有了上限,再看用量。最准也最快的方式是直接数 /proc 下的 fd 目录:

ls /proc/<PID>/fd | wc -l          # 已用句柄数(最准、最快)
lsof -p <PID> | wc -l              # 复核(会多出 cwd/txt/mem 等行)
watch -n 2 'ls /proc/<PID>/fd | wc -l'   # 观察是否持续上涨

关键动作是"看趋势",不是看单点值。 隔几秒采两次样:

  • 数值在某个水平来回波动 → 正常的高峰用量;
  • 数值只涨不落 → 基本可以判定是泄漏,只是还没撞到上限。

三、第 3 步:归类"都是些什么句柄"

知道谁在涨之后,接着看它长什么样。lsof 输出的第 5 列是句柄类型,按类型排序一目了然:

lsof -p <PID> | awk '{print $5}' | sort | uniq -c | sort -rn

三种典型结果对应完全不同的根因:

  • IPv4 / IPv6 类最多 → 网络连接堆积。连接池没上限、短连接用完没关、下游超时重试不停建连,都会让 fd 一路涨。
  • REG 类最多 → 打开的文件没关。看具体路径再收窄:lsof -p <PID> | grep REG | awk '{print $9}' | sort | uniq -c | sort -rn | head。如果是日志文件,先想想 logrotate 换了文件后进程有没有重新打开。
  • anon_inode 类异常多 → epoll / eventfd / inotify。每个进程这类句柄应当很有限,如果成千上万,通常是无节制地注册 inotify watch 把 fs.inotify.max_user_watches 撑爆了。

四、第 4 步:临时止血,再修根因

止血优先,但别把止血当治疗。 systemd 服务改上限的正规姿势:

# 临时:给服务提高上限(shell 里的 ulimit 对它无效)
systemctl edit myapp.service        # 写入 [Service] 段:LimitNOFILE=65535
systemctl daemon-reload && systemctl restart myapp

# 系统级上限
sysctl -w fs.file-max=1000000
echo '* soft nofile 65535' >> /etc/security/limits.conf

# 容器:由运行时决定,不继承宿主机 limits.conf
docker run --ulimit nofile=65535:65535 ...

调大之后一定要回头修根因:连接池加上限并配 maxIdle、代码里用 try-with-resources / with open 保证句柄归还、日志轮转后让进程重开文件、把 inotify 的 watch 数量降下来。否则只是把爆炸时间往后推,fd 会继续涨到新的上限。

五、四类根因速查

  1. 连接未关闭 / 连接池无上限:IPv4 类句柄线性上涨,最常见。
  2. 文件流未关闭:REG 类句柄上涨,多出现在日志、证书、临时文件场景。
  3. 短连接风暴:下游抖动导致重试建连,fd 与 TIME_WAIT 同时飙升。
  4. inotify / epoll 注册失控:anon_inode 类句柄异常膨胀,常见于文件同步与日志采集 agent。

六、避坑清单

  1. 别用 ulimit -n 判断线上服务上限,/proc/<PID>/limits 才是真值。
  2. 只改 ulimit 不改 systemd unit,服务重启后依然撞墙;LimitNOFILE 改完必须 daemon-reload + 重启。
  3. soft 和 hard 要一起留意,hard 只有 root 能调,且 soft 不能超过 hard。
  4. 只调大上限不修泄漏,是掩盖而非解决——问题只会延后爆发。
  5. limits.conf 对 systemd 服务不生效,容器里则由 --ulimit / 运行时决定。
  6. 句柄上限和端口耗尽是两件事:TIME_WAIT 堆积导致的是本地端口不够(ip_local_port_range),别混为一谈。
  7. 应急调大的值要留档跟进,别让临时配置悄悄变成永久配置。

小结

排查顺序记成一句话:看 /proc/<PID>/limits 确认真上限 → ls /proc/<PID>/fd 数用量看趋势 → lsof 按类型与路径归类 → 改 systemd LimitNOFILE 止血 + 代码层关句柄治本。句柄问题从不凭空出现,它只是"某个资源没被归还"的信号;把增长源找到并修掉,才算真的解决。