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


我正在使用节点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 从数据库中检索数据。数据量非常大(1M +行)。我用虚拟流交换了这个可读流(参见下面的代码)以简化调试并注意到相同的行为:我的内存使用量每次都会跳跃大约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 / mem的使用率仍然很高,但它似乎修复了泄漏(意味着内存使用率下降)。


8529
2017-09-18 22:19


起源

为什么不使用此流写入临时文件并将路径发送给用户,以便他可以下载它,然后将其删除? - Scoup
您的虚拟代码实际上是一个同步流。所以它会阻止其他代码执行。但这并不能解释高CPU /内存使用率。如果你让它异步会发生什么?喜欢用 this.push 内 setImmediate ? - hassansin
@Scoup我回过头来看了一会儿并且管道到了一个文件(通过 fs.createWriteStream)给出了相同的高CPU /内存行为。 - lebolo
@hassansin我没有试过虚拟代码,但我尝试修改 实际的可读流 我正在使用的代码(通过 process.nextTick)。当我在for循环中完成它时,它根本没有流。在for循环之外,我看到行为没有变化。 - lebolo
也尝试过 setImmediate - lebolo


答案:


看来你正在做的一切正确。我复制了你的测试用例,在v4.0.0中遇到了同样的问题。将它从objectMode中取出并使用 JSON.stringify 你的对象似乎阻止了高内存和高CPU。 这导致我内置 JSON.stringify 这似乎是问题的根源。使用流媒体库 JSONStream 而不是v8方法为我修复了这个问题。它可以像这样使用: .pipe(JSONStream.stringify())


5
2017-09-22 02:09



嗯,我使用的实际可读流是 节点-PG-查询流,它从DB提供行作为JSON对象(来自自定义 Cursor 目的)。我认为要让它在没有流的情况下流式传输需要花费很多工作 objectMode。 - lebolo
看起来他们的自述文件实际上有一个JSONstream的例子。这几乎肯定是问题所在。你试过吗? .pipe 那是在我的编辑?你可能需要一个类似的csv输出解决方案,它看起来像你正在尝试。 - Cody Gustafson
是的,我试过用 stream.pipe(JSONStream.stringify()).pipe(res) (之后objectMode仍在使用 stringify 期待一个对象),我仍然看到相同的高使用率。 - lebolo
嗯,我以为会这样做。还有其他事情发生吗? stream?只有那个stringify我不会指望任何问题。 - Cody Gustafson
任何更新@lebolo?您是否最终发现导致高内存使用的原因? - Cody Gustafson


更新2:这是各种Stream API的历史:

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

0.12使用Streams 3。

更新:对于旧的node.js流,这个答案是正确的。 New Stream API有一个机制 暂停 可写流如果可写流无法跟上。

背压

看起来你已经遇到了经典的“背压”node.js问题。 本文详细解释了它

但这是一个TL; DR:

你是对的,流用于不必将大量数据加载到内存中。

但不幸的是,流没有一种机制来知道继续流式传输是否可行。溪流是愚蠢的。他们只是尽可能快地在下一个流中投放数据。

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

解决方案是将读取流量调节到管道中最慢流的速度。即你用另一个流来预读你的阅读流,这将告诉你的阅读流什么时候可以读取下一个数据块。


5
2017-10-03 16:46





在Node.js中发生内存泄漏太容易了

通常,这是一个小问题,比如在创建匿名函数或在回调中使用函数参数后声明变量。但它对闭包背景产生了巨大的影响。因此,一些变量永远不会被释放。

本文 解释了您可能遇到的不同类型的内存泄漏以及如何找到它们。数字4 - 闭包 - 是最常见的。

我找到了一条允许你避免泄漏的规则:

  1. 在分配之前始终声明所有变量。
  2. 声明所有变量后声明函数
  3. 避免在循环或大块数据附近的任何地方关闭

1
2017-10-04 11:53





试试这个:

  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_gcnode --expose_gc app.js

  5. 使用Chrome调查转储

我强行收集垃圾后 申请我组装了内存使用率恢复正常(67MB。约)意思是

  1. 也许GC没有在如此短的时间内运行并且根本没有泄漏(主要的垃圾收集周期在启动前可能会闲置一段时间)。 这是一篇关于V8 GC的好文章然而,只有在gc周期相互比较的情况下才能确定GC确切时间,但很明显,花在主要GC上的时间越少越好。

  2. 我没有重新创造你的问题。然后,请看一看 这里 并帮助我更好地重现这个问题。


0
2017-09-28 15:07



附:不要在生产中运行显式gc,V8将处理内存本身。 GC经常运行成本很高。 - Alexander Arutinyants


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

或者也许制作自己的Readable使用光标并输出CSV(作为字符串/文本)。


-1
2017-09-26 08:13