一次线上OOM事故
2024年3月,我负责的一个Node.js 20.11服务在压测到5000 QPS时突然OOM,进程被系统kill。查看日志发现内存从200MB飙到1.8GB,然后崩溃。这个服务是一个实时数据聚合层,每秒钟处理约2000个WebSocket消息,每个消息会创建临时对象。
排查后发现罪魁祸首是一个全局Map缓存:
// 问题代码
const cache = new Map();
function processMessage(msg) {
const key = msg.id;
if (!cache.has(key)) {
cache.set(key, { data: msg, timestamp: Date.now() });
}
// 处理逻辑...
}
这个Map只增不减,导致内存持续增长。更隐蔽的是,由于闭包引用,一些对象明明不再使用,却无法被GC回收。
这篇文章我会从V8垃圾回收原理讲起,然后给出排查工具和修复方案,最后列出我踩过的5个坑。
V8垃圾回收机制
V8引擎(Chrome 122 / Node.js 20.11)采用分代回收策略,将堆内存分为新生代和老生代。
新生代(Young Generation)
大小约16MB(可配置),存放生命周期短的对象。使用Scavenge算法(Cheney算法),将空间分为From和To两个半区。当From区满时,标记存活对象并复制到To区,然后交换角色。这个过程叫Minor GC。
数据:一次Minor GC耗时约0.1-1ms,触发频率约50-200ms一次(取决于分配速率)。
老生代(Old Generation)
大小约1400MB(默认),存放生命周期长或经过多次GC后存活的对象。使用标记-清除(Mark-Sweep)和标记-整理(Mark-Compact)算法。
- 标记-清除:从根对象(全局变量、当前执行上下文等)出发,标记所有可达对象,然后清除未标记的。会产生内存碎片。
- 标记-整理:在标记后,将存活对象向一端移动,消除碎片。耗时较长,约10-100ms。
Major GC(全量GC)触发条件:老生代空间不足、内存分配失败、显式调用global.gc()。
增量标记与并发标记
V8在2019年后引入了增量标记(Incremental Marking)和并发标记(Concurrent Marking),避免全量GC导致主线程长时间停顿。增量标记将标记过程拆成多个小步骤,穿插在JS执行间隙。并发标记则用辅助线程并行标记。
效果:Chrome 122中,Major GC停顿时间从原来的100ms+降低到10ms以内。
内存泄漏的3种常见模式
根据我的经验,90%的内存泄漏属于以下三类:
1. 全局变量/缓存无限增长
如开头的Map例子。全局变量不会被GC回收,除非手动删除或置为null。
2. 闭包引用
function createLeak() {
const largeData = new Array(1000000).fill('x');
return function() {
// 即使这里没用到largeData,闭包仍然持有引用
console.log('hello');
};
}
const leakFn = createLeak();
// largeData无法被回收
3. 事件监听器未移除
// 在React组件中
useEffect(() => {
window.addEventListener('resize', handleResize);
// 忘记在return中移除
}, []);
排查工具与实战
我使用Chrome DevTools 122和Node.js 20.11内置的--inspect进行排查。
方案一:Chrome DevTools Memory面板
步骤:
- 打开Chrome,访问目标页面或Node.js应用(node --inspect app.js)
- 打开DevTools -> Memory -> Heap snapshot
- 点击"Take snapshot",然后执行操作,再拍第二个snapshot
- 选择"Comparison"视图,查看新增对象
实战案例:我拍了一个snapshot后,发现大量"Closure"对象,展开看到引用了largeData数组。修复后内存占用下降70%。
方案二:Performance录制
步骤:
- DevTools -> Performance -> 点击录制
- 执行操作,录制10-30秒
- 停止录制,查看"Memory"折线图
- 如果内存持续上升且不下降,说明有泄漏
数据:在一次压测中,录制发现内存从200MB线性增长到1.2GB,然后GC后只降到1.0GB,说明泄漏严重。
方案三:Node.js heapdump模块
// 安装:npm install heapdump
const heapdump = require('heapdump');
const fs = require('fs');
// 每5分钟拍一次快照
setInterval(() => {
const filename = `heap-${Date.now()}.heapsnapshot`;
heapdump.writeSnapshot(filename, (err) => {
if (err) console.error(err);
else console.log(`Snapshot saved: ${filename}`);
});
}, 300000);
然后用Chrome DevTools打开.heapsnapshot文件分析。
修复方案对比
针对开头的Map缓存泄漏,我对比了两种方案:
方案A:手动清理 + WeakMap
// 使用WeakMap,当key对象被回收时,value自动被回收
const cache = new WeakMap();
function processMessage(msg) {
// 注意:WeakMap的key必须是对象
const key = msg; // 直接使用msg对象作为key
if (!cache.has(key)) {
cache.set(key, { data: msg, timestamp: Date.now() });
}
// 处理逻辑...
}
// 当msg对象不再被引用时,cache中的条目自动消失
方案B:LRU缓存 + 定时清理
// 使用lru-cache库(版本10.2.0)
const LRU = require('lru-cache');
const cache = new LRU({
max: 500, // 最多500个条目
ttl: 1000 * 60 * 5, // 5分钟过期
});
function processMessage(msg) {
const key = msg.id;
if (!cache.has(key)) {
cache.set(key, { data: msg, timestamp: Date.now() });
}
// 处理逻辑...
}
对比数据
| 指标 | 原始Map | WeakMap | LRU缓存 |
|---|---|---|---|
| 内存峰值(压测5000 QPS) | 1.8GB | 350MB | 420MB |
| GC次数(10分钟) | 120次 | 45次 | 52次 |
| 平均响应时间 | 45ms | 32ms | 35ms |
| 代码复杂度 | 低 | 中(需对象key) | 中 |
结论:WeakMap在内存控制上最优,但要求key是对象;LRU缓存更灵活,适合需要显式控制大小的场景。
完整代码实现
以下是一个完整的WebSocket消息处理服务,包含内存泄漏修复:
// server.js - Node.js 20.11
const WebSocket = require('ws');
const LRU = require('lru-cache');
// 配置LRU缓存
const cache = new LRU({
max: 1000,
ttl: 1000 * 60 * 10, // 10分钟
});
// 连接池管理,使用WeakMap避免泄漏
const connections = new WeakMap();
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => {
// 使用WeakMap存储连接元数据
connections.set(ws, { connectedAt: Date.now() });
ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString());
const result = processMessage(msg);
ws.send(JSON.stringify(result));
} catch (e) {
ws.send(JSON.stringify({ error: e.message }));
}
});
ws.on('close', () => {
// 自动清理:WeakMap的key(ws对象)被回收时,value自动消失
connections.delete(ws);
});
});
function processMessage(msg) {
const key = msg.id;
if (cache.has(key)) {
return cache.get(key);
}
// 模拟耗时处理
const result = {
id: key,
data: msg.payload,
processedAt: Date.now(),
};
cache.set(key, result);
return result;
}
console.log('WebSocket server running on ws://localhost:8080');
压测数据
使用autocannon(版本7.12.0)进行压测:
# 安装autocannon
npm install -g autocannon
# 压测命令
autocannon -c 100 -d 60 -p 10 ws://localhost:8080
结果:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 最大内存 | 1.8GB | 420MB |
| 平均延迟 | 45ms | 35ms |
| 吞吐量 | 4500 req/s | 5200 req/s |
| GC暂停时间(最大) | 120ms | 18ms |
避坑指南
以下是我实际踩过的5个坑:
坑1:WeakMap的key必须是对象
如果你用字符串作为key,WeakMap不会自动回收:
// 错误用法
const cache = new WeakMap();
cache.set('key1', { data: 1 }); // 字符串不是对象,不会自动回收
// 正确用法
const keyObj = {};
cache.set(keyObj, { data: 1 });
坑2:闭包中的DOM引用
在浏览器中,如果闭包引用了DOM元素,即使DOM被移除,内存也不会释放:
// 错误
function setup() {
const el = document.getElementById('btn');
el.addEventListener('click', function() {
console.log(el.id); // 闭包持有el引用
});
}
// 正确:使用弱引用或手动置null
function setup() {
const el = document.getElementById('btn');
const id = el.id; // 只保存需要的值
el.addEventListener('click', function() {
console.log(id);
});
el = null; // 释放引用
}
坑3:定时器未清理
// 错误
function startPolling() {
setInterval(() => {
fetch('/api/data').then(updateUI);
}, 1000);
}
// 正确:保存定时器ID,在组件卸载时清除
let intervalId;
function startPolling() {
intervalId = setInterval(() => {
fetch('/api/data').then(updateUI);
}, 1000);
}
function stopPolling() {
clearInterval(intervalId);
}
坑4:console.log导致的内存泄漏
在开发环境中,console.log会保留对象引用,导致无法GC。生产环境一定要禁用:
// 错误:生产环境保留console.log
console.log(largeData); // 对象被console引用,无法回收
// 正确:使用条件日志
if (process.env.NODE_ENV === 'development') {
console.log(largeData);
}
坑5:V8的--max-old-space-size配置
在Node.js中,默认老生代大小是1400MB。如果内存泄漏,增大这个值只是延缓崩溃:
# 错误做法:只增大内存
node --max-old-space-size=4096 app.js
# 正确做法:先排查泄漏,再合理配置
总结
垃圾回收不是黑魔法,理解V8的分代回收和标记-清除算法,配合Chrome DevTools的Memory和Performance面板,90%的内存泄漏都能在10分钟内定位。
记住三个原则:
- 全局变量/缓存必须有限制(LRU、WeakMap、定时清理)
- 闭包只保留必要引用
- 事件监听器、定时器必须成对出现(add/remove、set/clear)
最后,压测是检验内存泄漏的唯一标准。用autocannon跑10分钟,看内存曲线是否平稳。