人人都会AI编程

27.6 线上 CPU 飙高、内存溢出排查步骤

更新时间:2026-07-11

线上服务出现 CPU 持续打满或内存逐渐吃光最终被系统杀掉,是所有 Node.js 开发者迟早会面对的棘手场景。这类问题往往在生产环境突发,受限于不能随意重启、不能随意安装工具,需要一套快速定位并止损的标准化排查流程。本节将分别针对 CPU 飙高内存溢出 两个方向,给出实用、可操作的排查步骤与常见诱因。


一、CPU 飙高排查步骤

1. 现象确认与初步定位

  • 现象:用户反馈接口响应缓慢,监控平台触发 CPU 使用率 > 90% 报警。
  • 登录服务器,使用 tophtop 查看进程状态:
  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 doctorclinic 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):使用了带有嵌套量词的复杂正则,在特定输入下导致指数级回溯。

解决:重构正则,使用 re2regexp-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 替代。

  • 定时器未清除setIntervalsetTimeout 引用了外部变量,且定时器不被取消,导致这些变量常驻。

修复:在组件销毁或请求结束时调用 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 分钟变得高效且从容。