我上次帮朋友优化一个Node.js高并发聊天服务,压测到4000并发上下时,进程日志突然开始刷EMFILE,报错信息后面跟着一句too many open files。当时第一反应是业务代码有资源泄漏,把文件读写、数据库连接、消息转发链路从头到尾查了一遍,什么都没查出来。冷静下来才意识到,这是文件描述符配额撞顶了。一个小团队专业能力并不差,但还是被这个隐性问题卡了很久,说明这个坑确实容易被忽略。
Node.js给外界的印象是事件驱动、单线程、省内存,所以大家做高并发优化时,习惯性盯着事件循环、内存堆、垃圾回收、数据库连接池调来调去,却忘了最底层的一道门槛:操作系统允许这个进程打开多少个文件描述符(FD)。这道门槛不抬上去,前面一切优化都白搭。这篇文章把我踩过的坑、查过的资料、最后落地的配置方案完整写出来,如果你也在用Node.js做Web API、WebSocket长连接、消息推送这类高并发服务,建议认真读一遍。
1. 一次压测事故复盘:从排查代码Leak到锁定EMFILE配额
1.1 当时的诡异现象:CPU和内存都健康,新连接就是进不来
那是一个基于WebSocket的群消息服务,单机目标压到几千路长连接。压测脚本刚跑起来时一切正常,连接数稳步升到3000左右,突然开始大量报错。日志里全是类似这样的内容:
Error: EMFILE, too many open files at Server.setupListenHandle [as _listen2] (net.js:...)诡异的是,进程的CPU占用不到30%,内存也只用了1GB出头,事件循环延迟完全正常。按经验判断,这怎么都不像资源耗尽。可新连接就是进不来,已经建立的连接虽然能正常收发消息,但压测客户端一旦尝试建立更多连接,就直接失败。
这个时候最容易犯的错,就是把问题归结为"Node.js性能不行"或者"代码有Bug"。实际上,服务器本身还非常空闲,只是进程向操作系统申请新文件描述符的请求被拒绝了。这就好比餐厅里明明还有很多空位,但因为取号机分配的座位牌发完了,服务员就是不让你进门。
1.2 完整排查链路:我是怎么一步步定位到文件描述符的
整个排查过程花了一个下午,链路大概是这样的:
第一步,先看错误类型。EMFILE这三个字母很关键,它是内核返回给进程的错误码,含义就是"进程打开的文件描述符数量达到了上限"。能和它区分开的是ENFILE,那是系统全局的文件描述符总数满了,通常很少见。
第二步,按常规思路审代码。我把项目里所有涉及fs.open、createReadStream、createWriteStream、数据库连接创建的地方全部过了一遍,确认没有明显泄漏点。这一步本身没错,但花了不少冤枉时间。
第三步,打印Node.js进程内部的活跃句柄。在代码里临时加了一行process._getActiveHandles().length,观察了半天,发现业务层面的句柄数量并没有异常增长。
第四步,回头看操作系统的资源配额。这是整个排查的转折点:
# 查看进程当前打开的文件描述符数量 ls /proc/<PID>/fd | wc -l # 查看进程的完整限制 cat /proc/<PID>/limitsls /proc/<PID>/fd | wc -l的输出结果让我有点意外,数字稳定在1024附近,不再增长。再看/proc/<PID>/limits,里面赫然写着:
Max open files 1024 1024 files问题一下清楚了:进程的FD配额就是1024,压测到900多个连接的时候,剩下的配额已经被监听socket和libuv内部对象占完了,后面的新连接全部拿不到FD。
这里必须多说一句:libuv在accept新连接遇到EMFILE时,并不会直接让Node.js进程崩溃,它会把accept操作挂起,然后每隔1秒重试一次。在用户侧和压测工具看来,表现就是"新连接建立超时、大量失败",但进程本身还活着,已经建立的连接还能继续跑。这个设计很实用,但也让故障现象变得更加隐蔽。
1.3 为什么大家第一反应都是代码泄漏,而不是系统配额
这个坑之所以难排查,核心原因是大多数人对"文件描述符"的理解太窄了。总以为只有打开文件才占FD,可实际上网络socket、管道、epoll实例、信号处理的内部对象,全部都要占FD。
另一个原因是服务进程的运行方式。很多Node.js服务由systemd、PM2或容器托管,你在shell里执行ulimit -n改掉的限制,只对当前shell会话有效,服务进程根本不受影响。这就导致即使有人想到了去修改限制,改完发现进程依然报错,又陷入了"难道真的是代码泄漏"的困惑。
实话说,压测压力增大时,业务代码确实可能在某个边界分支上产生FD泄漏,这是这个坑最迷惑人的地方。但正确的排查顺序应该是:先查看进程当前的FD占用和配额,确认配额够用,再去逐行审计代码。而不是反过来,先把代码翻个底朝天。
2. 文件描述符的底层逻辑:高并发场景里那张被忽略的资源账单
2.1 用一张"图书馆座位牌"理解文件描述符
要理解文件描述符,最合适的类比是图书馆的座位牌。你去图书馆学习,进门时管理员发给你一个座位牌,上面写着座位号;你走的时候把牌子还回去,座位才能给下一个人用。
操作系统的文件描述符就是这个座位牌。进程每打开一个文件、每建立一个TCP连接、每创建一条管道,都要向内核申请一个整数编号,这个编号就是FD。内核把一个元数据表存放在进程内部,有多少个FD就代表这个进程同时"占用"了多少个内核对象。申请不到新的FD时,内核会返回EMFILE错误。
高并发场景里的连接本质上就是"网络文件",所以一条TCP连接对应一个FD,这是最核心的消耗来源。此外,Node.js的libuv还要占用若干固定的FD作为epoll实例、事件通知用的eventfd、信号处理用的signalfd等,这部分虽然数量很小,但一直存在。
2.2 Node.js进程里的FD账单都花在了哪里
我建议所有做Node.js高并发服务的人,都认真梳理一遍自己进程启动后立即占用的FD清单。常规项目里,消耗主要集中在下面几块:
| 类型 | 具体对象 | 数量特征 |
|---|---|---|
| 网络连接 | HTTP请求、WebSocket连接、TCP socket | 每个连接一个FD,占比最大 |
| 外部中间件 | MySQL连接池、Redis客户端 | 每条连接一个FD,连接池越大占得越多 |
| 文件操作 | 日志流、静态资源文件、临时文件 | 活跃句柄数量,需保证正常关闭 |
| 子进程与管道 | child_process产生的IPC管道和stdout/stderr管道 | 每个子进程可能占多个FD |
| libuv内部对象 | epoll实例、eventfd、signalfd | 固定几个,几十个以内 |
| 第三方库隐藏连接 | 某些监控SDK、内部健康检查连接 | 容易被忽略,但确实存在 |
举一个真实项目里的统计例子。一个典型的Node.js HTTP API服务,MySQL连接池80个连接,Redis连接池20个连接,监听socket 2个(IPv4和IPv6),libuv内部对象约10个。那么进程什么都没干,光启动就占掉112个FD左右。如果再叠加8000个并发TCP连接,总FD数就会超过8100。
默认的1024配额,根本撑不到1000并发就已经触顶。这不是Node.js不行,而是操作系统给的"座位牌"本来就不够多。
2.3 单线程不等于低资源占用,这是高并发优化的认知误区
很多人一听到Node.js单线程、事件驱动,就天然觉得它省资源,可以放开了挂连接。这个认知在高并发场景下是有偏差的。单线程真正省的是线程栈和CPU上下文切换的开销,但在"每连接一个FD"这件事上,Node.js没有任何优势,反而因为可以挂起成千上万个空闲连接,更容易快速耗尽FD。
举个例子,多线程模型里每个线程有独立栈,内存消耗是线性的,你可以直观地看到"线程数涨了,内存涨了"。而Node.js的事件循环可以同时挂几万条空闲WebSocket连接,CPU和内存都很平稳,FD却在悄悄上涨。常规监控面板上根本没有"进程打开FD数"这一项,所以等到报错的时候,很多人还以为是网络问题。
再补充一点:开启cluster模式也解决不了FD配额问题。每个worker都是一个独立Node.js进程,继承的是各自的FD限制。如果系统配额没放开,开16个worker只会让进程总数变多,每个worker的连接承载能力依然是1024上下,整体上限是能通过多进程叠加,但单个worker的倒金字塔瓶颈仍然存在,而且系统总配额会被快速吃满。
所以,文件描述符不是优化清单上的配角,它和事件循环、内存堆、数据库连接池一样,是必须提前规划的基础资源。
3. 把配额真正抬上去:shell、systemd、Docker 三种启动态下的改法
3.1 先看清楚进程现在的限制,别改了个寂寞
任何优化第一步都应该是确认现状。下面三个命令是排查FD问题的"三件套",建议先跑一遍:
# 查看当前shell会话的FD限制 ulimit -n # 查看某个运行中Node.js进程的实际限制 cat /proc/<PID>/limits # 查看该进程当前已经打开的FD数量 ls /proc/<PID>/fd | wc -l重点看/proc/<PID>/limits里的Max open files。这里面有soft limit和hard limit两项。soft limit是进程实际受约束的值,hard limit是内核允许你突破的上限。普通用户只能在hard limit范围内调高soft limit,只有root用户才能同时提高hard limit。
这引出一个常见的坑:你在shell里执行ulimit -n 65535,然后立刻启动node进程,这个node进程确实会继承新的限制。但如果你用systemd启动服务,或者通过容器启动,shell的ulimit设置根本没有传递路径,改了半天进程的限制还是1024。
3.2 直接命令行启动场景
如果服务是通过命令行手动启动的,可以在启动前先修改当前shell的限制:
ulimit -n 65535 node app.js也可以写成启动脚本,每次启动都自动设置:
#!/bin/bash ulimit -n 65535 exec node app.js要提醒一点:如果当前shell的hard limit本身只有1024,那么直接执行ulimit -n 65535会报cannot modify limit,需要先确认ulimit -Hn的输出值。root用户一般不受这个限制,普通用户则需要在limits.conf里或者通过系统服务配置来提升。
3.3 systemd托管场景:这是最容易被忽略的改法
现在很多Node.js服务部署在云服务器上,使用systemd管理。这种情况你就算把/etc/security/limits.conf改得天花乱坠,服务进程也可能完全不受影响。原因是:systemd管理服务时,并不读取登录会话的PAM配置,而是直接读取service文件里的LimitNOFILE。
正确做法是在service文件里显式声明:
[Service] LimitNOFILE=65535改完以后执行:
sudo systemctl daemon-reload sudo systemctl restart myapp然后重新cat /proc/<PID>/limits确认。只有看到Max open files变成65535,才说明这次改真生效了。
提示:很多从CentOS 6走过来的老手,习惯先改
/etc/security/limits.conf,然后发现服务进程的FD配额纹丝不动。这不是你不会改,而是systemd的服务单元默认不读那个文件,必须走LimitNOFILE这条路。
3.4 Docker容器场景:宿主机改了,容器里没变
容器里的Node.js进程配额和宿主机是隔离的。即使宿主机ulimit -n已经是65535,容器内部也可能停留在默认的1024。使用docker run时,要显式传入ulimit参数:
docker run --ulimit nofile=65535:65535 --name myapp node:20 node app.js如果使用docker-compose,在服务定义里加上ulimits:
services: myapp: image: node:20 command: node app.js ulimits: nofile: soft: 65535 hard: 65535这里有个细节:--ulimit nofile=65535:65535,冒号前面是soft,冒号后面是hard。在线上建议把两者设为同一个值,避免应用在运行中尝试调高限制时因为hard limit不够而出幺蛾子。
3.5 系统全局配额:fs.file-max什么时候才需要管
单进程的FD配额解决了,还有一道总闸是系统级的fs.file-max,它限制整个操作系统所有进程加起来的FD总数。用下面两个命令查看:
cat /proc/sys/fs/file-max cat /proc/sys/fs/file-nrfile-max是系统允许的最大FD总数,file-nr里的第一列是当前已分配的FD数。默认情况下,file-max往往已经设到几十万甚至上百万,多数业务不至于打到这个上限。但如果你决定把某个Node.js进程的FD配额设成100万,就必须检查file-max够不够,因为所有进程的消耗都要算到这个总账里。
临时调整可以用:
sudo sysctl -w fs.file-max=300000要永久生效就写进/etc/sysctl.conf。需要强调的是,不要把file-max当成可以无脑调大的参数。每个FD在内核里都对应一个struct file对象,TCP socket还会附带收发缓冲区内存。十万条socket连接占用的内核内存可能高达数GB,这会在极端场景下反过来压垮服务器。
3.6 怎么算一个适合自己的FD配额,给一个可复用的估算公式
网上很多文章让你直接改65535,这个数值对大多数场景够用,但最优做法还是按业务估算。我用的是下面这套思路:
预估配额 = (预期最大并发连接数 + 固定连接池和内部句柄数) × 安全系数比如目标支撑5万WebSocket并发,固定开销算上MySQL连接池80、Redis连接池20、libuv内部对象和监听socket 15左右,那么基础就是50115。再乘以1.5到2倍的安全系数,得到约75000到100000。这样进程的LimitNOFILE设置为100000,系统的file-max结合其他服务占用再留出余量,比如设到200000以上。
如果代码层做了HTTP keep-alive连接复用,出站请求不再频繁新建TCP,固定开销会更小,甚至可以按实际压测结果把配额往下调。配额不是越大越好,够用、有余量、可扩展,才是准确的目标。
4. 代码层省着用FD:同样的并发量把句柄占用打下来
4.1 出站HTTP请求的复用:别让每个请求都烧掉一个新socket
很多人把系统配额调高之后,就以为万事大吉了。但每次出站HTTP请求都新建TCP连接,会让FD消耗速度快得惊人。Node.js的默认http.globalAgent在不同版本行为不完全一致,最稳妥的做法是在代码里显式创建带keep-alive的连接管理器。
const http = require('http'); const keepAliveAgent = new http.Agent({ keepAlive: true, maxSockets: 100, maxFreeSockets: 10, keepAliveMsecs: 1000, }); // 以axios为例 const axios = require('axios'); const instance = axios.create({ httpAgent: keepAliveAgent, timeout: 3000, });这里maxSockets指的是同一个目标主机下最多复用的并发socket数。50到100个socket的Agent池,在高并发下已经能支撑几千QPS的简单接口转发,FD占用非常可控。maxFreeSockets控制空闲时保留的socket数量,避免空闲连接堆积。
我在实测里发现一个规律:不做复用时,一次大规模服务间调用风暴,可能瞬间开出2000个新连接,FD从几百跳到3000。做了Agent池复用以后,同样的调用量压在100个FD以内,区别就是这么大。
但要注意:maxSockets设得太小,请求会排队,接口延迟上升。我一般从50开始压测,观察p99和FD占用曲线,再逐步调大到100或200,找到一个吞吐量和FD消耗都满意的平衡点。
如果目标服务支持HTTP/2,还可以进一步考虑在同一个TCP连接上复用多个Stream,但这要求客户端和服务端都兼容HTTP/2,改造复杂度更高。HTTP短连接转长连接复用,是性价比最高的第一步。
4.2 文件操作:用流处理代替一次性读入,并保证异常分支关闭句柄
fs.readFile这种一次性接口,文件读完内部会关闭FD,本身不会泄漏。真正容易出问题的是并发场景:一个请求进来读一个大文件,还没读完,下一批请求又来了。如果不做并发控制,或者使用fs.createWriteStream时没有在异常分支调用destroy,FD就可能在短时间内被文件操作占满。
文件多、并发高的服务,建议一律走流式处理:
const fs = require('fs'); fs.createReadStream('/data/large.json') .on('data', (chunk) => { /* 业务处理 */ }) .on('error', (err) => { // 这里要触发清理,流本身会自动destroy关联fd console.error(err); });大文件用createReadStream分段处理而不是一次性读进内存,既能降低内存压力,也能避免多个大文件同时open导致的FD堆积。临时文件处理完后记得unlink,日志文件交给专业的日志轮转库去管理,这些都属于代码层省FD的基本功。
另外可以了解一下graceful-fs这个库,它在底层拦截fs模块的操作,遇到EMFILE错误时会做队列延迟和重试。它不能替代系统配额的上调,但适合文件操作偶发密集、不想频繁调整系统参数的场景。我对它的定位是"挫折缓解器"而不是"根治方案"。
4.3 数据库连接池和Redis连接必须显式封顶
Node.js的MySQL驱动默认连接池上限可能是几十甚至更高,Redis客户端在某些配置下也会无限制新建连接。很多团队把连接池上限设成"大点更安全",却不知道每条连接都占一个FD,连接池膨胀时,FD消耗会成倍增加。
建议在配置里显式写明上限。以mysql2/promise为例:
const mysql = require('mysql2/promise'); const pool = mysql.createPool({ host: 'mysql.internal', user: 'app', password: '***', database: 'demo', connectionLimit: 50, });把connectionLimit显式设置成50或者更符合业务预期的值,不要依赖驱动默认值。Redis客户端也建议固定连接数,避免每次业务操作都临时创建一个createClient。
这么做的另一个好处是:连接池数量固定后,进程的FD基数非常稳定,监控报警的阈值才好设定。否则连接数在300到3000之间跳动,你根本分不清是业务高峰还是资源泄漏。
4.4 几个非常隐蔽的FD泄漏场景
在代码层面最容易出现的FD泄漏,不是显式的fs.open,而是下面这几种不那么直观的情况:
- HTTP响应体没有读完就被丢弃。比如反向代理场景里,客户端断开了,但Node.js的
req和res对象还挂在事件循环上,对应socket没有及时关闭。 - WebSocket连接异常断开后,业务端没有监听
close事件做资源清理。 - 自己用
net.createConnection创建的socket,在error和end的事件回调里没有调用destroy。 - 第三方依赖内部创建的socket,失败重试次数拉满后,旧连接没有及时回收。
排查FD泄漏有个实用技巧:Node.js的process._getActiveHandles()可以看到当前保持活跃的句柄列表,包括socket和文件流。定期把它打印到日志,如果某个时间段内活跃句柄数量只增不减,大概率就是异常了。
setInterval(() => { console.log(`active handles: ${process._getActiveHandles().length}`); }, 60000);正常的连接池化和复用架构里,活跃句柄数量应当比较平稳,业务量增长时小幅度波动,回落后能降下来。如果它呈现持续单调上升的趋势,赶紧去查代码。
4.5 cluster多进程部署和容器环境下的配额规划
开了cluster模式以后,每个worker都是独立的进程,有独立的FD配额。比如启用12个worker,每个worker的LimitNOFILE是65535,那么这些进程理论上加起来最多消耗78万个FD,但系统file-max才是真正的总闸。在两三台8核机器上跑了大量worker时,要特别留心系统级配额,否则某个连接风暴就可能让整机所有进程一起触顶。
容器编排环境还有一层特殊性:通常你在Dockerfile里设置不了ulimit,必须在docker-compose或Kubernetes的Pod级别指定。K8s里需要用securityContext配合pods.spec.securityContext设置limits。这些配置经常被基础设施团队单独保管,应用开发同学容易忽略,导致本地压测没问题、一到容器环境就EMFILE。我见过不止一个项目是这样反复横跳的。
5. 验证调优:压测指标、FD探针与长期运行的坑
5.1 压测时该盯哪些指标:别只看QPS
FD问题有一个特点:不爆发时没有存在感,一爆发就是灾难。所以调优后的压测验证,至少要看下面几项指标:
- 每秒报错数。重点观察EMFILE是否在某个并发拐点重现。
- FD占用曲线。定期统计
/proc/<PID>/fd目录条数,记录下来画趋势图。 - 事件循环延迟。确认配额调高后,事件循环本身没有引入新的延迟。
- TCP连接状态。用
ss -s看系统连接数,和进程FD使用量放在一起对比。
压测工具选择上,普通HTTP接口我用autocannon,一条命令就能跑出并发表现:
npx autocannon -c 3000 -d 60 http://127.0.0.1:3000/api/pingWebSocket长连接压测我从自写脚本起步,简单记录连接成功数和失败数,重点看连接数逐步上升时,FD总数是否撞上新的上限。
5.2 编写一个轻量的Node.js FD探针
生产环境长期运行,必须知道FD使用率距离上限还有多远。Linux下最简单的方式是通过/proc/self/fd目录来看当前进程打开的文件描述符数量。写成一个探针脚本,定期输出到日志:
const fs = require('fs'); function getFdCount() { try { return fs.readdirSync('/proc/self/fd').length; } catch (err) { return -1; } } setInterval(() => { const used = getFdCount(); const limit = 65535; console.log(`[fd-monitor] used=${used}, limit=${limit}, usage=${(used / limit * 100).toFixed(2)}%`); }, 5000);这段脚本统计/proc/self/fd里的符号链接数,得到的就是进程已打开的FD数量。readdirSync本身也会占用一两个FD,所以数字会比实际略高一点,但趋势完全可信。在生产环境建议把它上报到Prometheus这类监控系统,设置FD使用率超过80%就告警,不要在报错爆发后才回头看日志。
5.3 优化前后的对比数据:用表格说话
为了给团队和领导一个直观说明,我整理了压测前后的对比数据。这张表来自一次SSE推送服务的调优项目,业务场景是单机支撑数千路浏览器长连接,同时向后端服务发请求取数据:
| 状态 | 进程FD上限 | 压测目标连接数 | 实际结果 |
|---|---|---|---|
| 优化前 | 1024 | 2000 | 900+连接时开始报EMFILE,曲线断崖 |
| 只调系统配额 | 65535 | 3000 | 3000连接稳定,FD峰值约3100 |
| 系统配额+HTTP Agent复用 | 65535 | 5000 | 连接稳定,FD峰值反而降到2500左右 |
| 系统配额+连接池封顶 | 65535 | 5000 | 出站连接平稳,峰值维持在2300附近 |
能看到一个关键规律:代码层做好连接复用后,并发连接数提升了,FD峰值反而下降了。这就是为什么我说FD优化不能只停留在系统参数,代码侧的"省着用"才是高阶技巧。
5.4 部署拓扑里的隐藏环节:Nginx和反向代理的FD配额
如果Node.js服务前面有一层Nginx反向代理,客户端请求会形成"客户端到Nginx再到Node"的两段TCP连接。你在这边把Node的FD配额调到65535,压测还是崩,那就要查Nginx自己的FD限制。
Nginx默认的worker_connections通常足够,但进程级FD配额同样受系统limit影响。需要在Nginx的配置里显式声明:
worker_rlimit_nofile 65535; events { worker_connections 20480; }这类问题为什么隐蔽?因为客户端连接打到Nginx后,Nginx转发给Node时自己先EMFILE了,错误日志写进Nginx的error.log,而你的监控面板只盯着Node进程。不把双方日志对照看,你永远以为是应用又出新Bug了。
5.5 关于长期稳定运行,我最后的几点实在经验
现在我做Node.js高并发项目,基础配置都会坚持"三层落地":systemd管进程就用LimitNOFILE,交互式启动就在启动脚本里ulimit -n,容器场景一定在compose或K8s里显式声明ulimits。每一层都做验证,不假定继承关系。
关于配额数值,我倾向于在压测结果基础上加50%到100%的余量,但绝不盲目开到百万级。每个FD都吃内核内存,socket连接更多,内存风险更大。百万FD的服务一旦出现连接风暴,光内核内存就能吃掉好几个GB,到时候就不是EMFILE,而是整机内存告警了。
还有一个容易被忽略的细节是版本边界。Node.js不同版本的全局Agent默认行为不一样,别再照着老文章设置maxSockets以为默认就是5。看官方文档,或者在代码里显式初始化Agent,不要依靠隐式行为,这是我在生产环境踩过坑之后花的最值的十分钟。