← 返回工具首页 你的代码有内存泄漏?我翻了5个案例帮你排查

你的代码有内存泄漏?我翻了5个案例帮你排查

Node.js 生产环境内存泄漏实战手册 · 2026年整理

Node.js 内存泄漏 性能优化 生产环境

线上服务跑了几天,内存从 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);

泄漏原因

全局对象(globalglobalThis、或模块顶层变量)在进程整个生命周期内都不会被垃圾回收。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 回收。表面上是注册路由,实际上每次注册都在泄漏数据库连接。

另一个经典闭包泄漏场景:setTimeoutsetInterval 引用了大对象,而忘记在合适的时机 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.createReadStreamhttp.IncomingMessageresponse.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 的内部缓存……你可能根本不知道某个库在背后默默存了几十万条数据。某些 mysql2ioredis 的连接池配置不当,也会造成类似问题。

解决思路

  • 缓存必须有淘汰策略:LRU、TTL、或最大条目数限制
  • 优先使用成熟的缓存方案:lru-cachenode-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 之后内存几乎不变,说明对象正在被根引用锁住,必须从代码层面修。

Memory Snapshot 分析实操技巧: 打 Heap Snapshot 前,先让服务跑一段时间(至少让泄漏积累出明显差异),然后:
① 截图基准 Snapshot → 触发疑似泄漏的操作(多倍请求)→ 再打第二个 Snapshot → 对比两个 Snapshot 里各类对象的数量差
② 在第二个 Snapshot 里按 Delta 排序,数量增长最多的 Constructor 就是泄漏元凶
③ 点击可疑对象,看 Retainer 链(保留引用链),逐层往上找到 GC Root,链的终点就是泄漏的根因

总结——内存泄漏的本质就这几条

翻完这 5 个案例,你会发现内存泄漏的本质从来不复杂,就是三条:

  1. 本该释放的东西被全局根(GC Root)引用着——全局变量、静态单例持有了不该持有的引用
  2. 本该解绑的监听器没解绑——EventEmitter 的 .on() 配 .removeListener(),setInterval 配 clearInterval
  3. 本该有上限的缓存没有上限——Map 无限增长,没有淘汰机制

解决办法也不难:养成习惯——每次注册监听器想好在哪解绑,每次用全局对象想好容量上限,每次开流想好怎么关。Node.js 本身提供的能力足够,只看你有没有意识到要用。

如果你在排查过程中需要更多工具——比如 HTTP 抓包、接口测试、代码优化建议——我整理了一个开发者工具站,收藏了一些实用工具:

☘️ CloverTools 开发者工具站

内存分析、接口调试、代码优化……工具齐全,即开即用

https://clovertools.cn/tools/developers/

💡 遇到同类问题?用工具快速解决

试试这些配套工具,无需注册,打开即用

浏览所有工具

常见问题

Q: 如何使用 nodejs内存泄漏排查5个案例 相关工具?
A: 这类工具一般有明确的输入框和输出框,按提示输入内容,点击对应按钮即可得到结果。建议先用简单示例测试功能是否正常,再处理实际数据。
Q: nodejs内存泄漏排查5个案例 适合在什么场景使用?
A: 根据具体工具类型决定。格式转换工具适合处理第三方数据,编码工具适合加密传输,压缩工具适合文件上传前处理。多积累工具使用经验,遇到问题时能快速判断用哪个工具解决。
Q: 有没有更好的替代工具?
A: 不同工具有不同侧重,重点是理解原理。可以同时安装多个类似工具,实际使用中对比效果,选择最顺手的一个。随着使用经验增加,你也能判断工具的好坏。