凌晨告警:服务日志突然开始刷
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。每个进程这类句柄应当很有限,如果成千上万,通常是无节制地注册
inotifywatch 把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 会继续涨到新的上限。
五、四类根因速查
- 连接未关闭 / 连接池无上限:IPv4 类句柄线性上涨,最常见。
- 文件流未关闭:REG 类句柄上涨,多出现在日志、证书、临时文件场景。
- 短连接风暴:下游抖动导致重试建连,fd 与
TIME_WAIT同时飙升。 - inotify / epoll 注册失控:anon_inode 类句柄异常膨胀,常见于文件同步与日志采集 agent。
六、避坑清单
- 别用
ulimit -n判断线上服务上限,/proc/<PID>/limits才是真值。 - 只改
ulimit不改 systemd unit,服务重启后依然撞墙;LimitNOFILE改完必须daemon-reload+ 重启。 - soft 和 hard 要一起留意,hard 只有 root 能调,且 soft 不能超过 hard。
- 只调大上限不修泄漏,是掩盖而非解决——问题只会延后爆发。
limits.conf对 systemd 服务不生效,容器里则由--ulimit/ 运行时决定。- 句柄上限和端口耗尽是两件事:
TIME_WAIT堆积导致的是本地端口不够(ip_local_port_range),别混为一谈。 - 应急调大的值要留档跟进,别让临时配置悄悄变成永久配置。
小结
排查顺序记成一句话:看 /proc/<PID>/limits 确认真上限 → ls /proc/<PID>/fd 数用量看趋势 → lsof 按类型与路径归类 → 改 systemd LimitNOFILE 止血 + 代码层关句柄治本。句柄问题从不凭空出现,它只是"某个资源没被归还"的信号;把增长源找到并修掉,才算真的解决。