Node.js 作为一款高性能的 JavaScript 运行时,在处理大量并发请求时有着出色的表现。然而,内存管理和性能优化始终是后端开发中的重要环节。特别是在面对内存泄漏、堆内存溢出、垃圾回收性能瓶颈时,了解如何优化内存分配和性能变得尤为关键。
这里将深入探讨 Node.js 中的堆内存分配原理,如何监控内存使用情况,以及如何通过不同的测试方法来提升应用的性能。此外,还将介绍一些实用的调试工具和优化技巧,帮助开发者更好地管理内存和提升应用性能。
node --trace-gc app.js今天我们主要关注的是堆内存。现在你已经对垃圾回收器有了更多的了解,是时候在堆上分配一些内存了!
function allocateMemory(size) { // 模拟分配字节 const numbers = size / 8; const arr = []; arr.length = numbers; for (let i = 0; i < numbers; i++) { arr[i] = i; } return arr; }
局部变量很快就会死去,一旦函数调用在调用栈中结束。像 numbers 这样的原始类型永远不会进入堆,而是在调用栈中分配。对象 arr 将进入堆,并可能在垃圾回收中存活下来。
const memoryLeakAllocations = []; const field = 'heapUsed'; const allocationStep = 10000 * 1024; // 10MB const TIME_INTERVAL_IN_MSEC = 40; setInterval(() => { const allocation = allocateMemory(allocationStep); memoryLeakAllocations.push(allocation); const mu = process.memoryUsage(); // # bytes / KB / MB / GB const gbNow = mu[field] / 1024 / 1024 / 1024; const gbRounded = Math.round(gbNow * 100) / 100; console.log(`Heap allocated ${gbRounded} GB`); }, TIME_INTERVAL_IN_MSEC);这段代码每 40 毫秒分配大约 10 兆字节的内存,给垃圾回收足够的时间将存活的对象提升到老年代。process.memoryUsage 是一个粗略的工具,用于收集堆利用率的指标。随着堆分配的增长,heapUsed 字段跟踪堆的大小。这个字段报告的是 RAM 中的字节数,可以转换为千兆字节。
Heap allocated 4 GB Heap allocated 4.01 GB <--- Last few GCs ---> [18820:000001A45B4680A0] 26146 ms: Mark-sweep (reduce) 4103.7 (4107.3) -> 4103.7 (4108.3) MB, 1196.5 / 0.0 ms (average mu = 0.112, current mu = 0.000) last resort GC in old space requested <--- JS stacktrace ---> FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory在这里,垃圾回收器尝试在放弃并抛出 'heap out of memory' 异常之前压缩内存。这个过程达到了 4.1GB 的限制,并花了 26.6 秒才结束。
脚本在最新的 Node 14 版本下运行,并使用 64 位可执行文件。理论上,64 位进程应该能够分配超过 4GB 的内存,并可能增长到 16 万亿字节的地址空间。
node index.js --max-old-space-size=8000这将最大限制设置为 8GB。这样做时要谨慎。我的笔记本电脑有 32GB 的充足空间。我建议将这个值设置为物理 RAM 可用空间的大小。一旦物理内存用完,进程就会开始通过虚拟内存消耗磁盘空间。如果设置的限制太高,你可能会找到一种损坏你的电脑的方法!这里的目标是避免机器冒烟。
Heap allocated 7.8 GB Heap allocated 7.81 GB <--- 最后几次 GC ---> [16976:000001ACB8FEB330] 45701 ms: Mark-sweep (reduce) 8000.2 (8005.3) -> 8000.2 (8006.3) MB, 1468.4 / 0.0 ms (平均 mu = 0.211, 当前 mu = 0.000) 在老空间请求的最后手段 GC <--- 堆代码 duidaima.com ---> <--- JS 堆栈跟踪 ---> 致命错误:CALL_AND_RETRY_LAST 分配失败 - JavaScript 堆内存不足
堆大小几乎达到了 8GB,但还没有。我怀疑 Node 进程在分配这么多内存时有一些开销。这次它花了 45.7 秒才结束。在生产环境中,往往不会在一分钟内就耗尽内存。这就是为什么监控和了解内存消耗很重要的原因之一。内存消耗可能随着时间的推移慢慢增长,可能需要几天时间你才会意识到有问题。如果进程不断崩溃,并且 'heap out of memory' 异常出现在日志中,代码中可能存在 内存泄漏。
如果资源消耗持续增长,可能是因为它正在处理更多的数据。如果资源消耗持续增长,可能是时候将这个单体应用拆分成微服务了。这将减少单个进程的内存压力,并允许节点水平扩展。
const path = require('path'); const fs = require('fs'); const os = require('os'); const start = Date.now(); const LOG_FILE = path.join(__dirname, 'memory-usage.csv'); fs.writeFile( LOG_FILE, 'Time Alive (secs),Memory GB' + os.EOL, () => {}); // 放任不管为了避免将堆分配指标放入内存,我们选择将其写入 CSV 文件以便于数据消费。这使用了异步的 writeFile 函数和一个回调。回调被留空,以写入文件并继续而不进行任何进一步的处理。要获取逐步的内存指标,请在 console.log 之上添加此代码:
const elapsedTimeInSecs = (Date.now() - start) / 1000; const timeRounded = Math.round(elapsedTimeInSecs * 100) / 100; fs.appendFile( LOG_FILE, timeRounded + ',' + gbRounded + os.EOL, () => {}); // 放任不管
有了这段代码,你可以在堆利用率随时间增长时调试内存泄漏。你可以看到一个短时间内内存使用量的线性增长,4.1GB 的限制。内存消耗继续增长,并没有达到平稳状态,这表明某个地方存在内存泄漏。在调试这类内存问题时,要寻找导致分配进入老年代的代码。在垃圾回收中存活下来的对象可能会一直存在,直到进程死亡。
setInterval(() => { const mu = process.memoryUsage(); // # bytes / KB / MB / GB const gbNow = mu[field] / 1024 / 1024 / 1024; const gbRounded = Math.round(gbNow * 100) / 100; const elapsedTimeInSecs = (Date.now() - start) / 1000; const timeRounded = Math.round(elapsedTimeInSecs * 100) / 100; fs.appendFile( LOG_FILE, timeRounded + ',' + gbRounded + os.EOL, () => {}); // 放任不管 }, TIME_INTERVAL_IN_MSEC);我们可以通过实时收集内存数据,去监控我们的服务,确保服务健康稳定。除了 process.memoryUsage(),Node.js 的 v8 模块还提供了更详细的内存监控 API,例如 v8.getHeapStatistics() 和 v8.getHeapSpaceStatistics(),可以深入分析堆空间的使用情况:
const v8 = require('v8'); setInterval(() => { const heapStats = v8.getHeapStatistics(); console.log('Heap Size Limit:', heapStats.heap_size_limit / 1024 / 1024, 'MB'); console.log('Total Heap Size:', heapStats.total_heap_size / 1024 / 1024, 'MB'); console.log('Used Heap Size:', heapStats.used_heap_size / 1024 / 1024, 'MB'); }, 1000);
这些 API 对于检测堆内存使用的增长趋势,识别可能的内存泄漏非常有用。
优化内存分配和垃圾回收的另一个重要手段是通过基准测试和压力测试来分析应用在高负载下的表现。Node.js 社区提供了许多基准测试工具,可以帮助我们衡量内存使用的瓶颈。
const Benchmark = require('benchmark'); const suite = new Benchmark.Suite; suite.add('Array#push', function() { const arr = []; for (let i = 0; i < 1000; i++) { arr.push(i); } }).add('Array#unshift', function() { const arr = []; for (let i = 0; i < 1000; i++) { arr.unshift(i); } }).on('cycle', function(event) { console.log(String(event.target)); }).on('complete', function() { console.log('Fastest is ' + this.filter('fastest').map('name')); }).run({ 'async': true });使用 autocannon 进行压力测试
autocannon 是一个高效的 HTTP 服务器压力测试工具,适合模拟并发用户对应用进行压力测试。通过这种方式,可以观察高负载下的内存使用情况和垃圾回收性能。
npx autocannon -c 100 -d 30 http://localhost:3000基于多线程的压力测试
const { Worker } = require('worker_threads'); function runWorker() { return new Promise((resolve, reject) => { const worker = new Worker('./worker.js'); worker.on('message', resolve); worker.on('error', reject); worker.on('exit', (code) => { if (code !== 0) { reject(new Error(`Worker stopped with exit code ${code}`)); } }); }); } async function runWorkers() { const numWorkers = 10; const promises = []; for (let i = 0; i < numWorkers; i++) { promises.push(runWorker()); } await Promise.all(promises); console.log('All workers completed'); } runWorkers();第三方工具的使用
const heapdump = require('heapdump'); heapdump.writeSnapshot((err, filename) => { if (err) console.error(err); else console.log('Heap snapshot written to', filename); });通过 Chrome DevTools 可以加载生成的 .heapsnapshot 文件,并进行详细分析。
node --inspect app.js
然后可以在 Chrome 浏览器中打开 chrome://inspect,连接并分析应用的内存使用情况。
const memwatch = require('memwatch-next'); memwatch.on('leak', (info) => { console.error('Memory leak detected:', info); });
通过 memwatch 的监控,可以发现堆内存是否存在不正常的增长趋势,进而优化代码中的内存管理。减少内存占用的一个常见方法是使用对象池。对象池技术可以避免频繁创建和销毁对象,尤其是在高负载的场景中,有效降低内存消耗。
此外,使用弱引用(WeakMap 和 WeakSet)存储不需要持久保存的对象引用,确保它们可以在垃圾回收时被释放,也是优化内存的一个好方法。在 Node.js 中,异步 I/O 操作(例如文件读写、数据库查询、网络请求)是十分常见的操作。由于这些操作不会阻塞主线程,因此能够有效提高应用的性能。然而,如果处理不当,异步 I/O 可能会导致内存泄漏或内存过度增长。
let responseCache = {}; function fetchData(url) { return new Promise((resolve, reject) => { // 执行异步请求 setTimeout(() => { responseCache[url] = `Response from ${url}`; resolve(responseCache[url]); }, 1000); }); } async function handleRequest(url) { const data = await fetchData(url); console.log(data); // 堆代码 duidaima.com // 请求完成后清除缓存,释放内存 delete responseCache[url]; } handleRequest('http://example.com');数据流处理的内存管理:使用流(streams)来处理大数据文件或长时间的数据传输,可以减少内存占用。例如,当我们从文件中读取数据时,最好采用 stream 方式逐块读取,而不是一次性将整个文件加载到内存中:
const fs = require('fs'); const readable = fs.createReadStream('largefile.txt'); readable.on('data', (chunk) => { console.log(`Received ${chunk.length} bytes of data.`); }); readable.on('end', () => { console.log('No more data.'); });
这种方式可以有效减少内存占用,并避免因为加载大文件导致的堆内存溢出。在进行性能调优时,光靠简单的监控工具还不足以捕捉所有的性能瓶颈。高级的性能测试工具和方法可以帮助开发者全面掌握应用的内存使用和性能表现。火焰图(Flamegraph)是一种可视化工具,能够直观地展示应用程序中的函数调用关系及其 CPU 占用情况。通过生成火焰图,可以帮助开发者快速定位应用中最耗时的代码段。
npx clinic flame -- node app.js当应用运行结束时,clinic 将生成一份火焰图报告,开发者可以通过火焰图找出哪些函数消耗了最多的 CPU 时间,并进一步优化这些代码。
pprof 是 Google 开源的性能分析工具,支持捕获应用在运行时的 CPU 和内存性能数据。通过 pprof,可以对内存的分配情况、CPU 的使用情况进行深度分析,从而发现潜在的性能问题。
node --cpu-prof --heap-prof app.js
然后可以使用 pprof 工具对生成的性能文件进行分析,查看内存和 CPU 的详细使用情况。在生产环境中,我们可以通过定时触发内存和性能分析操作,或者借助云平台提供的性能监控服务(如 AWS CloudWatch,Google Stackdriver)来实时追踪内存和性能表现。
在生产环境中,实时监控应用的内存使用情况是必不可少的。借助 Prometheus 和 Grafana 等工具,我们可以设置内存使用的报警阈值,当应用的内存使用达到指定阈值时,系统会发出警报,从而及时发现并处理内存问题。
Node.js 内存管理和性能优化涉及多个层面,从理解 V8 引擎的垃圾回收机制,到掌握不同的内存监控工具,再到实际的基准测试和生产环境中的调优,每一步都至关重要。通过深入分析堆内存的使用情况,合理优化内存分配,并结合第三方工具进行高效测试,我们可以有效提升 Node.js 应用的稳定性和性能。