Node.js服务的接口突然整体变慢,CPU不高、内存也平稳、日志里一句报错都没有,就是慢。这种基本可以往事件循环上想:要么主线程被一段同步代码占住了,要么libuv的线程池排满了队。两者的表现不一样,得分开查。
先把延迟量出来
perf_hooks里的monitorEventLoopDelay测的就是事件循环滞后,返回的单位是纳秒,除以1e6换成毫秒:
const { monitorEventLoopDelay } = require("node:perf_hooks");
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
console.log("p99:", (h.percentile(99) / 1e6).toFixed(2), "ms");
h.reset();
}, 5000);
参考区间大致是这样:p99在10以内算健康,10到50之间说明有同步代码在抢主线程,超过50响应时间已经被拖长,超过100基本就是事故级别。这段打印长期挂着也没什么开销,比事后猜强得多。
这里有个分诊点很关键:延迟高,说明主线程被同步计算占住了;延迟正常但接口就是慢,那多半不是主线程的事,而是libuv线程池排了队。实测下来这两种的处置方向完全相反,主线程阻塞要拆任务,线程池排队要加线程或者换API。方向搞反了,怎么调都没用。
主线程被占住的几种常见代码
1、JSON.parse大报文。网关把几MB的响应体一次性parse,一次十几毫秒,QPS一上来立刻堆积。改成分块处理或者流式解析。
2、正则的灾难性回溯。带嵌套量词的正则碰上特定输入,匹配时间能到秒级。之前见过一次,一段校验用的正则把服务打挂了,改成线性写法或者给匹配加长度上限。
3、同步的加密与压缩。crypto.pbkdf2Sync、zlib.gzipSync、bcrypt的同步版本,在请求线程里跑一次就是几十毫秒。换成异步版,异步版会交给线程池执行。
4、process.nextTick递归。nextTick队列在每个阶段切换之前就被清完,递归往里塞回调会把I/O全部饿死,延迟飙高的同时CPU也满。要让出主线程,用setImmediate分片而不是nextTick。
5、console.log打到重定向的文件。stdout指向管道时是异步的,指向文件时在Linux上是同步的,日志量大的时候单这一条就能拖垮接口。
抓到底是哪段代码
Node自带 --cpu-prof,跑一段时间生成 .cpuprofile文件,拖进Chrome DevTools的Performance面板就是火焰图:
node --cpu-prof --cpu-prof-dir=./prof server.js
粒度粗一点的用 --prof,开销更小,出结果要再跑一次 --prof-process解析:
node --prof server.js node --prof-process isolate-0x*.log > processed.txt
要定位到具体函数名,--cpu-prof输出的是Chrome能直接读的格式,更方便。Node.js 20.11.0、22.11.0、24.0.0、24.11.0,这几个版本两个开关都在。
libuv线程池默认只有4条
Node启动时会拉起一个固定大小的线程池,默认4条,靠环境变量改:
UV_THREADPOOL_SIZE=16 node server.js
这个值必须在进程启动之前设好。在JS里写process.env.UV_THREADPOOL_SIZE已经晚了,libuv早就初始化完了,改了也不生效。Docker里写进ENV,PM2里写进ecosystem.config.js的env段。
线程池上限1024,libuv 1.30.0、1.45.0、1.50.0,这几个版本都有过相关调整:1.30.0把上限从128提到1024,1.45.0把每条线程的栈固定成8MB,1.50.0给线程加了名字,在top里会显示成libuv-worker,排查时一眼能认出来。
走线程池的API就那么几类:文件系统的fs异步方法(fs.watch除外,它走内核通知)、dns.lookup与dns.lookupService、crypto的pbkdf2、scrypt、randomBytes、generateKeyPair的异步版、zlib的异步压缩解压。dns.resolve走的是c-ares,自己发DNS包,不占线程池。
这里有个容易被忽略的点:HTTP客户端做域名解析默认调用dns.lookup,所以每调一个新的外部域名都要占一条线程。遇到过一次调第三方接口突然变慢,把解析换成dns.resolve之后立刻恢复,根因就是线程池被域名解析占满了。
线程数不是越多越好
每条线程的栈是8MB,设成128光栈就要吃掉1GB虚拟内存。一般给到CPU核数就够,文件IO特别多的场景可以给到核数的2倍。
调完拿延迟直方图对照,别只看吞吐:吞吐涨了、延迟没变,说明加对了;吞吐没动、CPU先满了,说明瓶颈在计算,加线程没用,得用worker_threads或者把计算挪出进程。
把CPU密集的活搬出主线程
worker_threads给的是真线程,跑在里面的计算不占事件循环。别每个请求都new Worker,线程初始化要几十毫秒,按请求建线程反而更慢,用piscina这类池化方案复用线程。
一时改不完的同步循环,可以用setImmediate分片,把一批数据切成多次小批处理,中间让出主线程:
function runChunk(list, i, done) {
const end = Math.min(i + 500, list.length);
for (let k = i; k < end; k++) handle(list[k]);
if (end < list.length) setImmediate(() => runChunk(list, end, done));
else done();
}
这种写法在批处理脚本里够用,在线服务上还是worker_threads更稳。
别和内存问题混在一起
内存打满的表现是进程被杀退出,或者日志里直接抛JavaScript heap out of memory,容器里退出码是137;事件循环阻塞是进程活着、CPU可能并不高、单纯响应慢。看到慢先量延迟,别急着去调 --max-old-space-size,那是另一条路上的参数。
版本与默认值核实截至2026年10月,以Node.js官方发布页为准:24.0.0于2025年5月发布并在2025年10月进入长期支持,20.x已于2026年4月停止支持。