最佳实践:如何发现、修复和防止 Node.js 内存泄漏

时间:2024-01-22 20:20:37

这篇文章将介绍什么是内存泄漏以及如何在 Node.js 应用程序中避免内存泄漏。

什么是内存泄漏?

在深入研究内存泄漏的细节之前,有必要先了解什么是内存生命周期。

  1. 为已定义变量分配内存
  2. 对分配的内存进行读、写等操作。
  3. 使用后,释放分配的内存

内存泄漏是指当程序没有释放它分配的内存时,即生命周期的第3步没有执行时,所发生的状况。例如,系统分配内存位置来存储我们在程序中声明的变量的值。在诸如C/C的编程语言中,我们可以分配额外的内存来保存所需的数据和变量。但是,在使用后释放内存是我们的责任。

高级编程语言,如JavaScript,利用了自动的内存管理,称为垃圾收集(garbage collection).垃圾收集会在我们声明变量时分配内存,并在不再需要时回收内存。不幸的是,尽管JavaScript使用垃圾收集器来释放内存,但有时无法确定是否释放内存。

因此,在某些情况下,垃圾回收器会错过回收已分配的内存,导致 Node.js (Javascript 生态系统)的内存泄漏。

Node.js 内存泄漏的原因是什么?

为了理解 JavaScript 应用程序中内存泄漏的原因,我们需要了解 JavaScript 中的范围、变量和闭包,让我们快速浏览一下这些内容,以理解 Node.js 中内存泄漏的原因。

意外出现的全局变量

JavaScript的作用域决定了变量、函数或对象在运行时的可见性。JavaScript有两种作用域:和。

我们在函数或代码块中声明的变量具有局部作用域,因此称为局部作用域变量,我们只能在函数或代码块中访问这些变量。

最佳实践:如何发现、修复和防止 Node.js 内存泄漏_nodejs

局部变量

JavaScript的根文档(window对象)中也有全局作用域变量,这意味着文档中的所有函数都可以访问在JavaScript中window/root文档中定义的变量。

最佳实践:如何发现、修复和防止 Node.js 内存泄漏_nodejs_02

全局变量

现在,如果我们在函数中使用一个没有声明的变量,它将创建一个自动或意外的全局变量。

最佳实践:如何发现、修复和防止 Node.js 内存泄漏_全局变量_03

缺少声明的全局变量

由于根节点引用了 JavaScript 中的全局变量(例如,global this 或 window),它们在应用程序的整个生命周期中都不会被垃圾回收,因此,只要应用程序还在运行,它们就不会释放内存。

闭包

在JavaScript中,我们可以在另一个函数中定义一个函数。内部函数可以访问外部函数中的变量。在内部函数中访问父函数的变量的过程称为闭包。

最佳实践:如何发现、修复和防止 Node.js 内存泄漏_全局变量_04


让我们逐一分析上面例子中的每一个步骤,并理解内存泄漏的潜在来源。首先,我们调用父函数,它创建一个局部变量“a”,并返回一个指针到内部函数。

内部函数保存着对父函数作用域变量的引用,因此,即使父函数已经完成了执行,垃圾回收器也不会回收该变量的内存,从而导致 Nodejs 应用程序中的内存泄漏。

Timers 计时器

定时器函数是高阶函数,用于延迟或重复执行其他函数。在Nodejs运行时中,有两个定时器函数。它们是:

  • setTimeout
  • setInterval

setTimeout 在一段时间后执行函数,因此我们可以配置延迟时间,而 setInterval 则以时间间隔重复执行函数。

最佳实践:如何发现、修复和防止 Node.js 内存泄漏_nodejs_05


在这里,我们可以看到 ‘runTimer’ 函数有一个带有对象引用的 setTimeout 定时器。因此,每次它执行回调时,它都会被重新初始化。由于这一点,即使我们删除了对象引用,垃圾回收器也不会回收对象的内存。因此,它可能会导致应用程序中的内存泄漏。

 事件监听器

Javascript使用事件监听器来处理DOM元素中的事件.例如,我们可以为组件的“onClick”事件添加事件处理程序.然而,如果DOM事件和其对应的事件监听器不匹配事件生命周期,它可能会导致内存泄漏.

上游代码

有时,内存泄漏也可能是由于我们的应用程序执行所依赖的上游或远程代码。可能有一个代码/包作为我们的应用程序的依赖,具有内存泄漏,影响应用程序的性能。因此,如果您无法确定应用程序代码中内存泄漏的确切原因,那么检查其依赖的性能是值得尝试的。

垃圾回收呢?

在像JavaScript这样的高级语言中,使用了自动内存管理,即垃圾收集。该实用工具依赖于引用的概念。

垃圾收集器(GC)从根对象开始跟踪对象引用,并标记所有从根对象可到达的节点。如果有根对象不可到达的对象,它将它们视为垃圾并回收它们的内存。GC主要使用两种算法。它们是:

  • Reference Counting  引用计数
  • Mark and Sweep 标记和清除

引用计数算法(Reference Counting Algorithm)

引用计数是一种本地垃圾收集算法,它首先检查一个对象是否拥有任何引用,如果没有,则将这些对象标记为可回收垃圾并在下一个循环中进行回收。

标记和扫描算法(Mark and Sweep Algorithm) 

这个算法比之前的算法更有效,因为一个对象从根节点的零引用将是不可访问的。但是,你可以有循环引用的对象,并且仍然无法从根节点访问。引用计数算法将找到这些对象中的引用,并让它们保持不变,即使由于对根的引用不足而不会使用它们。标记和清扫算法将识别它们从根节点不可访问,并处理它们。

最佳实践:如何发现、修复和防止 Node.js 内存泄漏_内存泄漏_06


对象作为内存节点

虽然Mark and Sweep算法带来的改进提高了内存管理,但它并不能完全防止泄漏,可能存在root可以访问但不再使用的对象,算法没有办法将这些对象标记为垃圾回收。

GC的另一个缺点是它需要额外的资源来处理自动内存管理和决定释放哪些内存空间,因此它会因为其资源需求而减慢应用程序的速度。

内存泄漏的实际商业成本

为了理解内存泄漏的商业成本,让我分享一下我以前的经历,我之前的公司正在构建一个 SaaS 产品,它在平台上有数千个客户,此外,有一个开发团队正在构建产品的初始版本。

由于偶然或糟糕的实践,他们在应用程序中交付了一些具有内存泄漏的代码。一段时间后,应用程序开始表现异常。例如,对于某些用户来说,应用程序会随机崩溃。每当应用程序的流量超过平均值时,它就会崩溃。

最终,由于糟糕的用户体验,客户开始抱怨,流失率变得很高。

更复杂的是,我们很难识别内存泄漏。在我们重启服务器后,它通常会消失。但我们知道崩溃是因为没有足够的内存或CPU等资源。因此,为了临时修复,我们向它添加了更多的RAM。

它对我们的业务产生了负面影响。它不仅让我们花费了更多的钱,还给我们的客户带来了糟糕的体验。因此,这样的内存泄漏可能会对业务产生非常可怕的影响。这个问题已经在像亚马逊网络服务(Amazon Web Services)这样的大环境中造成了严重破坏。


如何检测内存泄漏

你可以使用多种方法检测内存泄漏。此外,在修复代码库中的内存泄漏之前,诊断它也是很重要的。要做到这一点,你有几个特定于语言的库、工具和APM来帮助你。你可以使用这些来跟踪应用程序指标,如内存和CPU使用情况,并识别错误。

利用工具,发挥优势

在本节中,我们将讨论一组工具,您可以使用它们来识别 Node.js 中的内存泄漏。

Scout APM

Scout APM 是一个监控工具,可以跟踪资源使用和内存膨胀。开始使用 Scout 就像安装一个软件包一样简单。

const scout = require("@scout_apm/scout-apm");
scout.install({
allowShutdown: true, // allow shutting down spawned scout-agent processes from this program
monitor: true, // enable monitoring
name: "", // Name comes here
key: "" // Key comes here
  });

之后,我们需要在 express.js 中添加一个简单的路由,并解决内存泄漏问题。

const requests = new Map();
app.get("/", (req, res) => {

    requests.set(req.id, req);
    res.status(200).send("Hello World");
});

在每秒 200 个请求的负载测试中,我们可以看到资源被占用,导致应用程序崩溃。

最佳实践:如何发现、修复和防止 Node.js 内存泄漏_nodejs_07


您可以使用简单而优雅的用户界面在 Scout 中监视内存膨胀。

最佳实践:如何发现、修复和防止 Node.js 内存泄漏_全局变量_08


node-heapdump

这个包会转储v8堆,以便稍后检查。并帮助分析其性能瓶颈和内存泄漏。

首先,在你的项目中添加heapdump依赖项。

npm install heapdump --save

现在,将它添加到您的根项目中。

var heapdump = require("heapdump");

它将内存快照捕获为转储,然后将其存储为文件,或者您可以使用控制台查看结果。

heapdump.writeSnapshot(function(err, filename){
    console.log("Sample dump written to", filename);
});

Node Inspector

Node Inspector 是一个用于使用 Blink 开发者工具的 Node.js 应用程序的调试器接口。它是一个强大的 JavaScript 调试器接口。它可以帮助导航源文件,设置断点,检查作用域,变量和对象属性,以及 CPU 和堆分析。

要开始使用它,请全局安装node-inspector 。

npm install -g node-inspector

现在,您可以使用这个命令进行调试。

node-debug app.js

进程内存分析

Nodejs运行时进程提供了一种简单的方法来监控应用程序中的内存使用情况。

console.log(process.memoryUsage());

这个方法返回带有度量值的数据,但是在生产环境中不推荐使用,因为它会打开一个页面来显示数据,这些度量值就是你在输出中得到的。

{
  rss: 4935680
  heapTotal:1826816
  heapUsed:650472
  External: 49879
}
  • rss: 指驻留集大小,它计算主存中占用的空间总量,包括堆、栈和代码段。
  • heapTotal: 指堆中可用的总内存。
  • heapUsed: 指堆中占用的总内存。
  • external: 指的是Node.js中Buffers所占用的内存,它可以指向对象、字符串和闭包引用。

Chrome DevTools

Chrome开发者工具提供了一种更简单的方法来调试 Node.js 应用程序中的内存泄漏。它捕获堆快照并使用采样方法记录内存分配。分配抽样具有最小的性能开销,您可以使用它来分析长时间运行的操作。

使用以下命令检查应用程序。

node --inspect

然后你可以在Chrome中使用Node开发者工具。

chrome://inspect/#devices

最佳实践:如何发现、修复和防止 Node.js 内存泄漏_全局变量_09

最后,点击Open dedicated DevTools for Node开始调试代码。

最佳实践:如何发现、修复和防止 Node.js 内存泄漏_内存泄漏_10

如何修复内存泄漏

既然我们已经理解了内存泄漏背后的原因,我们就可以在代码中修复它。在这样做的时候,你会意识到修复内存泄漏比诊断它更容易。

修复意外的全局变量

如上所述,在未声明变量的情况下赋值会创建一个意外的全局变量,JavaScript 将其作为具有值且不在本地作用域中的全局变量。

// This will be hoisted as a global variable
function foo() {
bar = "This is global";
}

为了避免这种情况,你可以在javascript中使用strict模式。使用关键字“use strict”将有助于防止在没有声明的情况下赋值。如果你使用像Typescript或Babel这样的转译器,你不需要指定它,因为它是转译器的默认选择。

在最新版本的 Nodejs 中,您可以在运行命令时传递 --use-strict 标志来全局启用严格模式。

另一个可能出现全局变量的意外情况是使用箭头函数时。在这里,严格模式无法解决问题。当在箭头函数中定义“this”时,它指向全局变量“this”,因为箭头函数没有上下文作用域。因此,最好避免在箭头函数中使用“this”。

有效地使用全局变量。

首先,尽量避免使用全局变量,尽量少用全局变量,而使用函数和局部作用域来共享数据和动态变量。

使用局部变量可以帮助垃圾收集器诊断引用并在使用后回收内存。但是,如果你将它们定义为全局变量,它们将在整个应用程序生命周期中留在内存中。因此,即使你在技术上没有使用它,垃圾收集器也很难释放内存。因此,最好尽可能避免使用全局变量。

另外,你可以使用 'const' 而不是 'var' 来使用全局变量。'const' 有助于避免意外重写。此外,'const' 是块作用域,而 'var' 是全局作用域。

避免在全局作用域中存储大数据类型或对象。如果有需要在全局作用域中存储大型对象的情况,请确保在使用后将其null化,以帮助避免内存泄漏。

有效地使用闭包

看下面的例子:

var newElem;

function parent() {
    var someText = new Array(1000000);
    var elem = newElem;
 

    function child() {
        if (elem) return someText;
    }

    return function () {};
}

setInterval(function () {
    newElem = parent();
}, 5);

这里,我们有一个函数 'parent',它有两个函数:'child' 和另一个未命名的函数,我们把全局变量赋给局部作用域 'elem',父函数在这里返回一个空函数。

即使返回函数没有使用任何局部作用域变量,它在这里引用了'elem',因为它与'child'函数共享相同的上下文.这是因为子函数的词法环境是常见的.因此,它会导致应用程序中的内存泄漏.

最佳实践:如何发现、修复和防止 Node.js 内存泄漏_内存泄漏_11

我们可以通过将必须引用其全局变量的局部变量赋值为零来修复它。这样,我们可以避免在闭包环境中出现未使用的引用。

高效使用堆栈和堆内存

尽可能使用堆栈变量。堆栈具有固定的内存量,避免了动态内存分配。因此,它确保您不会意外地导致内存泄漏。最好避免从堆栈变量访问对象、数组和其他堆内存变量。尽量减少从堆栈引用堆变量。

当使用定时器和闭包时,始终确保只有在闭包或定时器事件中使用时才传递引用,如果是一个大型对象或数组,尝试分解并只传递所需的字段。

尽可能避免变异。不变性更好,以避免不必要的引用导致应用程序中的内存泄漏。使用“Object.assign”复制对象以防止变异。我们强烈推荐使用局部作用域而不是全局作用域。


我们能从一开始就避免内存泄漏吗?

我们可以遵循最佳实践和指南,首先避免内存泄漏。但是,在一开始就完全避免它本身是困难的。此外,一旦应用程序开始增长,一些代码库就有可能存在潜在的内存泄漏。因此,这是我们需要不断监控和分析的一个方面。

这就是像 Scout APM 这样的工具出现的地方. Scout 可以代替我们手动监控和基准测试代码库, 为我们节省大量时间. 下面是一个关于 meteor 团队如何诊断和修复内存泄漏的激动人心的故事. 这个例子表明,内存泄漏可能很奇怪,而且通常是随机的;因此,最好始终对应用程序的性能有充分的可观察性.