人人都会AI编程

内存泄漏、CPU 占用过高排查方法

更新时间:2026-07-11

在生产环境中,内存持续增长或 CPU 突然飙高是 Node.js 应用的常见故障。这类问题通常不会在开发阶段立即暴露,往往是在流量增加或长时间运行后才会触发。掌握下面这套排查路径,能够帮助你在发生这类问题时不至于手足无措。

一、内存泄漏:症状与排查方向

内存泄漏的典型表现是:进程占用的堆内存(heap used)随着时间推移不断增长,直到触发容器内存上限或系统 OOM Killer 杀掉进程。如果你在监控中看到内存曲线持续上扬而从不回落,哪怕低谷时仍比刚启动时高出很多,就基本可以断定存在泄漏。

1. 确认是否为内存泄漏

首先要区分正常的内存占用和泄漏。Node.js 的 V8 堆是有垃圾回收的,短期内内存增长后因 GC 而回落是正常的。真正的泄漏是指 GC 后内存仍不释放,堆尺寸无限逼近上限。

可以通过进程的内置方法快速查看内存状态:

console.log(process.memoryUsage());
// 输出类似 { rss: 24166400, heapTotal: 6537216, heapUsed: 4582456, external: 8272, arrayBuffers: 9898 }

如果每隔几秒调用一次,发现 heapUsed 持续增长,并且强制执行 global.gc()(需启动时带 --expose-gc 标志)后仍不下降,那就高度怀疑内存泄漏。

2. 常见泄漏场景

  • 全局缓存无限增长:用一个普通对象或 Map 作为缓存,从未清理过期项。每处理一次请求就放进去一点东西,内存只增不减。
  • 闭包引用未释放:回调函数中保留了本应被销毁的大对象引用,导致这些对象无法被 GC。
  • 事件监听器未销毁:EventEmitter 对象添加了监听器但从未移除,导致整个对象无法被回收。
  • 定时器未清除setIntervalsetTimeout 持有闭包引用,定时器不清,对象就不释放。
  • 流未正确关闭:文件读取流、数据库连接等使用完后没有关闭,底层资源持有了大量堆外内存(buffer)。

3. 分析工具与操作步骤

最常用的方案是生成堆快照(heap snapshot)对比增长差异。

① 使用 --inspect 与 Chrome DevTools

启动应用时添加 --inspect 参数,然后在 Chrome 中打开 chrome://inspect,连接到目标进程。在 Memory 标签页中,可以进行:

  • Take heap snapshot:在不同时间点抓取两份快照(例如刚启动后和运行一段时间后),然后在第二份快照中选择 “Comparison” 视图,按 Delta 排序,查看哪些类型的对象数量增长最多。通常顶端几个就是泄漏源。
  • Allocation instrumentation on timeline:记录一段时间内的内存分配,适用于定位因某次请求或定时任务引发的大量分配。
  • Allocation sampling:采样内存分配,CPU 开销较低,适合生产临时排查。

② 使用 heapdump 模块离线分析

如果无法直连生产应用,可以在代码中加入 heapdump 模块,通过 API 触发快照写入文件,然后本地用 DevTools 分析。

const heapdump = require('heapdump');
// 在需要时调用,例如检测到内存超过阈值
heapdump.writeSnapshot('/tmp/' + Date.now() + '.heapsnapshot');

随后将文件下载到本地,拖入 Chrome DevTools 的 Memory 面板即可进行分析。

③ 使用 clinic doctornode-memwatch

Clinic.js 是专门针对 Node.js 的诊断工具,clinic doctor 可以生成内存使用报告。node-memwatch 能监听泄漏事件并抛出统计信息,适合在测试环境中持续监控。

4. 修复思路

找到泄漏对象类名后,在代码中搜索该类的创建和引用链。常见解决方式:

  • 将缓存改为 LRU(例如使用 lru-cache 库)或主动设置 TTL。
  • 确保用完的流、连接调用 .destroy().close()
  • 在组件卸载、请求结束时移除事件监听器(emitter.removeListener 或明确使用 once)。
  • require.cache 时小心,避免动态加载模块无限增长。

二、CPU 占用过高:诊断与定位

CPU 过高通常表现为应用响应慢、事件循环延迟升高,甚至其它同主机的服务受影响。Node.js 单线程的特性决定了任何阻塞事件循环的操作都会放大延迟。

1. 判断 CPU 是否集中于主线程

用系统的 tophtop 查看 Node.js 进程的 CPU 使用率。如果单核心占用接近 100%(多线程池不在此列),说明主线程正被计算密集型任务或死循环占据。

同时检查事件循环延迟(lag):

const { eventLoopUtilization } = require('perf_hooks').performance;
// 可在定时器中比较差值

当延迟持续超过几十毫秒,就说明事件循环已严重阻塞。

2. 常见原因

  • 同步的大规模循环或递归:比如遍历含有百万条数据的数组,进行复杂转换。
  • 正则表达式灾难性回溯:不合理的正则表达式在特定输入下指数级耗时。
  • JSON 解析/序列化巨型对象:一次 JSON.parse 几 MB 也许还行,几十 MB 就会明显卡顿。
  • 无意中执行的同步 I/O:使用了 fs.readFileSync 等同步方法处理大文件。
  • 依赖库的内部循环:第三方模块的隐藏瓶颈,比如模板编译、加密库的同步调用。

3. 分析方法与工具

① 使用 Chrome DevTools 的 CPU Profiler

与内存排查类似,--inspect 启动后,切换到 Profiler 标签,开始录制 CPU profile。期间触发高负载的请求或操作,然后停止录制。火焰图会清晰展示调用栈的消耗时间,最宽的色块就是最可疑的函数。

如果发现很多时间花在某个匿名函数或三方库内部,可以通过鼠标点击在源码栏定位到具体代码行。

② 使用 node --prof 与火焰图

若没有 GUI 环境,可以利用 Node.js 内置的 V8 profiler 生成 CPU profile 文件,再处理为火焰图。

# 启动应用时加上 --prof
node --prof app.js

# 工作负载过后正常退出,会生成 isolate-XXXXXX-v8.log
# 使用 node --prof-process 转为可读文本
node --prof-process isolate-XXXXXX-v8.log > processed.txt

这个文本会按自底向上和自顶向下列出耗时最多的函数。但更直观的方式是生成火焰图。可以用 0x 工具(npm install -g 0x)一步到位:

0x app.js
# 启动后施加负载,退出时会自动生成火焰图 HTML

火焰图横轴表示函数调用栈合并后的占比,宽大头部的函数就是 CPU 热点,点击即可查看具体源码。

③ 使用 clinic flame

clinic flame 是 clinic 工具集的一员,它会自动启动应用、收集数据并生成可交互的火焰图报告,非常适合快速定位。

4. 优化建议

  • 将大计算任务拆分:使用 setImmediateprocess.nextTick 将一个长任务分片,让事件循环有机会处理 I/O。例如:
function processLargeArray(arr, batchSize, callback) {
  let index = 0;
  function nextBatch() {
    const end = Math.min(index + batchSize, arr.length);
    for (; index < end; index++) {
      // 对 arr[index] 做处理
    }
    if (index < arr.length) {
      setImmediate(nextBatch);
    } else {
      callback();
    }
  }
  nextBatch();
}
  • 使用 worker_threads 转移计算:把 CPU 密集任务整体迁移到 Worker 线程,主线程只负责传递消息。
  • 优化正则表达式:简化匹配模式,避免多重回溯;或改用字符串方法(indexOfsplit)替代。
  • 避免同步 I/O:任何可能大的输入都用异步流处理。

三、实战排障组合方案

当线上告警打到你的接收端时,可以遵循以下检查清单:

  1. 快速看监控确认:是内存涨了还是 CPU 高了?是不是某个新版本发布后出现的?
  2. 登录服务器获取 firsthand 数据
  • top -p <pid> 看 CPU 和内存基本情况。
  • 通过 kill 发 SIGUSR1 开启调试模式(如果应用支持),或直接进入 --inspect 端口查看。
  1. 内存类:生成两份快照对比,如果是生产环境,可使用 heapdump 或有 Debug 端口临时接入 DevTools。
  2. CPU 类:如果 CPU 高,立即采集 10~30 秒的 CPU profile(--inspect 录制或 0x 生成)。保存文件供后续分析,然后必要时重启恢复服务。
  3. 事后复盘:将快照/profiling 文件和相关代码一起检查,定位根因,添加单元测试防止回归。

记住一点:这些工具都不是“一键定位”的魔法,需要理解内存引用和调用栈的基本原理。但在经过一两次实践后,你就能形成自己的排错直觉,甚至主动在业务代码中埋入自我保护逻辑(如内存超限主动重启并 dump 快照)。这些能力的积累,正是 Node.js 生产运维的底气所在。