你的代码有内存泄漏?我翻了5个案例帮你排查
Node.js 生产环境内存泄漏实战手册 · 2026年整理
线上服务跑了几天,内存从 200MB 蹭到 1.2GB,GC 疯狂触发,延迟开始飘——这种场景,只要是写 Node.js 后端服务的,多多少少都碰上过。内存泄漏不像业务 Bug 会立刻报错,它是个慢性病,等你发现的时候服务器已经开始频繁重启了。
我翻了自己和团队踩过的坑,总结出 5 个最常见的内存泄漏场景,每个都带真实代码和排查方法论。不整概念,直接上案例。
一、全局变量——最容易被忽视的那个漏勺
翻车现场
问题代码
// globalStore.js —— 每个请求都往里塞数据,永不释放 const globalCache = {}; function processUser(userId) { globalCache[userId] = loadUserDetail(userId); return globalCache[userId]; } // 定时任务每秒跑一次,用户ID是递增的 setInterval(() => { processUser(Date.now()); }, 1000);
泄漏原因
全局对象(global、globalThis、或模块顶层变量)在进程整个生命周期内都不会被垃圾回收。globalCache 这个对象每秒钟都在变大,直到进程 OOM 被杀。全局变量在 Node.js 里是个深坑——很多人习惯性地把大对象挂在 global 上,觉得反正"随手方便",结果就是慢性失血。
解决思路
- 用完即删:
delete globalCache[key] - 限制容量:使用
Map+ LRU 淘汰策略,超出上限就清除旧条目 - 或者直接不用全局变量,改用请求作用域或依赖注入
改造后——LRU 限制缓存大小:
const LRU = require('lru-cache');
const cache = new LRU({ max: 500, ttl: 1000 * 60 * 10 });
// 超出 500 条自动淘汰,最旧的数据被清除,不会无限膨胀
function processUser(userId) {
if (cache.has(userId)) return cache.get(userId);
const user = loadUserDetail(userId);
cache.set(userId, user);
return user;
}
二、闭包——藏着引用的定时炸弹
翻车现场
问题代码
// 一个看似无害的工厂函数 function createHandler(dbConnection) { // 闭包引用了 dbConnection,这个引用会跟着 handler 对象存活 return function handler(req, res) { dbConnection.query(sql, (err, rows) => { if (err) { console.error(err); return; } // 每次请求处理完,dbConnection 都被 handler 引用,无法释放 res.json(rows); }); }; } // 路由注册——每注册一次,旧的 handler 连同 dbConnection 一起泄漏 app.get('/users', createHandler(myDbConnection)); app.get('/orders', createHandler(myDbConnection));
泄漏原因
JavaScript 闭包会持有外部作用域所有变量的引用。createHandler 返回的函数持有了 dbConnection 的引用,只要这个函数存在(路由 handler 只要进程不重启就一直存在),dbConnection 就无法被 GC 回收。表面上是注册路由,实际上每次注册都在泄漏数据库连接。
另一个经典闭包泄漏场景:setTimeout 或 setInterval 引用了大对象,而忘记在合适的时机 clearInterval。
解决思路
- 闭包只捕获必要的东西,不要把整个大对象都包进去
- 清理时记得 clearInterval / clearTimeout
- 使用
async_hooks追踪对象生命周期,辅助排查
// 改进:只传必要的参数,不传整个连接对象
function createHandler(queryFn) {
return function handler(req, res) {
queryFn((err, rows) => {
if (err) { console.error(err); return; }
res.json(rows);
});
};
}
// 只传 query 方法,不传整个 dbConnection
app.get('/users', createHandler((cb) => myDbConnection.query(sql, cb)));
三、事件监听器——注册了就没管过
翻车现场
问题代码
// eventBus.js —— 每次连接都注册监听,连接关闭也不移除 const { EventEmitter } = require('events'); class UserSession extends EventEmitter {} function handleUserConnection(socket) { const session = new UserSession(); session.on('data', (chunk) => { // 注册监听器 processUserData(chunk); }); session.on('close', () => { // 关闭时没有移除监听器 cleanupUser(socket.id); }); // socket 关闭时,如果代码没手动解绑,session 上的监听器就泄漏了 socket.on('close', () => session.emit('close')); }
泄漏原因
Node.js 的 EventEmitter 是高频泄漏重灾区。每个 .on() 注册的监听器都会持有目标对象的引用。如果:
- 组件被"销毁"但忘了
.removeListener()或.removeAllListeners() - 短生命周期对象被长生命周期对象监听(如请求对象注册到全局事件总线)
- 在循环/批量创建对象时重复注册同一事件监听
一个请求进来注册 3 个监听器,1000 个请求就是 3000 个监听器堆积。更要命的是,每次"堆积"的时候 GC 根本不知道这些已经没用了——因为 EventEmitter 还在引用着。
解决思路
- 使用
{ once: true }选项注册一次性监听,用完自动移除 - 组件销毁时统一解绑:
session.removeAllListeners() - 开启
events.defaultMaxListeners警告,超过阈值立刻报警
// 改进:使用 once 自动移除,或者显式 removeListener
session.once('data', (chunk) => { // 只触发一次,自动解绑
processUserData(chunk);
});
// 或者在关闭时显式清理
socket.on('close', () => {
session.removeAllListeners();
cleanupUser(socket.id);
});
四、流未关闭——大文件场景的经典杀手
翻车现场
问题代码
// fileHandler.js —— 处理文件上传,处理完了流没关 const fs = require('fs'); function processUploadedFile(filePath, res) { const stream = fs.createReadStream(filePath); const chunks = []; stream.on('data', (chunk) => { chunks.push(chunk); }); stream.on('end', () => { // 正常流程:处理完了,但 stream 没 .destroy(),也没 .close() const content = Buffer.concat(chunks); parseAndSave(content); res.json({ success: true }); // 问题来了:如果 parseAndSave 里出异常,'end' 根本不会触发,流就永远开着 // 而且即使用户中断了连接,stream 也没有被 cancel,文件描述符泄漏 }); }
泄漏原因
Node.js 的 Stream 是出了名的"开了就要关"——fs.createReadStream、http.IncomingMessage、response.write 全都占用系统文件描述符。在 Linux 上,文件描述符是有限资源(默认 ulimit -n 是 1024),泄漏到上限就会报 EMFILE: too many open files。
而且流对象本身也会持有缓冲区(buffer),一个大文件进来如果流没关,内存会持续增长。最坑的是:流泄漏不会立刻体现在 V8 堆内存上,而是体现在系统文件描述符数量上,很容易误判。
解决思路
- 统一使用
stream.pipeline()(或老版本的pipe())处理流,它会在所有情况下自动清理 - 注册
stream.destroy()到 error/end/close 事件 - 用
AbortController统一管理请求取消时的流清理
// 改进:使用 pipeline 自动处理关闭和错误
const { pipeline } = require('stream/promises');
const { createWriteStream } = require('fs');
async function processUploadedFile(filePath, res) {
try {
const writeStream = createWriteStream('/tmp/processed_' + Date.now());
const readStream = createReadStream(filePath);
await pipeline(readStream, writeStream); // 无论正常还是异常,都会关闭流
res.json({ success: true });
} catch (err) {
// 出错时 pipeline 会自动销毁所有流,不会泄漏
console.error('Processing failed:', err);
res.status(500).json({ error: 'Processing failed' });
}
}
五、缓存累积——增长无上限的隐蔽黑洞
翻车现场
问题代码
// cacheManager.js —— 缓存只进不出,内存无限膨胀 const cacheMap = new Map(); function getCachedUser(userId) { if (!cacheMap.has(userId)) { const user = db.queryUser(userId); cacheMap.set(userId, user); // 每次查询都往里塞,从不清除 } return cacheMap.get(userId); } // 业务里每个用户的每个请求都会触发缓存 app.get('/user/:id/profile', (req, res) => { const user = getCachedUser(req.params.id); res.json(user); });
泄漏原因
这是生产环境里最常见的泄漏形式,没有之一。用 Map 或普通对象做缓存,只要没有淘汰机制,就一定会无限增长。用户量稍微大一点,几天之内内存就能从 100MB 涨到 1GB 以上。
而且这种缓存往往藏在业务深处:缓存层、数据服务层、第三方 SDK 的内部缓存……你可能根本不知道某个库在背后默默存了几十万条数据。某些 mysql2、ioredis 的连接池配置不当,也会造成类似问题。
解决思路
- 缓存必须有淘汰策略:LRU、TTL、或最大条目数限制
- 优先使用成熟的缓存方案:
lru-cache、node-cache、或直接上 Redis - 第三方 SDK 启用时一定要看默认配置,很多库默认缓存是无限的
// 改进:用 lru-cache 限制容量,设置过期时间
const LRU = require('lru-cache');
const cache = new LRU({
max: 1000, // 最多 1000 条,超出自动淘汰最旧的
ttl: 1000 * 60 * 30, // 30 分钟后过期,自动失效
updateAgeOnGet: true // 频繁访问的条目年龄不会增长,减少被淘汰概率
});
function getCachedUser(userId) {
if (cache.has(userId)) return cache.get(userId);
const user = db.queryUser(userId);
cache.set(userId, user);
return user;
}
5 种排查方法——从肉眼看到专业工具
1 观察进程内存读数
最简单也最直接。Node.js 进程内存包括 V8 堆内存和堆外内存两部分,用 process.memoryUsage() 持续监控:
setInterval(() => {
const { heapUsed, heapTotal, external } = process.memoryUsage();
console.log({
heapUsed: Math.round(heapUsed / 1024 / 1024) + 'MB',
heapTotal: Math.round(heapTotal / 1024 / 1024) + 'MB',
external: Math.round(external / 1024 / 1024) + 'MB'
});
}, 5000);
如果 heapUsed 持续增长且从不下降(或者下降幅度很小),基本可以确定有泄漏。
2 Chrome DevTools Heap Snapshot
最强大的可视化工具。启动时加 --inspect 参数,用 Chrome 链接上去:
# 启动服务 node --inspect server.js # Chrome 地址栏输入 chrome://inspect → Target → 打开 DevTools → Memory # 选择 "Allocation timeline" 或 "Heap snapshot" # 记录一段时间后,点击 "Generate snapshot"
然后在 snapshot 里用 Summary 视图按 Constructor 分组,查看保留内存最多的对象。重点关注:
- 对象数量是否持续增加
- 哪个 Constructor 数量最多(往往就是泄漏源)
- Object sizes 列表里靠前的都是重点嫌疑对象
3 clinic.js / 0x——自动定位热点的火焰图
不需要手动猜测,直接跑:
npm install -g clinic clinic doctor -- node server.js # 跑一段时间后 Ctrl+C,clinic 会生成报告,标注内存异常 # 或者用 bubbleprof,专注分析异步调用链路上的延迟 clinic bubbleprof -- node server.js
clinic 会自动分析并给出可视化报告,告诉你内存消耗的热点在哪里。
4 memwatch-next 追踪堆变化
const memwatch = require('memwatch-next');
// 监听内存泄漏事件
memwatch.on('leak', (info) => {
console.error('Memory leak detected:', info);
});
// 每次 GC 完成后打印堆差异
memwatch.on('stats', (stats) => {
console.log('GC stats:', stats);
});
memwatch-next 会追踪每次 GC 之后的堆增长情况,如果连续 5 次 GC 内存都在增长,它会触发 leak 事件。结合这个信号再去打 Heap Snapshot,可以精准定位是哪个对象在增长。
5 使用 --expose-gc + global.gc() 强制触发 GC
结合上面的监控手段,在怀疑有泄漏的地方手动触发 GC,观察内存是否回落:
# 启动时暴露 GC node --expose-gc server.js # 业务代码里 global.gc(); console.log(process.memoryUsage().heapUsed);
如果手动触发 GC 之后内存明显下降,说明泄漏比较轻微(只是该回收但没触发);如果手动 GC 之后内存几乎不变,说明对象正在被根引用锁住,必须从代码层面修。
① 截图基准 Snapshot → 触发疑似泄漏的操作(多倍请求)→ 再打第二个 Snapshot → 对比两个 Snapshot 里各类对象的数量差
② 在第二个 Snapshot 里按 Delta 排序,数量增长最多的 Constructor 就是泄漏元凶
③ 点击可疑对象,看 Retainer 链(保留引用链),逐层往上找到 GC Root,链的终点就是泄漏的根因
总结——内存泄漏的本质就这几条
翻完这 5 个案例,你会发现内存泄漏的本质从来不复杂,就是三条:
- 本该释放的东西被全局根(GC Root)引用着——全局变量、静态单例持有了不该持有的引用
- 本该解绑的监听器没解绑——EventEmitter 的 .on() 配 .removeListener(),setInterval 配 clearInterval
- 本该有上限的缓存没有上限——Map 无限增长,没有淘汰机制
解决办法也不难:养成习惯——每次注册监听器想好在哪解绑,每次用全局对象想好容量上限,每次开流想好怎么关。Node.js 本身提供的能力足够,只看你有没有意识到要用。
如果你在排查过程中需要更多工具——比如 HTTP 抓包、接口测试、代码优化建议——我整理了一个开发者工具站,收藏了一些实用工具:
常见问题
A: 这类工具一般有明确的输入框和输出框,按提示输入内容,点击对应按钮即可得到结果。建议先用简单示例测试功能是否正常,再处理实际数据。
A: 根据具体工具类型决定。格式转换工具适合处理第三方数据,编码工具适合加密传输,压缩工具适合文件上传前处理。多积累工具使用经验,遇到问题时能快速判断用哪个工具解决。
A: 不同工具有不同侧重,重点是理解原理。可以同时安装多个类似工具,实际使用中对比效果,选择最顺手的一个。随着使用经验增加,你也能判断工具的好坏。