关注「索引目录」公众号,获取更多干货。
我们是如何追踪到导致生产服务器不断宕机的隐蔽内存泄漏,以及我们是如何彻底修复它的。
周一早晨被内存溢出毁了
一切看起来都很正常——直到警报开始响起。我们的Node.js API实例一个接一个地崩溃,并出现一条熟悉但令人恐惧的信息:
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
这种模式令人沮丧地始终如一:
-
服务器运行正常数小时。 -
交通量增加 -
记忆力稳步上升 -
然后💥——一声巨响
如果你曾经在生产环境中使用过 Node.js,你肯定知道这是什么感觉:内存泄漏。
在这篇文章中,我将详细介绍我们是如何诊断出这个问题的,哪些信号最重要,以及在重负载下稳定内存的简单修复方法。
解读GC茶叶茶叶
在修改任何代码之前,我们仔细查看了V8的垃圾回收器输出:
Mark-Compact (reduce) 646.7 (648.5) -> 646.6 (648.2) MB
乍一看,它似乎无害。但关键在于:
GC几乎没有释放任何垃圾。
从约 646.7 MB 到约 646.6 MB。这实际上等于零。
这告诉我们什么?
-
GC运行频繁且成本高昂 -
对象仍然是强引用 -
内存不符合收集条件。
简而言之:这不是“垃圾回收器运行缓慢”——这是内存泄漏或过度分配。
战场准备
1. 确认堆内存限制
首先,我们验证了Node.js实际被允许使用的内存量:
const v8 = require('v8');
console.log(
'Heap limit:',
Math.round(v8.getHeapStatistics().heap_size_limit / 1024 / 1024),
'MB'
);
这消除了猜测——这在容器或云运行时环境中尤为重要。
2. 开启GC追踪
接下来,我们实时观察了垃圾回收器的行为:
node --trace-gc server.js
由此可见:
- 掠食
→ 小型星系团(年轻天体) - 标记清除/标记压缩
→ 主要垃圾回收(老一代)
频繁发生的大型气相色谱泄漏事故,且清理工作质量差,这是一个巨大的危险信号。
3. (有意地)缩小堆规模
与其等待数小时让生产环境崩溃,我们不如在本地强制重现这个问题:
node --max-old-space-size=128 server.js
较小的堆内存意味着内存问题会很快显现出来——通常在几分钟内就会出现。
4. 重现问题(负载)
我们编写了一个简单的并发负载脚本来模拟真实流量。在负载下,内存使用量持续攀升,并且始终没有下降。
至此,我们已经成功复现了问题。现在该找出泄露点了。
模板:内存测试负载测试脚本
为了在本地持续重现该问题(而不是等待真实流量),我们使用了以下负载测试模板。
这段脚本有意设计得非常简洁:
-
无外部依赖 -
可配置的并发性 -
回答会被完全吸收(这对记忆准确性很重要) - 专为垃圾回收和堆行为
设计,而非基准测试
用法
node load-test.js [concurrent] [endpoint]
# Example
node load-test.js 100 data
负载测试模板代码
答案:
/**
* Load Test Script for Memory Testing
* Usage: node load-test.js [concurrent] [endpoint]
* Example: node load-test.js 100 data
*/
const http = require('http');
const CONFIG = {
hostname: 'localhost',
port: 3000,
endpoints: {
data: {
path: '/api/data',
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
items: ['sample_item'],
userContext: {
userId: 'test-user',
sessionId: 'test-session'
}
})
}
}
};
const CONCURRENT = parseInt(process.argv[2]) || 50;
const ENDPOINT = process.argv[3] || 'data';
const endpointConfig = CONFIG.endpoints[ENDPOINT];
if (!endpointConfig) {
console.error(
`Unknown endpoint: ${ENDPOINT}. Available: ${Object.keys(CONFIG.endpoints).join(', ')}`
);
process.exit(1);
}
const makeRequest = (requestId) => {
return new Promise((resolve) => {
const startTime = Date.now();
const options = {
hostname: CONFIG.hostname,
port: CONFIG.port,
path: endpointConfig.path,
method: endpointConfig.method,
headers: endpointConfig.headers
};
const req = http.request(options, (res) => {
// Consume response to avoid socket & memory leaks
res.resume();
res.on('end', () => {
resolve({
requestId,
status: res.statusCode,
duration: Date.now() - startTime,
success: res.statusCode >= 200 && res.statusCode < 300
});
});
});
req.on('error', (err) => {
resolve({
requestId,
success: false,
duration: Date.now() - startTime,
error: err.message
});
});
req.setTimeout(30000, () => {
req.destroy();
resolve({
requestId,
success: false,
duration: Date.now() - startTime,
error: 'Timeout'
});
});
if (endpointConfig.body) {
req.write(endpointConfig.body);
}
req.end();
});
};
const runLoadTest = async () => {
console.log('='.repeat(60));
console.log('MEMORY LOAD TEST');
console.log('='.repeat(60));
console.log(`Endpoint: ${endpointConfig.method} ${endpointConfig.path}`);
console.log(`Concurrent Requests: ${CONCURRENT}`);
console.log(`Target: ${CONFIG.hostname}:${CONFIG.port}`);
console.log('='.repeat(60));
console.log('\nStarting load test...\n');
const startTime = Date.now();
const promises = Array.from(
{ length: CONCURRENT },
(_, i) => makeRequest(i + 1)
);
const results = await Promise.all(promises);
const totalTime = Date.now() - startTime;
const successful = results.filter(r => r.success);
const failed = results.filter(r => !r.success);
const durations = successful.map(r => r.duration);
const avgDuration = durations.length
? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length)
: 0;
console.log('='.repeat(60));
console.log('RESULTS');
console.log('='.repeat(60));
console.log(`Total Requests: ${CONCURRENT}`);
console.log(`Successful: ${successful.length}`);
console.log(`Failed: ${failed.length}`);
console.log(`Total Time: ${totalTime}ms`);
console.log(`Avg Response Time: ${avgDuration}ms`);
console.log(`Min Response Time: ${Math.min(...durations)}ms`);
console.log(`Max Response Time: ${Math.max(...durations)}ms`);
console.log(`Requests/sec: ${Math.round(CONCURRENT / (totalTime / 1000))}`);
console.log('='.repeat(60));
if (failed.length) {
console.log('\nFailed requests:');
failed.slice(0, 5).forEach(r => {
console.log(` Request #${r.requestId}: ${r.error}`);
});
}
console.log('\n>>> Check server logs for [MEM] entries <<<\n');
};
runLoadTest().catch(console.error);
追寻记忆之路
我们在可疑路径周围添加了轻量级日志记录:
const logMemory = (label) => {
const { heapUsed } = process.memoryUsage();
console.log(`[MEM] ${label}: ${Math.round(heapUsed / 1024 / 1024)} MB`);
};
在负载情况下,日志清晰地反映了情况:
processData START: 85 MB
processData START: 92 MB
processData START: 99 MB
processData START: 107 MB
内存占用持续攀升——请求接踵而至。
最终,所有线索都指向一个看似无害的辅助函数。
真正的罪魁祸首
const getItemAssets = (itemType) => {
const assetConfig = {
item_a: { thumbnail: '...', full: '...' },
item_b: { thumbnail: '...', full: '...' },
// many more entries
};
return assetConfig[itemType] || { thumbnail: '', full: '' };
};
为什么这是一场灾难
- 每次调用
都会重新创建配置对象 -
每个请求该函数运行多次 -
在并发环境下,每秒创建数万个对象。
即使垃圾回收器可以回收它们,但分配速度比清理速度快——对象被推入老年代,最终耗尽堆内存。
解决方案:一个小小的举措,巨大的影响
const ASSET_CONFIG = Object.freeze({
item_a: { thumbnail: '...', full: '...' },
item_b: { thumbnail: '...', full: '...' },
});
const DEFAULT_ASSET = Object.freeze({ thumbnail: '', full: '' });
const getItemAssets = (itemType) =>
ASSET_CONFIG[itemType] || DEFAULT_ASSET;
发生了什么变化?
-
对象只创建一次,而不是每次请求都创建。 -
热路径中没有新增分配 -
显著降低了气相色谱压力
证明修复有效
我们重新运行了完全相同的测试:
前
-
堆无情地攀爬 -
GC几乎没有释放任何垃圾 -
进程在大约 128 MB 时崩溃
后
-
堆内存使用率在一个较小的范围内波动。 -
小型垃圾回收有效地清理了内存。 -
即使在持续负载下,也没有发生崩溃。
最后想说
大多数Node.js内存溢出崩溃并非由“数据量过大”或“垃圾回收机制不佳”引起,而是由在错误的地方重复进行的小额内存分配
导致。
一旦你学会阅读 GC 日志并控制分配速率,内存错误就不再神秘,而是可以修复的了。
关注「索引目录」公众号,获取更多干货。

