我们的 Node.js API 网关服务在上线后,得了个神奇的“周期病”。每隔差不多一周,容器就会因为内存超出限制被 Kubernetes 的 OOMKiller 无情拍死。监控大盘上的内存指标图非常规律:从启动开始,内存使用量像爬楼梯一样一格一格往上蹭,即使到了业务低峰期,物理内存也完全没有降下来的意思。这绝对是经典的内存泄漏(Memory Leak)。
为了抓出这个隐藏的“吸血鬼”,我们在测试环境下模拟了线上流量,并决定引入 heapdump 库,在运行时主动打印 Node 进程的堆内存快照。我们在管理后台加了一个临时的隐藏接口,专门用来在不同时间点触发快照保存,然后拉取到本地用 Chrome DevTools 的 Memory 面板进行对比分析。
下面是我们用于生成堆快照的 Express/NestJS 辅助代码,以及当时写出大坑的内存泄漏简化示例:
import * as express from 'express';
import * as heapdump from 'heapdump';
import * as path from 'path';
const app = express();
const port = 3000;
// 模拟的泄漏源:全局生命周期的事件监听器或者缓存对象
interface RequestLog {
id: string;
timestamp: number;
headers: Record<string, string>;
meta: () => string;
}
const requestCache: RequestLog[] = [];
// 这是一个极其隐蔽的闭包内存泄漏示例
app.use((req, res, next) => {
const requestInfo = {
id: req.headers['x-request-id'] as string || Math.random().toString(),
timestamp: Date.now(),
headers: req.headers as Record<string, string>,
};
// 错误的闭包使用:由于闭包持有了外部作用域的引用,导致 requestInfo 无法被回收
const getLogMeta = () => {
return `Request logic log metadata for ${requestInfo.id}`;
};
const logEntry: RequestLog = {
id: requestInfo.id,
timestamp: requestInfo.timestamp,
headers: requestInfo.headers,
meta: getLogMeta,
};
// 假设这里本来是为了做 100 条请求的滑动窗口统计,但因为写错逻辑导致无限增长
requestCache.push(logEntry);
if (requestCache.length > 5000) {
// 垃圾回收陷阱:即使清空前面的项,如果闭包还以某种奇怪方式挂在全局定时器或未销毁事件上,也会泄漏
requestCache.shift();
}
next();
});
// 暴露排查接口,线上通常用环境变量或内网鉴权限制此路由
app.get('/admin/heapdump', (req, res) => {
const filename = path.join(__dirname, `heap-${Date.now()}.heapsnapshot`);
heapdump.writeSnapshot(filename, (err, writePath) => {
if (err) {
return res.status(500).json({ error: err.message });
}
res.json({ message: 'Snapshot generated successfully', path: writePath });
});
});
app.get('/api/data', (req, res) => {
res.send({ status: 'ok', data: new Array(10000).fill('leak_payload') });
});
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
我们在压测前后分别请求了 /admin/heapdump,拿到了两个快照文件。把它们导入 Chrome DevTools 后进行 “Comparison”(对比)。在构造函数(Constructor)列表里,我们按 “Delta”(增量)排序,赫然发现 system / Context 以及 closure 的数量高得离谱。
进一步点开对象的 Retainer 引用树,发现几乎所有的泄漏节点都指向了 requestCache 这个数组,而每一个 logEntry 下面的 meta 闭包函数,又牢牢绑定了它创建时的上下文环境,导致 req 上的一些大对象被拽住,无法进入 V8 的垃圾回收轮次。
修起来其实挺直接,干脆把闭包换成纯粹的数据对象,或者用 WeakMap 来建立临时绑定。另外,一定要避免在全局作用域里乱挂没有解绑机制的 EventEmitter 监听。这次踩坑的教训是,任何看似方便的临时全局缓存,一定要有严格的淘汰与容量兜底机制,否则在高并发环境下,细微的内存泄漏积累起来就是致命的定时炸弹。
你们平时在 Node.js 中遇到这种由于垃圾回收不彻底导致内存缓慢上升的问题,都是用什么工具或者手段去精确定位泄露路径的?