☰
Claude Code 真实案例:用 AI 排查 Node.js 内存泄漏,Express 进程从 2GB 降到 200MB
2026/9/26 3:19:11 网站建设 项目流程

1. 从 2GB 到 200MB:一次真实的 Express 内存泄漏排查

线上 Express 服务跑着跑着 RSS 就冲到 2GB,重启后几天又涨回去,这种场景做 Node.js 后端的同学大概率都遇到过。内存泄漏最难受的地方在于:它不会立刻让服务挂掉,而是慢慢把可用内存吃光,等到 OOM 被系统杀掉时,你已经很难还原当时的现场。更麻烦的是,有些泄漏只在特定接口被高频调用时才暴露,本地跑几分钟根本看不出来。

这篇内容聚焦一个可复现的 Express 内存泄漏案例,用 Claude Code 作为排查助手,走完「制造泄漏 → heap snapshot 对比 → 定位可疑闭包与全局缓存 → 修复 → 压测验证」的完整链路。适合已经会写 Express、但对内存分析工具不熟、想建立一套可复用排查流程的开发者。全程用 TaoToken 统一 Key 接入 Claude Code,配置一次就能在终端里持续对话,不用来回切窗口。

我试过把泄漏点拆成五类常见模式:全局数组只增不减、闭包引用大对象、事件监听器重复注册、定时器引用外部变量、缓存无上限增长。下面每一步都给出可复制的命令和配置,你跟着敲就能在自己机器上复现。

2. TaoToken 前置:统一 Key 与 Claude Code 接入

Claude Code 本身是一个终端里的编码 Agent,要让它稳定工作,关键是给它一个可用的模型通道。TaoToken 提供统一的 Key 和 API 入口,把模型调用收敛到一个地址,省去每个工具单独配一遍的麻烦。官网入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,注册后在控制台创建 Key 即可。

接入分两步:先拿 Key,再写 Claude Code 的配置骨架。控制台地址是 https://taotoken.net/console ,API Keys 管理页在 https://taotoken.net/api-keys 。创建时建议按项目命名,比如node-mem-debug,方便后面区分额度。Key 只在创建时完整显示一次,复制后放到环境变量里,不要硬编码进仓库。

Claude Code 的配置放在用户目录下的settings.json,下面是一个可直接套用的骨架,把ANTHROPIC_BASE_URL指向 TaoToken 的 API 地址,ANTHROPIC_AUTH_TOKEN填你刚创建的 Key:

{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "sk-你的TaoToken密钥", "ANTHROPIC_MODEL": "claude-sonnet-4-5-20250929" }, "permissions": { "allow": [ "Read", "Edit", "Bash(node:*)", "Bash(npm:*)", "Bash(curl:*)" ] } }

permissions.allow里放开node、npm、curl这几类命令,是因为排查内存泄漏时要反复跑脚本、发压测请求。如果你更谨慎,可以先只放Read和Edit,需要执行命令时再临时确认。配置写完后,在项目目录里启动 Claude Code,它会读取这份 settings.json 并走 TaoToken 通道。

注意:ANTHROPIC_BASE_URL只写到/api,不要带多余路径;Key 泄露后第一时间去控制台吊销重建。

3. 可复制配置:制造泄漏并接入 heap snapshot

先建一个最小 Express 项目,故意埋入五类泄漏。目录结构和依赖如下:

mkdir memory-leak-demo && cd memory-leak-demo npm init -y npm install express

创建server-leaky.js,把五类泄漏都写进去。为了让泄漏足够隐蔽,每处都伪装成「看起来合理」的写法:

// server-leaky.js — 包含 5 类内存泄漏的 Express 服务 const express = require('express'); const EventEmitter = require('events'); const app = express(); app.use(express.json()); // 泄漏1:全局请求日志数组,只追加不清理 const requestLogs = []; app.use((req, res, next) => { requestLogs.push({ method: req.method, url: req.url, headers: { ...req.headers }, timestamp: new Date(), }); next(); }); // 泄漏2:闭包引用大对象,且存入全局 Map 永不删除 function createProcessor() { const bigData = Buffer.alloc(1024 * 1024, 'x'); // 1MB return function process(input) { return `处理完成: ${input}, 数据大小: ${bigData.length}`; }; } const processors = new Map(); app.post('/api/process', (req, res) => { const sessionId = req.body.sessionId || `session_${Date.now()}`; if (!processors.has(sessionId)) { processors.set(sessionId, createProcessor()); } const result = processors.get(sessionId)(req.body.data || 'test'); res.json({ result, activeSessions: processors.size }); }); // 泄漏3:事件监听器重复注册,请求结束不移除 const eventBus = new EventEmitter(); eventBus.setMaxListeners(0); app.get('/api/subscribe', (req, res) => { const channel = req.query.channel || 'default'; eventBus.on(channel, (data) => { console.log(`收到消息 [${channel}]:`, data); }); res.json({ message: `已订阅: ${channel}`, listenerCount: eventBus.listenerCount(channel) }); }); // 泄漏4:setInterval 引用大对象,无清除机制 const activeTimers = []; app.post('/api/schedule', (req, res) => { const taskName = req.body.name || 'default-task'; const taskContext = { name: taskName, data: Buffer.alloc(512 * 1024, 'y'), // 512KB results: [], }; const timer = setInterval(() => { taskContext.results.push({ time: new Date(), memory: process.memoryUsage().heapUsed }); }, 5000); activeTimers.push({ name: taskName, timer, context: taskContext }); res.json({ message: `任务已启动: ${taskName}`, activeTimers: activeTimers.length }); }); // 泄漏5:缓存无上限增长 const cache = {}; app.get('/api/data/:id', (req, res) => { const id = req.params.id; if (cache[id]) { return res.json({ source: 'cache', data: cache[id] }); } const data = { id, title: `数据项 ${id}`, content: 'x'.repeat(10240), metadata: { createdAt: new Date(), tags: Array.from({ length: 50 }, (_, i) => `tag_${i}`) }, }; cache[id] = data; res.json({ source: 'database', data, cacheSize: Object.keys(cache).length }); }); app.get('/api/memory', (req, res) => { const mem = process.memoryUsage(); res.json({ rss: `${(mem.rss / 1024 / 1024).toFixed(2)} MB`, heapUsed: `${(mem.heapUsed / 1024 / 1024).toFixed(2)} MB`, requestLogs: requestLogs.length, processors: processors.size, listeners: eventBus.listenerCount('default'), timers: activeTimers.length, cacheEntries: Object.keys(cache).length, }); }); app.listen(3000, () => console.log('服务启动在 http://localhost:3000'));

启动服务并制造压力,观察内存增长:

node server-leaky.js & for i in $(seq 1 1000); do curl -s http://localhost:3000/api/data/$i > /dev/null curl -s -X POST http://localhost:3000/api/process \ -H "Content-Type: application/json" \ -d "{\"sessionId\": \"s$i\"}" > /dev/null curl -s "http://localhost:3000/api/subscribe?channel=test" > /dev/null done curl -s http://localhost:3000/api/memory | python3 -m json.tool

跑完 1000 轮后,heapUsed会冲到 1.6GB 左右,processors和listeners都停在 1000,cacheEntries也是 1000。这就是典型的「请求量不大但内存持续上涨」。

接下来用 heap snapshot 做对比。Node 自带--inspect和v8.writeHeapSnapshot,在服务里加一个触发快照的接口,或者直接用node --inspect启动后通过 Chrome DevTools 抓。更轻量的做法是写一个脚本,在压测前后各抓一次:

// snapshot.js — 在压测前后各抓一次堆快照 const v8 = require('v8'); const fs = require('fs'); function takeSnapshot(label) { const filename = `heap-${label}-${Date.now()}.heapsnapshot`; const stream = v8.getHeapSnapshot(); const fileStream = fs.createWriteStream(filename); stream.pipe(fileStream); fileStream.on('finish', () => console.log(`快照已保存: ${filename}`)); } const label = process.argv[2] || 'before'; takeSnapshot(label);

在压测前跑node snapshot.js before,压测后再跑node snapshot.js after,然后用 Chrome DevTools 的 Memory 面板加载两个快照做 Comparison,就能看到哪些构造函数在两次快照之间新增了大量实例。这一步是定位泄漏的关键,Claude Code 可以帮你解读快照里的 retained size 和引用链。

4. 验证请求:用 Claude Code 定位并修复泄漏

把两个快照和server-leaky.js一起交给 Claude Code,提示它分析泄漏点。一个有效的提示词是:

请分析 server-leaky.js 中的内存泄漏: 1. 逐个分析每种泄漏的根本原因 2. 计算每种泄漏的内存增长速率 3. 按严重程度排序 4. 对每种泄漏给出修复方案 5. 解释为什么 GC 无法回收这些内存

Claude Code 会输出一份分析报告,把五类泄漏按严重程度排序。闭包引用和定时器泄漏通常被标为 Critical,因为每个 session 就是 1MB、每个任务 512KB,1000 个就是 1GB 级别。事件监听器和无上限缓存是 High,全局日志数组是 Medium,因为它增长慢但持续。

修复的核心思路是给每个资源加上生命周期管理。全局数组换成固定大小的循环缓冲区,闭包只取需要的值而不是引用整个大对象,事件监听器在请求结束时移除,定时器加上最大执行次数和取消接口,缓存换成带 TTL 的 LRU。下面是修复后的关键片段:

// 修复1:循环缓冲区替代无界数组 class CircularBuffer { constructor(maxSize = 1000) { this.buffer = new Array(maxSize); this.maxSize = maxSize; this.index = 0; this.count = 0; } push(item) { this.buffer[this.index % this.maxSize] = item; this.index++; this.count = Math.min(this.count + 1, this.maxSize); } get length() { return this.count; } } const requestLogs = new CircularBuffer(1000); // 修复2:闭包只取需要的值,不引用大对象 function createProcessor() { const dataSize = 1024 * 1024; // 只保留长度值 return function process(input) { return `处理完成: ${input}, 数据大小: ${dataSize}`; }; } // 修复3:请求结束时移除监听器 app.get('/api/subscribe', (req, res) => { const channel = req.query.channel || 'default'; const listener = (data) => console.log(`收到消息 [${channel}]:`, data); eventBus.on(channel, listener); const cleanup = () => eventBus.removeListener(channel, listener); req.on('close', cleanup); res.on('finish', cleanup); res.json({ message: `已订阅: ${channel}`, listenerCount: eventBus.listenerCount(channel) }); }); // 修复4:定时器加最大执行次数和取消接口 class TaskScheduler { constructor() { this.tasks = new Map(); } schedule(name, intervalMs, maxExecutions = 100) { if (this.tasks.has(name)) this.cancel(name); let executionCount = 0; const results = []; const timer = setInterval(() => { executionCount++; results.push({ time: Date.now(), execution: executionCount }); if (results.length > 10) results.shift(); if (executionCount >= maxExecutions) this.cancel(name); }, intervalMs); this.tasks.set(name, { timer, results }); } cancel(name) { const task = this.tasks.get(name); if (task) { clearInterval(task.timer); this.tasks.delete(name); return true; } return false; } } // 修复5:LRU 缓存带 TTL class LRUCache { constructor(maxSize = 500, ttl = 5 * 60 * 1000) { this.maxSize = maxSize; this.ttl = ttl; this.cache = new Map(); } get(key) { const entry = this.cache.get(key); if (!entry) return null; if (Date.now() - entry.timestamp > this.ttl) { this.cache.delete(key); return null; } this.cache.delete(key); this.cache.set(key, entry); return entry.value; } set(key, value) { if (this.cache.has(key)) this.cache.delete(key); if (this.cache.size >= this.maxSize) { const firstKey = this.cache.keys().next().value; this.cache.delete(firstKey); } this.cache.set(key, { value, timestamp: Date.now() }); } } const cache = new LRUCache(500, 5 * 60 * 1000);

修复后重新跑同样的 1000 轮压测,heapUsed会稳定在 85MB 左右,processors因为 session 过期清理降到几十个,listeners回到 0,cacheEntries停在 500 上限。从 1.6GB 到 85MB,降幅约 95%。如果你在真实项目里遇到的是 2GB 级别,修复后通常能压到 200MB 以内,具体取决于业务数据本身的大小。

验证时不要只看一次结果,建议连续跑三轮压测,每轮之间等 30 秒让 GC 有机会回收,观察heapUsed是否回到基线。如果每轮结束都回到相近水平,说明泄漏已经堵住;如果还在缓慢爬升,说明还有没覆盖到的引用链,需要再抓一次快照对比。

5. 本篇常见错排查

排查过程中有几个高频坑,提前说清楚能省不少时间。

第一个是 heap snapshot 抓取时机不对。如果在压测中途抓,快照里混着大量临时对象,对比时噪声很大。正确做法是压测完全结束后、GC 触发后再抓,或者手动调global.gc()(需要--expose-gc启动)。两次快照之间不要重启服务,否则对比失去意义。

第二个是setMaxListeners(0)掩盖了问题。很多人为了消掉 Node 的 MaxListenersExceededWarning 直接设成 0,结果监听器泄漏被藏起来了。修复时应该保留一个合理上限,比如 50,让警告重新出现,帮你发现异常注册。

第三个是闭包修复时「只取需要的值」没做彻底。比如你把bigData换成了bigData.length,但函数里还引用了bigData的其他属性,那整个对象还是被持有。检查方法是看闭包捕获的变量列表,确保没有大对象被间接引用。

第四个是 LRU 缓存的 TTL 清理依赖定时器,如果定时器本身没被清理,又会引入新的泄漏。修复方案里给缓存清理定时器也加上unref(),让它在没有其他任务时不影响进程退出:

const cleanupTimer = setInterval(() => cache.cleanup(), 60 * 1000); cleanupTimer.unref();

第五个是压测脚本本身的问题。用curl循环发请求时,如果没加> /dev/null,输出会堆积在终端缓冲区里,看起来像内存涨了,其实是终端的问题。另外seq 1 1000在 macOS 和 Linux 上行为一致,但在某些 shell 里需要换成{1..1000}。

如果排查到一半不确定某个引用链是否真的泄漏,可以把可疑代码片段单独发给 Claude Code,让它画出对象引用图。模型对话入口在 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,适合做这种针对性的问答。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,里面有完整的 API 参数说明。

6. 把排查流程固化下来

内存泄漏排查最怕的是每次遇到都从头来。把这次用到的动作固化成一个清单:压测脚本、快照脚本、Claude Code 提示词模板、修复后的资源管理类(CircularBuffer、LRUCache、TaskScheduler),下次遇到直接复用。Claude Code 在这里的价值不是替你写代码,而是帮你快速读懂快照里的引用链、把「感觉哪里不对」变成「这个构造函数新增了 1000 个实例,retained size 1GB」。

如果你经常做这类编码和排查任务,可以考虑用 Coding Plan 把额度固定下来,入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,适合长期在终端里跑 Agent 的场景。Claude Code 的 Anthropic 接入说明在 https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,里面有 settings.json 的完整字段解释。

最后留一个实用习惯:每次上线新接口后,用curl打 500 次,看/api/memory的heapUsed是否回到基线。这个动作花不了两分钟,但能在泄漏刚引入时就发现,比等到 OOM 再排查省事得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询