内存泄漏是服务端程序最隐晦也最致命的隐患之一。与浏览器页面刷新就会释放内存不同,Node.js 应用通常需要长时间持续运行,任何细微的内存泄漏都会在数小时或数天后累积成严重的性能问题:响应变慢、CPU 飙升,最终导致进程 OOM 崩溃重启。本节将梳理最常见的泄漏场景,并给出一套可落地的排查流程。
6.4.1 常见内存泄漏场景
场景一:全局变量与常驻引用
很多开发者习惯在模块顶层直接定义变量作为缓存:
const cache = {};
app.get('/data/:id', async (req, res) => {
const { id } = req.params;
if (!cache[id]) {
cache[id] = await fetchData(id);
}
res.json(cache[id]);
});
这个 cache 对象只会增长,永远不会被回收。如果 id 数量无上限且没有过期淘汰机制,进程内存会随着请求持续增加,最终泄漏殆尽。更隐蔽的变体是把数据挂到 global 对象上,或在闭包中无意间保留了对外部大对象的引用,导致这些对象无法被 GC 识别为垃圾。
场景二:事件监听器未移除
Node.js 的 EventEmitter 是很多核心模块的基类。每添加一个监听器,事件发射器内部就会保留对回调函数的引用。如果监听器不再需要却没有被移除,对应的闭包及回调函数引用的外部变量都不会被释放。
经典例子:
const EventEmitter = require('events');
const emitter = new EventEmitter();
function setup() {
const largeData = Buffer.alloc(10 * 1024 * 1024); // 10MB
emitter.on('data', () => {
console.log(largeData.length);
});
}
setInterval(setup, 1000); // 每秒注册一个新监听器
每次 setup 都会创建一个新的 largeData 和一个新的监听器,但旧监听器从不移除。一分钟后,10 个监听器以及它们引用的 100MB+ 数据常驻内存。大量 WebSocket 连接或自定义事件系统如果忘记 removeListener 或在连接关闭时清理,问题基本相同。
场景三:定时器与未清理的异步任务
setInterval 和 setTimeout 返回的句柄如果不被 clearTimeout/clearInterval 清除,其回调以及回调闭包中的引用会一直存在,即使逻辑上已经不再需要执行。例如:
app.get('/start-task', (req, res) => {
const task = setInterval(() => {
// 执行某些检查
}, 1000);
// 忘记 clearInterval(task)
res.send('started');
});
每次请求都启动一个不会停止的定时器,随着请求增多,定时器链越拉越长,内存只升不降。类似地,未 cancel 的 Promise(虽然 Promise 本身不会阻止 GC,但如果内部引用了外部资源且一直 pending,仍可能导致泄漏)、未关闭的数据库连接或文件流,也属于同一类问题。
场景四:闭包中的意外引用
闭包是 JavaScript 的常见特性,但稍有不慎就会捕获了超出预期的作用域。V8 的垃圾回收会分析闭包实际引用了哪些变量,但如果闭包持有的是一个更外层的大对象,即便只访问其中的一个属性,整个对象都无法被回收。
function createHandler(bigData) {
return function(req, res) {
// 仅使用了 bigData 中的某个字段
res.end(bigData.smallField);
};
}
// bigData 可能是一个数 MB 的 JSON 对象
app.get('/data', createHandler(hugeObject));
这里 hugeObject 被闭包捕获,只要该路由存在,整个 hugeObject 就会常驻内存。通常的解决方法是只传递必要的最小数据,或者通过浅拷贝切断对大对象的引用链。
场景五:数据库连接池未关闭或内存缓存无限增长
数据库驱动如 mysql2、mongoose 创建的连接池,如果没有正确配置最大连接数或没有在程序退出时释放,可能导致连接占用的内存及相关缓冲区无法回收。此外,手写的内存缓存(如上面第一个场景)或者第三方 LRU 缓存没有设置最大容量,也是高频泄漏点。
场景六:失控的回调或流管道
使用流处理数据时,如果可读流产生的数据速率远大于可写流的处理速率,而又没有正确监听 drain 事件或使用 pipe,数据可能在内存中堆积。虽然背压机制能够缓解,但如果开发者手动调用 readable.read() 但不消费数据,缓冲区会不断增长,最终导致内存溢出。
6.4.2 排查思路与工具
当服务出现内存持续上涨、GC 频率变高、响应延迟增加等征兆时,可以按以下步骤进行定位。
第一步:确认是否真的泄漏
先通过操作系统的简单命令观察:
# 观察进程内存变化
ps aux | grep node
# 或使用 Node.js 内置的 process.memoryUsage()
node -e "setInterval(() => console.log(process.memoryUsage()), 1000)"
重点关注 heapUsed 的增长趋势。正常跑一段时间后,内存应该趋于稳定(GC 会周期性回收)。如果 heapUsed 持续单调递增,不随 GC 回落,基本可以确定为内存泄漏。
第二步:生成并分析 Heap Snapshot
使用 Chrome DevTools 或 Node.js 内置的 inspector 模块来抓取堆快照,对比分析:
- 启动应用时添加
--inspect参数:
node --inspect app.js
- 打开 Chrome 浏览器,访问
chrome://inspect,连接到目标进程。 - 在 Memory 标签页中,先拍一张快照作为基线(Snapshot 1)。
- 模拟一些请求或等待一段时间后,再拍第二张快照(Snapshot 2)。
- 将视图切换为 “Comparison”,按照 Delta(差值)排序,找出哪些对象数量或大小增长最多。
通常需要重点关注 Object、Array、Buffer、Closure 等类别。点击泄漏对象可以查看其引用链(Retainers),从而追踪到哪个变量或事件持有该对象,导致它无法被释放。
第三步:使用 heapdump 或 v8-profiler 进行线上采样
对生产环境难以直接用 DevTools 时,可以使用 heapdump 或 v8-profiler-next 模块,在程序运行中的某个时刻触发堆快照导出:
const heapdump = require('heapdump');
// 在怀疑泄漏的时机写入快照文件
heapdump.writeSnapshot(`./heap-${Date.now()}.heapsnapshot`);
生成的 .heapsnapshot 文件可以下载到本地,用 Chrome DevTools 的 Memory 面板加载分析,步骤同上。
第四步:采样 CPU 与内存分配时间线
如果难以从快照直接定位,可以录制 Allocation Timeline(内存分配时间线)来观察内存分配行为:
- 在 DevTools 中选择 “Record Allocation Timeline” 开始录制。
- 触发业务请求,观察内存分配的变化曲线和分配栈,找出短时间内分配大量对象且未释放的代码路径。
第五步:通过 clinic.js 或 0x 等专业工具定位
clinic.js 套件中的 clinic doctor 可以自动分析 CPU、内存和事件循环延迟,给出诊断报告和可疑代码位置。0x 可以生成火焰图,直观展示哪些函数占用了大量内存分配。
第六步:检查事件监听器和定时器的数量
可以通过 Node.js 的 process._getActiveHandles() 和 process._getActiveRequests()(非文档化的内部 API,谨慎使用)查看当前活跃的句柄和请求。如果发现大量 Timer 或 Socket 句柄,很可能就是未清理的定时器或未关闭的连接。
第七步:代码审查与实践原则
排查到最后,修复措施往往回归到良好的编码习惯:
- 对缓存设置 过期策略和最大容量限制,优先使用成熟的 LRU 缓存库而非自定义对象。
- 绑定事件监听后,在不再需要时调用
removeListener或off,或使用once只监听一次。 - 确保每个
setInterval都有对应的clearInterval,每个数据库连接或文件流都在使用完毕后调用close或end。 - 闭包中避免捕获大对象,必要时在外层将大对象置为
null断开引用。 - 控制并发和流处理速度,充分利用背压机制。
内存泄漏的排查是一项需要耐心和经验的工作,但遵循上述步骤,绝大多数泄漏都可以在几小时内定位并修复,避免线上隐患累积。结合 CI 环节中的内存回归测试(如模拟持续请求后检查 heapUsed 是否回落到基线附近),可以将泄漏扼杀在发布之前。