Node.js游戏服务器项目移植 3-手撸简单的内存泄露监控

在 Node.js 服务线上运维过程中,内存泄漏是很头疼的问题之一。进程内存持续上涨、无规律 OOM 崩溃、服务越跑越卡,往往都是内存泄漏导致的。

老项目依赖的是memwatch-next第三方库,Node 24+ 已彻底不兼容 ,旧方案没法用了。功能需求简单,我懒得去找第三方库,手撸了一个。因为是基于 V8 原生 API 实现的,感觉应该可以兼容所有新版 Node.js ,支持定时检测、内存超标自动生成堆快照。

一、方案核心设计思路

需要实现的功能

  1. 手动生成内存快照:随时主动导出 V8 堆快照,用于针对性排查问题

  2. 全自动内存监控:定时轮询检测进程堆内存使用量

  3. 阈值自动触发快照:内存超出设定阈值时,自动保存快照文件,留存异常现场

核心依赖原生 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. 同步文件操作的合理性

代码中使用 existsSyncmkdirSync 同步方法,疑惑是否影响性能。

但考虑到目录校验仅在生成快照时执行一次,属于低频操作,对服务性能几乎无影响。相比异步写法,同步代码逻辑更简洁、无回调嵌套,更适合运维监控场景。

五、代码使用

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 标准堆快照:

  1. 打开 Chrome 浏览器,打开开发者工具(F12)

  2. 切换至 Memory 面板

  3. 点击面板右上角 Load,导入快照文件

  4. 通过 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');
    });
});
相关推荐
tedcloud1238 小时前
DeepSeek-TUI部署教程:打造CLI AI助手环境
服务器·人工智能·word·excel·dreamweaver
无情的西瓜皮9 小时前
MCP协议实战:用Python从零搭建一个AI Agent工具服务器(保姆级教程)
服务器·人工智能·python·mcp
万能的知了10 小时前
服务器托管 vs 云主机 vs 裸金属:一个决策故事
运维·服务器·云计算
winlife_10 小时前
在 Unity 里用 AI 做游戏:funplay-unity-mcp 从安装到第一次让 AI 改场景
人工智能·游戏·unity·ai编程·claude·mcp
zhiSiBuYu051710 小时前
Claude-Code 新手极速上手指南
javascript·node.js
茫忙然12 小时前
U 盘搭建免驱 Linux 便携系统教程
linux·服务器
柒和远方13 小时前
每日一学V012: 从 Python 到 Node.js:一个 AI Native 开发者的 JavaScript 调用 LLM 实战
javascript·node.js·api
lihao lihao13 小时前
linux匿名管道
linux·运维·服务器
STDD14 小时前
Glances:跨平台系统资源监控,浏览器实时查看服务器状态
运维·服务器