线上服务出现 CPU 持续打满或内存逐渐吃光最终被系统杀掉,是所有 Node.js 开发者迟早会面对的棘手场景。这类问题往往在生产环境突发,受限于不能随意重启、不能随意安装工具,需要一套快速定位并止损的标准化排查流程。本节将分别针对 CPU 飙高 和 内存溢出 两个方向,给出实用、可操作的排查步骤与常见诱因。
一、CPU 飙高排查步骤
1. 现象确认与初步定位
- 现象:用户反馈接口响应缓慢,监控平台触发 CPU 使用率 > 90% 报警。
- 登录服务器,使用
top或htop查看进程状态:
top -p <pid>
观察该 Node 进程的 %CPU 值,如果稳定在 100%(单核打满)或更高(多核),则说明存在 CPU 密集代码阻塞了事件循环。
- 快速定性:如果多个进程(cluster)的 CPU 同时飙高,可能是共同处理了某个触发计算爆炸的请求;如果只是某一个进程高,可能是该进程部分逻辑进入死循环或执行长计算。
2. 抓取 CPU Profile 定位热点函数
在不重启服务的情况下生成火焰图或 CPU profile,是定位 CPU 热点函数最直接的手段。
方法一:使用 Node.js 内置 Inspector + Chrome DevTools
- 开启调试端口(对运行中的进程):
kill -SIGUSR1 <pid>
执行后进程不会重启,但会打开一个调试端口(默认 9229),并在日志中输出类似 Debugger listening on ws://127.0.0.1:9229/... 的信息。
- 建立 SSH 隧道(若服务器无公网端口):
ssh -L 9229:127.0.0.1:9229 user@your-server
- 打开 Chrome 浏览器,访问
chrome://inspect,配置127.0.0.1:9229后即可看到远端的 Node 进程。 - 进入 Profiler 面板,开始录制 CPU profile,等待 30 秒左右停止。
- 分析火焰图,找到
(self)占比最高的函数调用,即为热点。
方法二:使用 clinic doctor 或 clinic flame
- 在本地或预发环境复现问题时可直接使用,但线上环境由于性能开销通常不建议直接跑,可用于事后分析。
- 安装:
npm install -g clinic
- 运行:
clinic doctor -- node your-app.js
访问服务并施加负载后,clinic 会生成一个 HTML 报告,直观展示 CPU 瓶颈函数。
方法三:使用 0x 快速生成火焰图
- 同样适合在预发环境复现:
npm install -g 0x
0x -o app.js
请求结束后生成 flamegraph.html。
方法四:使用 v8-profiler-next 在代码中动态抓取(需提前安装)
- 在项目启动时引入
v8-profiler-next,并暴露一个内部接口来触发采集(线上慎重,需鉴权)。 - 代码示例:
const profiler = require('v8-profiler-next');
// 开始
profiler.startProfiling('CPU profile');
// ... 等待一段时间
const profile = profiler.stopProfiling();
profile.export((error, result) => {
fs.writeFileSync('cpu.cpuprofile', result);
profile.delete();
});
3. 常见 CPU 飙高诱因及应对
- 同步的大计算量操作:如大数组循环、大对象 JSON 序列化、大字符串处理。
解决:使用 worker_threads 将计算转移到工作线程;或利用 setImmediate/process.nextTick 将大任务切片,让出事件循环。
- 死循环或无限递归:条件判断错误导致 while 循环无法退出,或递归没有终止条件。
解决:直接修复代码逻辑,并在循环中加入监控,超过阈值抛异常。
- 灾难性正则表达式回溯(ReDoS):使用了带有嵌套量词的复杂正则,在特定输入下导致指数级回溯。
解决:重构正则,使用 re2 或 regexp-tree 这类安全引擎。
- 同步系统调用:误用了
fs.readFileSync等同步文件 API 在请求路径中。
解决:统一使用异步版本,或改用流式处理。
- 依赖包问题:某个第三方包内部进行了意外的大量计算。通过 profile 定位到具体包后,升级或替换该包。
二、内存溢出排查步骤
1. 现象与初步判断
- 表象:服务运行一段时间后重启(PM2 自动重启或 Docker 容器退出),系统日志显示
OOM killer终止了进程,或监控显示 RSS 内存持续线性增长直到上限。 - 快速查看进程内存:
ps aux | grep node
# 或
top -p <pid>
观察 RSS 列是否异常偏高(如 >1GB 且持续上升)。
- 在代码中内嵌内存监控日志:
setInterval(() => {
const mem = process.memoryUsage();
console.log(`RSS: ${Math.round(mem.rss / 1024 / 1024)}MB, HeapUsed: ${Math.round(mem.heapUsed / 1024 / 1024)}MB`);
}, 30000);
若 heapUsed 稳步上升而 rss 同步增长,大概率是 JavaScript 堆泄漏;若 rss 增长但 heapUsed 相对稳定,则可能是 Buffer 或 C++ 层面的外部内存泄漏。
2. 生成堆快照定位泄漏点
方法一:使用 heapdump 模块(推荐)
- 安装:
npm install heapdump - 在应用入口引入(无需配置):
require('heapdump');
- 该模块默认监听
SIGUSR2信号,可在进程运行时随时生成快照:
kill -USR2 <pid>
快照文件会生成在应用目录下,形如 heapdump-<时间戳>.heapsnapshot。
- 将
.heapsnapshot文件下载到本地,使用 Chrome DevTools 的 Memory 面板加载。 - 技巧:间隔一段时间(如 5 分钟)生成两个快照,加载后使用 Comparison 视图,查看两次快照之间新增的对象及其
Retained Size,即可定位到泄漏的源头。
方法二:使用 Node.js 内置 v8 模块编程抓取
- 适用于不方便安装额外包的场景:
const v8 = require('v8');
const fs = require('fs');
const snapshot = v8.getHeapSnapshot();
snapshot.pipe(fs.createWriteStream('heap.heapsnapshot'));
方法三:使用 clinic heap(本地复现)
- 类似 CPU 分析,在预发环境复现时使用:
clinic heap -- node app.js
3. 常见内存泄漏模式与修复
- 全局变量/闭包持有大对象引用:未使用
let/const意外创建的全局变量,或者长时间存在的闭包捕获了不再需要的大数据。
定位:快照对比中找到异常保留的大数组或对象,回溯其引用链。
- 事件监听器未移除:反复添加
EventEmitter监听器而没有removeListener,导致其引用的上下文无法释放。
修复:确保在适当生命周期移除监听,或使用 once 替代。
- 定时器未清除:
setInterval或setTimeout引用了外部变量,且定时器不被取消,导致这些变量常驻。
修复:在组件销毁或请求结束时调用 clearInterval / clearTimeout。
- 流未正确销毁:可读流、可写流未调用
destroy()或未解除对其的引用,导致底层缓冲区无法释放。
修复:始终在一个流的生命周期结束时调 stream.destroy() 或使用 pipeline 自动管理。
- 缓存无限增长:使用普通对象或
Map做本地缓存,未设置过期或容量限制。
修复:使用 lru-cache 等带淘汰策略的缓存库,或定期清理。
- Buffer 对象泄漏:堆外内存无法被 GC 直接回收,如果持续分配
Buffer而不释放引用,会导致 RSS 增大但 heap 变化不大。排查时注意网络连接、文件读写中Buffer的引用释放。
4. 兜底止损策略
- PM2 内存自动重启:在
ecosystem.config.js中配置max_memory_restart: '1G',当内存超过阈值时自动重启进程,防止 OOM 杀死影响整体服务。 - 利用负载均衡摘除问题节点:在 Nginx 或注册中心对异常实例进行摘流,给排查留出时间窗口。
- 快速回滚:无法即时定位时,立刻回滚到上一稳定版本,保证业务可用。
三、通用排查前置与预防
1. 构建可观测性基础
- 接入 APM 工具:如
easy-monitor、阿里Node.js 性能平台、Elastic APM等,可自动收集 CPU、内存、GC 等指标,并生成堆快照。 - 日志中记录关键指标:定期输出
process.memoryUsage()到日志,便于事后回溯内存增长趋势。 - 使用
--trace-gc参数:在启动时加上--trace-gc可让 Node.js 打印每次 GC 的耗时和回收情况,帮助判断 GC 压力。
2. 压力测试与 Code Review 防线
- 压测中观察内存曲线:预发环境进行流量复制或压测,通过火焰图和内存趋势及早发现潜在问题。
- Code Review 关注点:
- 是否有大数组/对象在请求作用域外被引用;
- 事件监听器是否成对出现;
- 定时器、流、数据库连接等资源是否正确释放;
- 是否误用同步文件 I/O 或大 JSON 操作;
- 引入的第三方包是否经过安全审核。
3. 利用 TypeScript 和 Lint 规则
- 使用 TypeScript 禁止意外全局变量,类型系统也能帮你抓住循环引用等问题。
- 配置 ESLint 规则,如
no-restricted-syntax禁止JSON.parse处理未知大小输入,或no-restricted-imports限制某些危险包。
当线上出现 CPU 飙高或内存溢出时,成熟的开发者不会慌张,而是按照上述步骤:先确认症状 → 抓取数据(CPU profile / heap snapshot) → 分析热点或持有关系 → 定位代码并修复 → 补充监控和预防措施。整个流程可能只需 20 分钟就能定位到根源,而提前积累的排查经验与工具准备,会让这 20 分钟变得高效且从容。