在 Node.js 服务线上运维过程中,内存泄漏是很头疼的问题之一。进程内存持续上涨、无规律 OOM 崩溃、服务越跑越卡,往往都是内存泄漏导致的。
老项目依赖的是memwatch-next第三方库,Node 24+ 已彻底不兼容 ,旧方案没法用了。功能需求简单,我懒得去找第三方库,手撸了一个。因为是基于 V8 原生 API 实现的,感觉应该可以兼容所有新版 Node.js ,支持定时检测、内存超标自动生成堆快照。
一、方案核心设计思路
需要实现的功能
-
手动生成内存快照:随时主动导出 V8 堆快照,用于针对性排查问题
-
全自动内存监控:定时轮询检测进程堆内存使用量
-
阈值自动触发快照:内存超出设定阈值时,自动保存快照文件,留存异常现场
核心依赖原生 API
-
process.memoryUsage():获取 Node 进程内存占用数据 -
v8.writeHeapSnapshot():V8 引擎原生生成堆快照(Chrome 可直接解析) -
setInterval:实现定时巡检,稳定替代 memwatch 监听机制
二、源码
复制即可直接使用,Node.js 24.15.0 已测
/**
* Created by wgc2k on 2026/06/02.
* Updated for Node 24 --- uses built-in v8.writeHeapSnapshot() instead of heapdump,
* and removes memwatch-next (not compatible with Node 24).
*/
var v8 = require('v8');
var fs = require('fs');
var path = require('path');
class MemoryLeakManager {
constructor() {
}
/**
* 写入内存快照到指定路径
* @param {string} dirPath 快照目录
*/
static writeMemory(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, {recursive: true});
}
var filePath = path.join(dirPath, Date.now() + '.heapsnapshot');
v8.writeHeapSnapshot(filePath);
console.log("Heap snapshot written to:", filePath);
}
/**
* 自动监测内存使用,当内存超过阈值时写入快照
* memwatch-next 在 Node 24 不再可用,改为定期检查内存使用
* @param {string} dirPath 快照目录
* @param {number} memoryThresholdMB 内存阈值(MB),默认 512MB
* @param {number} checkIntervalMs 检查间隔(毫秒),默认 30000ms
*/
static autoWatchMemory(dirPath, memoryThresholdMB, checkIntervalMs) {
if (!memoryThresholdMB) memoryThresholdMB = 512;
if (!checkIntervalMs) checkIntervalMs = 30000;
setInterval(function () {
var memUsage = process.memoryUsage();
var heapUsedMB = memUsage.heapUsed / 1024 / 1024;
if (heapUsedMB > memoryThresholdMB) {
console.warn("Memory threshold exceeded: " + heapUsedMB.toFixed(2) + "MB (threshold: " + memoryThresholdMB + "MB)");
MemoryLeakManager.writeMemory(dirPath);
}
}, checkIntervalMs);
}
}
module.exports = MemoryLeakManager;
三、实现解析
1. 为什么优先检测 heapUsed 而非 rss?
process.memoryUsage() 会返回四个核心参数,我们只关注 heapUsed:
-
heapUsed :V8 堆中 JS 代码实际占用的内存,是判断内存泄漏的唯一核心指标
-
rss:进程常驻内存,包含系统缓存、共享库、空闲内存,波动极大,无法作为判断依据
-
heapTotal、external:堆总内存、外部内存,仅作参考,不用于阈值判断
2. 时间戳命名快照的优势
使用 Date.now() 作为文件名,可保证每一次异常快照都是独立文件,不会覆盖历史数据。我们可以通过多个时间节点的快照对比,清晰观察内存持续上涨、对象堆积的泄漏特征。
3. 同步文件操作的合理性
代码中使用 existsSync、mkdirSync 同步方法,疑惑是否影响性能。
但考虑到目录校验仅在生成快照时执行一次,属于低频操作,对服务性能几乎无影响。相比异步写法,同步代码逻辑更简洁、无回调嵌套,更适合运维监控场景。
五、代码使用
1. 自动监控
开启全局内存监控,默认 30 秒检测一次,超过 512MB 自动生成快照:
const MemoryLeakManager = require('./MemoryLeakManager');
// 开启自动监控,快照存储在 ./memory-snapshot 目录 MemoryLeakManager.autoWatchMemory('./memory-snapshot');
2. 自定义阈值和检测频率
// 自定义:200MB阈值,每5秒检测一次
MemoryLeakManager.autoWatchMemory('./memory-snapshot', 200, 5000);
3. 手动生成快照
排查线上突发内存问题时,可手动调用生成快照,留存现场:
MemoryLeakManager.writeMemory('./memory-snapshot');
六、快照文件如何分析?
生成的 .heapsnapshot 文件是 V8 标准堆快照:
-
打开 Chrome 浏览器,打开开发者工具(F12)
-
切换至 Memory 面板
-
点击面板右上角 Load,导入快照文件
-
通过 Comparison(对比模式) 查看新增对象、未释放内存、堆积变量
通过多份时间节点的快照对比,可精准定位到未销毁的定时器、全局变量、闭包堆积、无效事件监听等泄漏源头。
六、单元测试
node18之后自己原生就支持单元测试,不需要使用mocha或者vitest
/**
* MemoryLeakManager 单元测试
* 使用 Node 内置的 node:test 和 node:assert(Node 18+,无需额外依赖)
*
* 运行方式: node --test test/memory_leak_manager.test.js
*/
'use strict';
const { describe, it, beforeEach, afterEach, mock } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const v8 = require('v8');
const MemoryLeakManager = require('../core/memory_leak_manager');
const TEST_TMP_DIR = path.join(os.tmpdir(), 'mj-memory-leak-test-' + Date.now());
// ─── 辅助函数 ────────────────────────────────────────────────────────────────
function cleanup() {
if (fs.existsSync(TEST_TMP_DIR)) {
fs.rmSync(TEST_TMP_DIR, { recursive: true, force: true });
}
}
/**
* 帮助创建 mock 的 process.memoryUsage 返回值
*/
function mockMemoryUsage(heapUsedMB) {
return {
heapUsed: heapUsedMB * 1024 * 1024,
heapTotal: (heapUsedMB + 100) * 1024 * 1024,
rss: (heapUsedMB + 200) * 1024 * 1024,
external: 5 * 1024 * 1024,
arrayBuffers: 2 * 1024 * 1024,
};
}
// ─── describe: writeMemory ────────────────────────────────────────────────────
describe('MemoryLeakManager.writeMemory()', function () {
beforeEach(function () {
cleanup();
});
afterEach(function () {
cleanup();
});
it('应该在指定目录创建堆快照文件', function () {
MemoryLeakManager.writeMemory(TEST_TMP_DIR);
const files = fs.readdirSync(TEST_TMP_DIR);
assert.ok(files.length > 0, '目录应包含至少一个文件');
const snapshotFile = files.find(f => f.endsWith('.heapsnapshot'));
assert.ok(snapshotFile, '应存在 .heapsnapshot 文件');
const filePath = path.join(TEST_TMP_DIR, snapshotFile);
const stat = fs.statSync(filePath);
assert.ok(stat.size > 0, '快照文件不应为空');
});
it('当目录不存在时应自动创建目录(包括多层嵌套)', function () {
const nestedDir = path.join(TEST_TMP_DIR, 'deep', 'nested', 'path');
MemoryLeakManager.writeMemory(nestedDir);
assert.ok(fs.existsSync(nestedDir), '嵌套目录应被创建');
const files = fs.readdirSync(nestedDir);
const snapshotFile = files.find(f => f.endsWith('.heapsnapshot'));
assert.ok(snapshotFile, '嵌套目录中应存在快照文件');
});
it('多次调用应生成多个不同的快照文件', function () {
MemoryLeakManager.writeMemory(TEST_TMP_DIR);
MemoryLeakManager.writeMemory(TEST_TMP_DIR);
MemoryLeakManager.writeMemory(TEST_TMP_DIR);
const snapshotFiles = fs.readdirSync(TEST_TMP_DIR)
.filter(f => f.endsWith('.heapsnapshot'));
assert.strictEqual(snapshotFiles.length, 3, '应有 3 个快照文件');
// 文件名基于 Date.now(),应互不相同
const baseNames = snapshotFiles.map(f => path.basename(f, '.heapsnapshot'));
const uniqueNames = new Set(baseNames);
assert.strictEqual(uniqueNames.size, 3, '每个快照文件名应不同');
});
it('生成的快照文件应是有效的 V8 堆快照(JSON 格式)', function () {
MemoryLeakManager.writeMemory(TEST_TMP_DIR);
const files = fs.readdirSync(TEST_TMP_DIR);
const snapshotFile = files.find(f => f.endsWith('.heapsnapshot'));
const content = fs.readFileSync(path.join(TEST_TMP_DIR, snapshotFile), 'utf-8');
// V8 堆快照是 JSON 格式
assert.doesNotThrow(function () {
JSON.parse(content);
}, '堆快照应可被解析为 JSON');
const parsed = JSON.parse(content);
assert.ok(parsed.snapshot, '快照 JSON 应包含 snapshot 字段');
});
it('当目录已存在时不应报错', function () {
fs.mkdirSync(TEST_TMP_DIR, { recursive: true });
assert.doesNotThrow(function () {
MemoryLeakManager.writeMemory(TEST_TMP_DIR);
}, '对已存在目录调用 writeMemory 不应抛出异常');
});
it('生成的完整快照文件应包含 node 和 edge 信息', function () {
MemoryLeakManager.writeMemory(TEST_TMP_DIR);
const files = fs.readdirSync(TEST_TMP_DIR);
const snapshotFile = files.find(f => f.endsWith('.heapsnapshot'));
const content = fs.readFileSync(path.join(TEST_TMP_DIR, snapshotFile), 'utf-8');
const parsed = JSON.parse(content);
assert.ok(parsed.nodes && parsed.nodes.length > 0, '应包含 nodes 数组');
assert.ok(parsed.edges && parsed.edges.length > 0, '应包含 edges 数组');
});
});
// ─── describe: autoWatchMemory ────────────────────────────────────────────────
describe('MemoryLeakManager.autoWatchMemory()', function () {
let intervals = [];
const TMP_DIR = path.join(TEST_TMP_DIR, 'auto');
let savedMemoryUsage;
let savedWriteHeapSnapshot;
/**
* 用可控的 setInterval 替换全局 setInterval
* 返回的 interval ID 会被收集,afterEach 中统一清理
*/
function installFakeTimers(t) {
intervals = [];
const realSetInterval = global.setInterval;
t.mock.method(global, 'setInterval', function (fn, delay, ...args) {
const id = realSetInterval(fn, delay, ...args);
intervals.push(id);
return id;
});
}
beforeEach(function (t) {
cleanup();
savedMemoryUsage = process.memoryUsage;
savedWriteHeapSnapshot = v8.writeHeapSnapshot;
installFakeTimers(t);
});
afterEach(function () {
// 清理所有通过 setInterval 创建的定时器
intervals.forEach(function (id) { clearInterval(id); });
intervals = [];
cleanup();
process.memoryUsage = savedMemoryUsage;
v8.writeHeapSnapshot = savedWriteHeapSnapshot;
});
it('当堆内存超过阈值时定时器回调应触发快照写入', function (t) {
// Mock: 内存超过阈值
process.memoryUsage = function () { return mockMemoryUsage(600); };
let snapshotCalled = false;
v8.writeHeapSnapshot = function (filePath) {
snapshotCalled = true;
return filePath;
};
MemoryLeakManager.autoWatchMemory(TMP_DIR, 512); // 600MB > 512MB 阈值
// 验证 setInterval 被调用(interval 已通过 fake timers 收集)
assert.ok(intervals.length >= 1, '应至少创建一个 interval');
});
it('当堆内存低于阈值时不应触发快照写入', function (t) {
process.memoryUsage = function () { return mockMemoryUsage(100); };
let snapshotCalled = false;
v8.writeHeapSnapshot = function () {
snapshotCalled = true;
};
MemoryLeakManager.autoWatchMemory(TMP_DIR, 512); // 100MB < 512MB 阈值
// 快照不应被触发 --- 内存低于阈值
assert.ok(!snapshotCalled, '低于阈值时不应立即触发快照');
});
it('当未传入阈值时应使用默认值 512MB', function (t) {
assert.doesNotThrow(function () {
MemoryLeakManager.autoWatchMemory(TMP_DIR);
}, '使用默认参数不应抛出异常');
});
it('自定义检查间隔应被接受和使用', function (t) {
assert.doesNotThrow(function () {
MemoryLeakManager.autoWatchMemory(TMP_DIR, 256, 15000); // 256MB, 15s
}, '使用自定义间隔不应抛出异常');
});
});
// ─── describe: 真实内存泄漏场景模拟 ──────────────────────────────────────────
describe('真实内存泄漏场景 --- 模拟内存增长触发快照', function () {
const TMP_DIR = path.join(TEST_TMP_DIR, 'leak-sim');
beforeEach(function () {
cleanup();
});
afterEach(function () {
cleanup();
});
it('分配大量对象后应能检测到内存增长', function () {
// 手动触发一次 GC(如果可用),获取更准确的基线
if (global.gc) { global.gc(); }
const before = process.memoryUsage().heapUsed;
// 模拟内存泄漏:持续分配对象并保持引用
const leakedObjects = [];
for (let i = 0; i < 50000; i++) {
leakedObjects.push({
id: i,
payload: 'x'.repeat(200),
nested: { a: i, b: 'y'.repeat(100) },
});
}
const after = process.memoryUsage().heapUsed;
const increaseMB = (after - before) / 1024 / 1024;
console.log(' 内存增长: ' + increaseMB.toFixed(2) + ' MB');
assert.ok(after > before, '分配大量对象后应导致内存增长');
// leakedObjects 保持引用,防止 GC 回收(验证内存泄漏检测场景)
assert.ok(leakedObjects.length === 50000,
'应有 50000 个泄漏对象');
});
it('内存持续增长时应能连续生成多个快照', function () {
// 写入第 1 个快照
MemoryLeakManager.writeMemory(TMP_DIR);
// 模拟分配更多内存
const moreData = [];
for (let i = 0; i < 100000; i++) {
moreData.push({ idx: i, val: 'data-' + i });
}
// 写入第 2 个快照
MemoryLeakManager.writeMemory(TMP_DIR);
const snapshotFiles = fs.readdirSync(TMP_DIR)
.filter(f => f.endsWith('.heapsnapshot'));
assert.ok(snapshotFiles.length >= 2, '至少应有 2 个快照文件');
// 第 2 个快照应比第 1 个大(反映了内存增长)
const size1 = fs.statSync(path.join(TMP_DIR, snapshotFiles[0])).size;
const size2 = fs.statSync(path.join(TMP_DIR, snapshotFiles[1])).size;
console.log(' 快照1: ' + (size1 / 1024).toFixed(1) + 'KB, 快照2: ' + (size2 / 1024).toFixed(1) + 'KB');
assert.ok(size2 > 0, '两个快照都应非空');
// 保持引用
assert.ok(moreData.length > 0);
});
});
// ─── describe: 快照文件合理性 ─────────────────────────────────────────────────
describe('快照文件合理性检查', function () {
const TMP_DIR = path.join(TEST_TMP_DIR, 'size-check');
beforeEach(function () {
cleanup();
});
afterEach(function () {
cleanup();
});
it('正常运行的堆快照文件应大于 100KB 且小于 500MB', function () {
MemoryLeakManager.writeMemory(TMP_DIR);
const files = fs.readdirSync(TMP_DIR);
const snapshotFile = files.find(f => f.endsWith('.heapsnapshot'));
const stat = fs.statSync(path.join(TMP_DIR, snapshotFile));
const sizeKB = stat.size / 1024;
console.log(' 快照大小: ' + (sizeKB / 1024).toFixed(2) + ' MB (' + sizeKB.toFixed(1) + ' KB)');
assert.ok(stat.size > 100 * 1024,
'快照文件应 > 100KB,实际: ' + sizeKB.toFixed(1) + 'KB');
assert.ok(stat.size < 500 * 1024 * 1024,
'快照文件应 < 500MB');
});
it('多次快照文件内容应不相同(因为堆状态在变化)', function () {
MemoryLeakManager.writeMemory(TMP_DIR);
// 分配一些新对象改变堆状态
const temp = [];
for (let i = 0; i < 10000; i++) {
temp.push({ x: i });
}
MemoryLeakManager.writeMemory(TMP_DIR);
const files = fs.readdirSync(TMP_DIR)
.filter(f => f.endsWith('.heapsnapshot'))
.sort();
assert.strictEqual(files.length, 2, '应有 2 个快照');
const size1 = fs.statSync(path.join(TMP_DIR, files[0])).size;
const size2 = fs.statSync(path.join(TMP_DIR, files[1])).size;
// 大小可能相同也可能不同(取决于堆变化),但两者都应有效
console.log(' 快照1: ' + size1 + ' bytes, 快照2: ' + size2 + ' bytes');
assert.ok(size1 > 0 && size2 > 0, '两个快照都应非空');
// 防止优化
assert.ok(temp.length > 0);
});
});
// ─── describe: 边界与异常情况 ─────────────────────────────────────────────────
describe('边界与异常情况', function () {
const TMP_DIR = path.join(TEST_TMP_DIR, 'edge');
let intervals = [];
let realSetInterval;
beforeEach(function () {
cleanup();
intervals = [];
// 拦截 setInterval 收集所有创建的定时器 ID,afterEach 统一清理
realSetInterval = global.setInterval;
global.setInterval = function (fn, delay, ...args) {
const id = realSetInterval(fn, delay, ...args);
intervals.push(id);
return id;
};
});
afterEach(function () {
// 清理所有定时器防止进程挂起
intervals.forEach(function (id) { clearInterval(id); });
intervals = [];
global.setInterval = realSetInterval;
cleanup();
});
it('autoWatchMemory 传入零阈值不应崩溃', function () {
assert.doesNotThrow(function () {
MemoryLeakManager.autoWatchMemory(TMP_DIR, 0);
}, '零阈值不应导致崩溃');
});
it('writeMemory 路径包含特殊字符应正常处理', function () {
// 使用合法的特殊字符路径
const specialDir = path.join(TEST_TMP_DIR, 'test_dir_2026_06_02');
assert.doesNotThrow(function () {
MemoryLeakManager.writeMemory(specialDir);
}, '包含下划线的路径不应抛出异常');
assert.ok(fs.existsSync(specialDir), '目录应被创建');
});
it('autoWatchMemory 极小间隔(1ms)不应崩溃', function () {
assert.doesNotThrow(function () {
MemoryLeakManager.autoWatchMemory(TMP_DIR, 1024, 1);
}, '极小间隔不应导致崩溃');
});
it('在高频调用 writeMemory 下不应丢文件', function () {
const COUNT = 10;
for (let i = 0; i < COUNT; i++) {
MemoryLeakManager.writeMemory(TMP_DIR);
}
const snapshotFiles = fs.readdirSync(TMP_DIR)
.filter(f => f.endsWith('.heapsnapshot'));
assert.strictEqual(snapshotFiles.length, COUNT,
'高频调用应生成 ' + COUNT + ' 个快照,实际: ' + snapshotFiles.length);
});
});
// ─── describe: MemoryLeakManager 实例化 ────────────────────────────────────────
describe('MemoryLeakManager 类本身', function () {
it('可以直接 new 实例化(无参数构造)', function () {
assert.doesNotThrow(function () {
const instance = new MemoryLeakManager();
assert.ok(instance instanceof MemoryLeakManager,
'实例应是 MemoryLeakManager 类型');
}, 'new MemoryLeakManager() 不应抛出异常');
});
it('writeMemory 和 autoWatchMemory 应是静态方法', function () {
assert.strictEqual(typeof MemoryLeakManager.writeMemory, 'function',
'writeMemory 应是 function');
assert.strictEqual(typeof MemoryLeakManager.autoWatchMemory, 'function',
'autoWatchMemory 应是 function');
});
});