ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Claude Code 真实案例:用 AI 排查 Node.js 内存泄漏,Express 进程从 2GB 降到 200MB

Claude Code 真实案例:用 AI 排查 Node.js 内存泄漏,Express 进程从 2GB 降到 200MB 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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) }); }); // 泄漏4setInterval 引用大对象无清除机制 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?channeltest /dev/null done curl -s http://localhost:3000/api/memory | python3 -m json.tool跑完 1000 轮后heapUsed会冲到 1.6GB 左右processors和listeners都停在 1000cacheEntries也是 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、每个任务 512KB1000 个就是 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; } } // 修复5LRU 缓存带 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回到 0cacheEntries停在 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 适合做这种针对性的问答。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里面有完整的 API 参数说明。6. 把排查流程固化下来内存泄漏排查最怕的是每次遇到都从头来。把这次用到的动作固化成一个清单压测脚本、快照脚本、Claude Code 提示词模板、修复后的资源管理类CircularBuffer、LRUCache、TaskScheduler下次遇到直接复用。Claude Code 在这里的价值不是替你写代码而是帮你快速读懂快照里的引用链、把「感觉哪里不对」变成「这个构造函数新增了 1000 个实例retained size 1GB」。如果你经常做这类编码和排查任务可以考虑用 Coding Plan 把额度固定下来入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 适合长期在终端里跑 Agent 的场景。Claude Code 的 Anthropic 接入说明在 https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里面有 settings.json 的完整字段解释。最后留一个实用习惯每次上线新接口后用curl打 500 次看/api/memory的heapUsed是否回到基线。这个动作花不了两分钟但能在泄漏刚引入时就发现比等到 OOM 再排查省事得多。
返回列表