节点流导致大量内存占用或泄漏

Node streams cause large memory footprint or leak

本文关键字:泄漏 内存 节点      更新时间:2023-09-26

我正在使用节点v0.12.7,并希望直接从数据库流到客户机(用于文件下载)。但是,我注意到使用流时会占用大量内存(以及可能的内存泄漏)。

使用express,我创建了一个端点,它将一个可读流简单地输送到响应,如下所示:

app.post('/query/stream', function(req, res) {
  res.setHeader('Content-Type', 'application/octet-stream');
  res.setHeader('Content-Disposition', 'attachment; filename="blah.txt"');
  //...retrieve stream from somewhere...
  // stream is a readable stream in object mode
  stream
    .pipe(json_to_csv_transform_stream) // I've removed this and see the same behavior
    .pipe(res);
});

在生产环境中,可读的stream从数据库中检索数据。数据量非常大(100多万行)。我用一个虚拟流(见下面的代码)交换了这个可读流,以简化调试,并注意到相同的行为:我的内存使用量每次都会增加约200M。有时垃圾收集会启动,内存会下降一点,但它会线性上升,直到服务器耗尽内存。

我开始使用流的原因是而不是必须将大量数据加载到内存中。这种行为是意料之中的吗?

我还注意到,在流式传输时,我的CPU使用率跳到100%并阻塞(这意味着其他请求无法处理)。

我用错了吗?

虚拟可读流码

// Setup a custom readable
var Readable = require('stream').Readable;
function Counter(opt) {
  Readable.call(this, opt);
  this._max = 1000000; // Maximum number of records to generate
  this._index = 1;
}
require('util').inherits(Counter, Readable);
// Override internal read
// Send dummy objects until max is reached
Counter.prototype._read = function() {
  var i = this._index++;
  if (i > this._max) {
    this.push(null);
  }
  else {
    this.push({
      foo: i,
      bar: i * 10,
      hey: 'dfjasiooas' + i,
      dude: 'd9h9adn-09asd-09nas-0da' + i
    });
  }
};
// Create the readable stream
var counter = new Counter({objectMode: true});
//...return it to calling endpoint handler...

更新

只是一个小更新,我一直没有找到原因。我最初的解决方案是使用集群来产生新的进程,这样其他请求仍然可以处理。

我已经更新到节点v4。虽然cpu/内存使用率在处理过程中仍然很高,但似乎已经修复了泄漏(意味着内存使用率下降)。

更新2:这里是各种流api的历史记录:

https://medium.com/the-node-js-collection/a-brief-history-of-node-streams-pt-2-bcb6b1fd7468

0.12使用Streams 3.

Update:这个答案对于旧的node.js流是正确的。新的流API有一个机制,当可写流跟不上时,暂停可读流。

反压力

看起来你遇到了典型的"背压"node.js问题。本文对此作了详细的解释。

但是这里有一个TL;DR:

你是对的,流是用来不需要加载大量的数据到内存。

但不幸的是,流没有一种机制来知道是否可以继续流。溪流是无声的。他们只是尽可能快地把数据扔到下一个流中。

在您的示例中,您正在读取一个大型csv文件并将其流式传输到客户端。问题是,读取文件的速度比通过网络上传文件的速度要快。因此,数据需要存储在某个地方,直到它们被成功地遗忘。这就是为什么你的内存一直在增长,直到客户端下载完成。

解决方案是将读取流节流到管道中最慢流的速度。也就是说,你在你的阅读流之前加上另一个流,它会告诉你的阅读流什么时候可以读取下一个数据块。

看来你做的一切都是正确的。我复制了您的测试用例,并在v4.0.0中遇到同样的问题。将其从objectMode取出并在对象上使用JSON.stringify似乎可以防止高内存和高cpu。这导致我建立在JSON.stringify似乎是问题的根源。使用流库JSONStream代替v8方法为我解决了这个问题。它可以这样使用:.pipe(JSONStream.stringify()) .

先试一下:

  1. 添加手动/显式垃圾收集调用到你的应用程序,和
  2. 添加堆转储npm install heapdump
  3. 添加代码来清理垃圾并转储剩余的以查找泄漏:

    var heapdump = require('heapdump');
    app.post('/query/stream', function (req, res) {
        res.setHeader('Content-Type', 'application/octet-stream');
        res.setHeader('Content-Disposition', 'attachment; filename="blah.txt"');
        //...retrieve stream from somewhere...
        // stream is a readable stream in object mode
        global.gc();
        heapdump.writeSnapshot('./ss-' + Date.now() + '-begin.heapsnapshot');
        stream.on('end', function () {
            global.gc();
            console.log("DONNNNEEEE");
            heapdump.writeSnapshot('./ss-' + Date.now() + '-end.heapsnapshot');
        });
        stream
                .pipe(json_to_csv_transform_stream) // I've removed this and see the same behavior
                .pipe(res);
    });
    
  4. 使用节点密钥--expose_gc: node --expose_gc app.js运行应用程序

  5. 调查转储与Chrome

在我组装的应用程序上强制垃圾回收后,内存使用恢复正常(67MB)。约)这意味着:

  1. 可能GC在这么短的时间内没有运行,根本没有泄漏(主要的垃圾收集周期在开始之前可以空闲很长时间)。这是一篇关于V8 GC的好文章,但是没有提到GC的精确计时,只是比较了GC周期,但很明显,花在主GC上的时间越少越好。

  2. 我没有很好地重新创建你的问题。那么,请看看这里,帮我更好地重现问题。

在Node.js中很容易出现内存泄漏

通常都是小事,比如在创建匿名函数后声明变量,或者在回调中使用函数参数。但这对闭包上下文有很大的不同。因此,有些变量永远不能被释放。

这篇文章解释了可能存在的不同类型的内存泄漏以及如何找到它们。数字4——闭包——是最常见的一个。

我发现了一个可以让你避免泄漏的规则:

    在给变量赋值之前一定要声明所有的变量。
  1. 声明所有变量后再声明函数
  2. 避免在任何靠近循环或大块数据的地方使用闭包

对我来说,看起来你正在加载测试多个流模块。这是为Node社区提供的一个很好的服务,但是您也可以考虑将postgres数据转储缓存到一个文件gzip中,并提供一个静态文件。

或者可以创建自己的可读文件,使用游标并输出CSV(作为字符串/文本)