在生产环境中,内存持续增长或 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 对象添加了监听器但从未移除,导致整个对象无法被回收。
- 定时器未清除:
setInterval或setTimeout持有闭包引用,定时器不清,对象就不释放。 - 流未正确关闭:文件读取流、数据库连接等使用完后没有关闭,底层资源持有了大量堆外内存(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 doctor 或 node-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 是否集中于主线程
用系统的 top 或 htop 查看 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. 优化建议
- 将大计算任务拆分:使用
setImmediate或process.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 线程,主线程只负责传递消息。 - 优化正则表达式:简化匹配模式,避免多重回溯;或改用字符串方法(
indexOf、split)替代。 - 避免同步 I/O:任何可能大的输入都用异步流处理。
三、实战排障组合方案
当线上告警打到你的接收端时,可以遵循以下检查清单:
- 快速看监控确认:是内存涨了还是 CPU 高了?是不是某个新版本发布后出现的?
- 登录服务器获取 firsthand 数据:
top -p <pid>看 CPU 和内存基本情况。- 通过 kill 发
SIGUSR1开启调试模式(如果应用支持),或直接进入--inspect端口查看。
- 内存类:生成两份快照对比,如果是生产环境,可使用
heapdump或有 Debug 端口临时接入 DevTools。 - CPU 类:如果 CPU 高,立即采集 10~30 秒的 CPU profile(
--inspect录制或0x生成)。保存文件供后续分析,然后必要时重启恢复服务。 - 事后复盘:将快照/profiling 文件和相关代码一起检查,定位根因,添加单元测试防止回归。
记住一点:这些工具都不是“一键定位”的魔法,需要理解内存引用和调用栈的基本原理。但在经过一两次实践后,你就能形成自己的排错直觉,甚至主动在业务代码中埋入自我保护逻辑(如内存超限主动重启并 dump 快照)。这些能力的积累,正是 Node.js 生产运维的底气所在。