Node.js 线上问题 40 分钟:一套可直接照抄的排障与性能分析工作流
1024 这天,朋友圈在过节,我的手机在响。告警内容是接口 P99 从 120ms 涨到了 900ms,CPU 占用稳定在 180%。
这种时候最耗时间的不是修,而是在十几个工具之间来回试。这篇文章把我自己踩出来的顺序写下来:先分诊,再上工具,最后收口成 checklist。目标只有一个——把「不知道从哪查」压缩成「三步之内锁定方向」。
一、先分诊,别急着开 DevTools
绝大多数人一上来就 --inspect 打断点,但如果方向错了,断点只会让你在正确的代码里看到正常的结果。
我的分诊表只有三行:
| 症状 | 第一嫌疑 | 第一个该跑的东西 |
|---|---|---|
| CPU 持续偏高 | 热循环 / 同步计算 | --inspect + CPU Profile |
| 内存阶梯式上涨 | 引用未释放 / 缓存无上限 | --inspect + Heap Snapshot |
| CPU 不高但响应慢 | 事件循环被阻塞 / 下游 I/O | clinic doctor 看 event loop delay |
关键判断点是第三行:CPU 不高而 RT 高,说明你的进程在等,而不是在算。这时候去看 CPU Profile 是浪费时间的——火焰图会是平的。
二、主案例:一次同步 I/O 引发的雪崩
告警对应的接口是「扫描上传目录并返回超限文件列表」。这段代码是早期写的,逻辑很直白:
// 反例:循环里同步查文件信息
const list = [];
for (const file of files) {
const stat = fs.statSync(file);
if (stat.size > LIMIT) list.push(file);
}
return list;
单次调用没问题,但目录里文件数上来以后,fs.statSync 的每一次调用都会阻塞事件循环。并发一叠加,事件循环延迟被拉长,所有请求一起排队——这就是「CPU 不高但全线变慢」的典型形态。
定位过程按分诊表走:
node --inspect=0.0.0.0:9229 app.js,打开chrome://inspect连上去npx clinic doctor -- node app.js,复现一次请求,看 event loop delay 曲线- 曲线在请求进入后同步抬升 → 确认是主线程阻塞,不是下游超时
- 回到 inspector 抓 CPU Profile,
statSync出现在最热的栈顶
改法是把它交给线程池,异步并发 + 结果过滤:
// 正例:异步并发,交给 libuv 线程池
const list = (
await Promise.all(
files.map(async (file) => {
const stat = await fs.promises.stat(file);
return stat.size > LIMIT ? file : null;
})
)
).filter(Boolean);
return list;
改完之后 P99 回到 130ms 附近,事件循环延迟从峰值 800ms 降到 20ms 以内。
有个细节值得单独说:Promise.all 不是无代价的。文件数上万时它会把线程池排队打满,反而拖慢其他 I/O。正确做法是加上并发上限,比如一次 32 个,用 p-limit 或自己写一个简单的信号量。这是我在改完之后又回滚了一版才想明白的地方。
三、两个让我多花半小时的坑
坑一:以为是内存泄漏,其实堆还没回收。
内存曲线一直上涨就断定泄漏,是不成立的。V8 的堆分配有惰性,垃圾对象要等下一次 GC 才归还。正确的做法是主动触发一次回收再看基线:
node --expose-gc app.js
# 在 inspector 的 Console 里
global.gc();
gc() 之后如果内存回到基线,就不是泄漏,只是分配速率快于回收节奏——这时候该调的是 --max-old-space-size 和对象池,而不是翻代码找引用。
坑二:以为是 OOM,其实是 PM2 配得太保守。
进程反复重启,日志里没有 JavaScript heap out of memory,却被 PM2 拉起来——去翻 ecosystem.config.js,max_memory_restart 设成了 300M,而容器实际给了 1G。PM2 按配置杀进程,它不关心真相。
module.exports = {
apps: [{
name: "api",
script: "./app.js",
max_memory_restart: "800M",
node_args: "--max-old-space-size=640"
}]
};
两个值要错开:--max-old-space-size 给 V8 划边界,max_memory_restart 兜底重启,后者必须比前者大,否则 V8 还没到 GC 压力线就被 PM2 杀了。
四、收口成 checklist
排查完一次就该沉淀一次。我现在的顺序固定是这五条:
- 看症状归类:CPU 高 / 内存涨 / RT 高,先定方向再开工具
- RT 高而 CPU 低,优先怀疑事件循环阻塞和下游超时,不碰 CPU Profile
- 同步文件与网络调用一律换成异步,并给并发加显式上限
- 内存上涨先用
--expose-gc+global.gc()验证基线,再谈泄漏 - PM2 的
max_memory_restart与--max-old-space-size保持安全差值
写在最后
1024 讨论「高效创作」,我觉得效率不来自更快的键盘,而来自更少的无效动作。
上面这套东西没有任何新技术,--inspect 十年前就有,clinic 也不是新工具。它的价值只在于顺序:把「我知道很多工具」变成「我知道什么时候不该用某个工具」。省下来的那四十分钟,本质上是少走了三次弯路。
如果你的线上也遇到过「CPU 不高但全线变慢」,大概率能在这五条里找到对应的那一行。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/u010100877/article/details/168967373




