Node.js内存泄漏实用指南 – Arbaz Siddiqui


内存泄漏就像应用程序的寄生虫一样,会不经意地蔓延到您的系统中,并且最初不会造成任何危害,但是一旦泄漏足够严重,它们就会对您的应用程序造成灾难性问题,例如高延迟和崩溃。在本文中,我们将研究什么是内存泄漏,javascript如何管理内存,如何在现实情况下识别泄漏以及最终如何解决它们。
内存泄漏可以广义地定义为应用程序不再需要的内存块,但操作系统无法将其用于进一步使用。换句话说,内存块正在占用您的应用程序,而无意在将来使用它。

内存管理
内存管理是一种将内存从计算机内存分配给应用程序,然后在不再使用时将其释放回计算机的方法。内存管理有多种方式,这取决于您使用的编程语言。以下是几种内存管理方式:

  • 手动内存管理:在这种内存管理模式中,程序员负责分配和释放内存。默认情况下,该语言不会为您提供任何自动化工具。虽然它为您提供了极大的灵活性,但也增加了开销。C并C++使用这种方法来管理内存,并提供类似方法malloc并free与机器内存协调。
  • 垃圾收集:垃圾收集的语言为您提供了开箱即用的内存管理功能。程序员不必担心释放内存,因为内置的垃圾收集器将为您完成此任务。对于开发人员来说,它的工作方式以及何时触发释放未使用的内存将是一个黑匣子。像大多数现代编程语言Javascript,JVM based languages (Java, Scala, Kotlin),Golang,Python,Ruby等都是垃圾回收的语言。
  • 所有权:在这种内存管理方法中,每个变量必须具有其所有者,并且一旦所有者超出范围,该变量中的值就会被丢弃,从而释放内存。Rust使用这种内存管理方法。

还有许多其他管理语言使用的内存的方法,但这超出了本文的范围。这些方法中每种方法的优缺点和比较要求有其自己的文章。由于Web开发人员的宠爱js语言以及本文范围内的语言是“垃圾收集”,因此我们将更深入地研究Javascript中垃圾收集的工作方式

Javascript中的垃圾收集
如上一节所述,javascript是一种垃圾收集语言,因此,名为Garbage Collector的引擎会定期运行,并检查您的应用程序代码仍可以访问分配的内存,即哪些变量您仍然具有引用。如果发现应用程序未引用某些内存,它将释放它。上述方法有两种主要算法。首先 是:JS的标记和清除算法Mark and Sweep;另外一种是Python和PHP使用的Reference counting。

标记和清除算法首先创建一个根列表,这些根是环境中的全局变量(window浏览器中的对象),然后从根到叶节点遍历树并标记它遇到的所有对象。堆中未被标记对象占用的所有内存都标记为空闲。

Node应用中的内存泄漏
现在,我们对内存泄漏和垃圾回收有了足够的了解,可以深入到实际应用中。在本节中,我们将编写一个有泄漏的node服务器,尝试使用其他工具识别该泄漏,然后最终对其进行修复。
为了演示起见,我构建了一个其中包含泄漏路由的Express服务器。我们将使用此API服务器进行调试。

const express = require('express')

const app = express();
const port = 3000;

const leaks = [];

app.get('/bloatMyServer', (req, res) => {
    const redundantObj = {
        memory: "leaked",
        joke:
"meta"
    };

    [...Array(10000)].map(i => leaks.push(redundantObj));

    res.status(200).send({size: leaks.length})
});

app.listen(port, () => console.log(`Example app listening on port ${port}!`));

在这里,我们有一个leaks数组超出了我们API的范围,因此,每次调用该数组时,它将一直将数据推送到该数组,而无需清除它。由于它将始终被引用,因此GC将永远不会释放它占用的内存。
网上有很多文章介绍如何调试服务器中的内存泄漏,方法是先用大炮artillery等工具多次击中它,然后再使用进行调试,node --inspect但是这种方法存在一个主要问题。想象一下,如果您有一个具有数百个API的API服务器,每个API包含多个参数,这些参数会触发不同的代码路径。因此,在现实环境中,您根本不知道泄漏所在的位置,如果要胀满内存以调试泄漏,您将必须多次调用每个具有每种可能参数的API。对我来说,这听起来很棘手,除非您拥有goreplay之类的工具,使您可以在测试服务器上记录和重放实际流量。
为了解决这个问题,我们将在生产环境中进行调试,即,我们将允许服务器内存在生产环境中膨胀(因为它将获得各种api请求),一旦发现内存使用量上升,我们将开始对其进行调试。

堆转储
要了解什么是堆转储,我们首先需要了解什么是堆。用最简单的术语来说,堆是所有东西扔到那里的地方,它一直呆在那里,直到GC删除了应该是垃圾的东西。堆转储是您当前堆的快照。它将包含堆中当前存在的所有内部和用户定义的变量及分配。
因此,如果我们能够以某种方式比较新服务器的堆转储与运行时间较长的ated肿服务器的堆转储,那么我们应该能够通过查看差异来识别未被GC拾取的对象。
但是首先让我们看一下如何进行堆转储。我们将使用一个npm库heapdump,它允许我们以编程方式获取服务器的heapdump。要安装,请执行以下操作:

npm i heapdump

我们将在express服务器中进行一些更改以使用此软件包。

const express = require('express');
const heapdump = require("heapdump");

const app = express();
const port = 3000;

const leaks = [];

app.get('/bloatMyServer', (req, res) => {
    const redundantObj = {
        memory:
"leaked",
        joke:
"meta"
    };

    [...Array(10000)].map(i => leaks.push(redundantObj));

    res.status(200).send({size: leaks.length})
});

app.get('/heapdump', (req, res) => {
    heapdump.writeSnapshot(`heapDump-${Date.now()}.heapsnapshot`, (err, filename) => {
        console.log(
"Heap dump of a bloated server written to", filename);

        res.status(200).send({msg:
"successfully took a heap dump"})
    });
});

app.listen(port, () => {
    heapdump.writeSnapshot(`heapDumpAtServerStart.heapsnapshot`, (err, filename) => {
        console.log(
"Heap dump of a fresh server written to", filename);
    });
});

服务器启动后,我们已使用该软件包进行堆转储,并在调用API /heapdump进行堆转储。当我们意识到内存消耗增加时,将调用此API。

如果您在kube集群中运行应用程序,则将无法使用所需的高消耗Pod。为此,您可以使用端口转发来命中群集中的该特定容器。另外,由于您将无法访问文件系统来下载这些大型转储,因此您可以将这些大型转储上传到云(s3)。

识别泄漏
因此,现在我们的服务器已部署并且已经运行了好几天。它受到许多请求的攻击(在我们的例子中只有一个),并且我们注意到服务器的内存消耗已经激增(可以使用Express Status MonitorClinicPrometheus等监视工具来实现)。现在,我们将进行API调用以进行堆转储。该堆转储将包含GC无法收集的所有对象。

curl --location --request GET 'http://localhost:3000/heapdump'

采取堆转储会强制GC触发,因此我们不必担心将来GC收集后的内存,而是关注当前位于堆内的内存分配,即非泄漏对象。进行堆转储是占用大量内存并且会阻塞的操作,应该谨慎进行。阅读此警告以获取更多信息。
一旦您掌握了两个堆转储(新的和运行时间长的服务器),我们就可以开始进行比较。
打开chrome并按F12键。这将打开chrome控制台,转到Memory选项卡,然后打开Load两个快照。

加载完两个快照后,将perspective改为Comparison,然后单击长时间运行的服务器的快照
我们可以通过Constructor 浏览GC未扫描的所有对象。它们中的大多数将是NodeJS使用的内部引用,一个巧妙的技巧是通过Alloc. Size对它们进行排序,以检查我们拥有的最繁重的内存分配。如果我们进行扩展array,然后进行扩展,(object elements)我们将能够看到leaks其中包含疯狂数量的对象的数组,而该数组没有被GC拾取。
现在,我们可以将指向leaks数组的原因归结为高内存消耗的原因。

解决泄漏
现在我们知道数组leaks会引起麻烦,我们可以查看代码并很容易地对其进行调试,因为数组不在请求周期的范围内,因此永远不会删除其引用。我们可以通过以下操作轻松修复它:

app.get('/bloatMyServer', (req, res) => {
    const redundantObj = {
        memory: "leaked",
        joke:
"meta"
    };
const leaks = [];

    [...Array(10000)].map(i => leaks.push(redundantObj));

    res.status(200).send({size: leaks.length})
});


我们可以通过重复上述步骤并再次比较快照来验证此修复程序。

结论
内存泄漏势必会在垃圾收集语言(如javascript)中发生。修复内存泄漏很容易,尽管识别它们确实是很痛苦的。在本文中,我们了解了内存管理的基础知识以及如何通过各种语言完成内存管理。我们模拟了一个真实的场景,并尝试调试其内存泄漏并最终对其进行了修复。