佚名程序员头像
Node.js 线上问题 40 分钟:一套可直接照抄的排障与性能分析工作流封面图

Node.js 线上问题 40 分钟:一套可直接照抄的排障与性能分析工作流

Node.js 线上问题 40 分钟:一套可直接照抄的排障与性能分析工作流

1024 这天,朋友圈在过节,我的手机在响。告警内容是接口 P99 从 120ms 涨到了 900ms,CPU 占用稳定在 180%。

这种时候最耗时间的不是修,而是在十几个工具之间来回试。这篇文章把我自己踩出来的顺序写下来:先分诊,再上工具,最后收口成 checklist。目标只有一个——把「不知道从哪查」压缩成「三步之内锁定方向」。

一、先分诊,别急着开 DevTools

绝大多数人一上来就 --inspect 打断点,但如果方向错了,断点只会让你在正确的代码里看到正常的结果。

我的分诊表只有三行:

症状第一嫌疑第一个该跑的东西
CPU 持续偏高热循环 / 同步计算--inspect + CPU Profile
内存阶梯式上涨引用未释放 / 缓存无上限--inspect + Heap Snapshot
CPU 不高但响应慢事件循环被阻塞 / 下游 I/Oclinic 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 不高但全线变慢」的典型形态。

定位过程按分诊表走:

  1. node --inspect=0.0.0.0:9229 app.js,打开 chrome://inspect 连上去
  2. npx clinic doctor -- node app.js,复现一次请求,看 event loop delay 曲线
  3. 曲线在请求进入后同步抬升 → 确认是主线程阻塞,不是下游超时
  4. 回到 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

排查完一次就该沉淀一次。我现在的顺序固定是这五条:

  1. 看症状归类:CPU 高 / 内存涨 / RT 高,先定方向再开工具
  2. RT 高而 CPU 低,优先怀疑事件循环阻塞和下游超时,不碰 CPU Profile
  3. 同步文件与网络调用一律换成异步,并给并发加显式上限
  4. 内存上涨先用 --expose-gc + global.gc() 验证基线,再谈泄漏
  5. PM2 的 max_memory_restart 与 --max-old-space-size 保持安全差值

写在最后

1024 讨论「高效创作」,我觉得效率不来自更快的键盘,而来自更少的无效动作。

上面这套东西没有任何新技术,--inspect 十年前就有,clinic 也不是新工具。它的价值只在于顺序:把「我知道很多工具」变成「我知道什么时候不该用某个工具」。省下来的那四十分钟,本质上是少走了三次弯路。

如果你的线上也遇到过「CPU 不高但全线变慢」,大概率能在这五条里找到对应的那一行。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/u010100877/article/details/168967373

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--