Node.js 内存性能优化 - 知乎

Node.js 内存性能优化 - 知乎

Node.js 内存性能优化

关山难越,萍水相逢
本文翻译自:medium,已获得作者 Naren Yellavula 的翻译许可


Node.js 允许我们写各种体量的服务端应用,小到单文件,大到数M。我们知道应用程序跑在计算机内存(RAM)上,所以内存性能优化变得至关重要,一个不好的实现可能阻塞服务器上同时运行的其它应用。C 和 C++ 程序员需要手动进行内存管理,因为内存泄露可能发生在 C 程序的任何地方,那么作为 JS 程序员,我们该如何处理呢?


JS 程序员可能并不关心多任务处理的性能问题,因为 Node.js 一般用于web 服务开发,单线程的运行在高容量的特定服务器上。但即使是 web 服务开发,我们也需要在服务器上运行诸如 MySQL,Redis 等其它应用,所以我们需要关心内存性能问题,因为胡乱编写代码,可能导致其它应用的排队甚至阻塞。本文我们通过实例来了解一下,如何通过 stream(流),buffer(缓冲器)和 piping(管道)来优化内存性能。


我们使用 Node.js V8.12.0 来运行实例,所有示例代码可在此查看:

narenaryan/node-backpressure-internalsgithub.com


问题:拷贝大文件

使用 Node.js 拷贝大文件,最简单的实现如下

const fs = require('fs');

let fileName = process.argv[2];
let destPath = process.argv[3];

fs.readFile(fileName, (err, data) => {
    if (err) throw err;

    fs.writeFile(destPath || 'output', data, (err) => {
        if (err) throw err;
    });
    
    console.log('New file has been created!');
});

这段代码做了一个简单的先读后写操作,用来拷贝小文件没有任何问题。

如果我们拷贝一个大于 4GB 的文件,比如说,我有一个 7.4GB 的 4K 视频,如果我用上面的代码把这个视频从当前文件夹拷贝到Documents目录

$ node basic_copy.js cartoonMovie.mkv ~/Documents/bigMovie.mkv

在 Ubuntu 上会报一个 buffer 错误

/home/shobarani/Workspace/basic_copy.js:7
    if (err) throw err;
             ^
RangeError: File size is greater than possible Buffer: 0x7fffffff bytes
    at FSReqWrap.readFileAfterStat [as oncomplete] (fs.js:453:11)

错误提示,Node JS 只允许我们向 buffer 中写入不超过 2GB 的数据,如何突破这个限制呢,当你进行高负载 IO 操作的时候,比如拷贝,处理,压缩等,系统内存是一个必须要考虑的点。


使用 Node JS 中的 Buffer 和 Stream

为了解决上面的问题,我们需要先把大文件打成块(chunk),然后按顺序传输和还原这些块。Buffer 和 Stream 分别承担这两部分的作用。其中 buffer 是存储二进制数据的数据结构,而 Stream 提供顺序读写数据的功能


Buffer 缓冲器

我们可以很容易的通过 new 一个 Buffer 对象来创建一个缓冲器:

let buffer = new Buffer(10); # 10 is size of buffer
console.log(buffer); # prints <Buffer 00 00 00 00 00 00 00 00 00 00>

在 Node.js 8.0以上的版本中,你也可以这么做:

let buffer = new Buffer.alloc(10);
console.log(buffer); # prints <Buffer 00 00 00 00 00 00 00 00 00 00>

如果我们想把已有的对象转为buffer,我们可以这么做:

let name = 'Node JS DEV';
let buffer = Buffer.from(name);
console.log(buffer) # prints <Buffer 4e 6f 64 65 20 4a 53 20 44 45 5>

Buffer 还有一些很重要的方法,比如buffer.toString()buffer.toJSON()来查看存储在 buffer 中的数据。

在本例中,我们并不直接创建 buffer 对象,Node JS 的 V8 引擎会在处理 stream 或者 网络 sockets 的时候自动生成 buffer。


Steam 流

流是 Node JS 中的传送门,进来的是输入流,出去的输出流

Steam 一共有 4 种:

  • 可读流(Readable streams)
  • 可写流(Writable streams)
  • 双工流(Duplex streams):即可读又可写
  • 转换流(Transform streams):可处理数据的双工流,比如在读写的同时进行压缩和数据校验

一句话来描述为什么要使用流:

stream API 尤其是 stream.pipe() 方法的一个重要目的,就是限制进入 buffer 缓冲器数据量,防止超过内存读写文件的处理速度上限

正如我们开篇提到的,我们需要使用流防止系统过载

来源:Node JS Docs

上图描述了两种流,可读流与可写流,你现在不用理解这张图,在阅读后续的几个例子后,再回到这里,就会显得很清晰易懂。.pipe()方法是一个将可读流转换为可写流的基础方法,我们将通过两个例子来详细解释一下管道这个非常重要的概念


解决方案一(使用流拷贝本地文件)

我们使用两个流来解决前文所述的大文件拷贝问题

1、监听可读流

2、写入可写流

3、跟踪读写进度

streams_copy_basic.js

/*
    A file copy with streams and events - Author: Naren Arya
*/

const stream = require('stream');
const fs = require('fs');

let fileName = process.argv[2];
let destPath = process.argv[3];

const readabale = fs.createReadStream(fileName);
const writeable = fs.createWriteStream(destPath || "output");

fs.stat(fileName, (err, stats) => {
    this.fileSize = stats.size;
    this.counter = 1;
    this.fileArray = fileName.split('.');
    
    try {
        this.duplicate = destPath + "/" + this.fileArray[0] + '_Copy.' + this.fileArray[1];
    } catch(e) {
        console.exception('File name is invalid! please pass the proper one');
    }
    
    process.stdout.write(`File: ${this.duplicate} is being created:`);
    
    readabale.on('data', (chunk)=> {
        let percentageCopied = ((chunk.length * this.counter) / this.fileSize) * 100;
        process.stdout.clearLine();  // clear current text
        process.stdout.cursorTo(0);
        process.stdout.write(`${Math.round(percentageCopied)}%`);
        writeable.write(chunk);
        this.counter += 1;
    });
    
    readabale.on('end', (e) => {
        process.stdout.clearLine();  // clear current text
        process.stdout.cursorTo(0);
        process.stdout.write("Successfully finished the operation");
        return;
    });
    
    readabale.on('error', (e) => {
        console.log("Some error occured: ", e);
    });
    
    writeable.on('finish', () => {
        console.log("Successfully created the file copy!");
    });
    
});

这段代码中,我们要求用户输入要拷贝的文件和目标地址,然后创建两个流完成从源到目标地址的拷贝。同时声明几个变量用于追踪拷贝进度,另外,我们监听了以下几个事件:

data 当一个文件块被读取时触发

end当可读流读取完毕时触发

error当读取发生错误时触发

尝试运行这段代码,我们就可以成功拷贝一个诸如 7.4GB 大小的文件

$ time node streams_copy_basic.js cartoonMovie.mkv ~/Documents/4kdemo.mkv

但这里有个问题,打开活动监视器,查看你的 Node.js 进程:

Node.js 拷贝文件内存占用

4.6GB?这太夸张了,我们的拷贝文件程序占用了太多的内存,很可能阻塞其它应用程序


为什么会这样?

如果你同时观察一下硬盘的读写数据,就会发现一些有意思的现象:

硬盘读:53.4 MB/s

硬盘写:14.8 MB/s

这说明你的可读流制造了太多的数据,而你的可写流已经消费不了了,电脑只好消耗内存把冗余的可读流先临时存储起来,这就是上面内存爆炸的原因

这个拷贝程序在我的电脑上跑了3分16秒

17.16s user 25.06s system 21% cpu 3:16.61 total


解决方案二(使用自动背压的流拷贝文件)

背压的意思是指:上流河道宽阔水流量大,到了一个河道急速收窄的地方,就会水量过剩,产生背压

为了解决上面的问题,我们需要微调我们的代码,让它可以自动调节读写速度,奥秘就是背压。不过我们不需要做很多,只需要使用.pipe()方法将可读流压入可写流即可,Node.js 会为我们自动处理背压问题。

streams_copy_efficient.js

/*
    A file copy with streams and piping - Author: Naren Arya
*/

const stream = require('stream');
const fs = require('fs');

let fileName = process.argv[2];
let destPath = process.argv[3];

const readabale = fs.createReadStream(fileName);
const writeable = fs.createWriteStream(destPath || "output");

fs.stat(fileName, (err, stats) => {
    this.fileSize = stats.size;
    this.counter = 1;
    this.fileArray = fileName.split('.');
    
    try {
        this.duplicate = destPath + "/" + this.fileArray[0] + '_Copy.' + this.fileArray[1];
    } catch(e) {
        console.exception('File name is invalid! please pass the proper one');
    }
    
    process.stdout.write(`File: ${this.duplicate} is being created:`);
    
    readabale.on('data', (chunk) => {
        let percentageCopied = ((chunk.length * this.counter) / this.fileSize) * 100;
        process.stdout.clearLine();  // clear current text
        process.stdout.cursorTo(0);
        process.stdout.write(`${Math.round(percentageCopied)}%`);
        this.counter += 1;
    });
    
    readabale.pipe(writeable); // Auto pilot ON!
    
    // In case if we have an interruption while copying
    writeable.on('unpipe', (e) => {
        process.stdout.write("Copy has failed!");
    });
    
});;

这里,我们使用一行代码替换了原来的块写入操作

readabale.pipe(writeable); // 开启自动驾驶模式

pipe的神奇魔法调节了 I/O 读写速度 ,防止噎死内存的惨剧发生

原文:The pipe is the reason for all magic that is going to happen. It controls the read and write speeds of disk thus will not choke the memory(RAM).
看我翻译的好不好 :)

现在再来跑一下命令

$ time node streams_copy_efficient.js cartoonMovie.mkv ~/Documents/4kdemo.mkv

拷贝同样的 7.4GB 文件,看一下内存占用情况:

使用 pipe 来调节 Node.js 读写流

哇!现在 Node.js 进程只消耗了 61.9MB 的内存,再来看看硬盘的读写速率

硬盘读:35.5 MB/s

硬盘写:35.5 MB/s

完美,由于自动背压,在任何时间点硬盘的读写速度都是相同的 35.5 MB/s,甚至连拷贝耗时都缩短了 13 秒

12.13s user 28.50s system 22% cpu 3:03.35 total
感谢 Node.js 的流和管道,程序减少了 98.68% 的内存占用,同时缩短了执行时间,这就是为什么我们说 pipe 是个强大的工具

61.9MB 是可读流生成的缓冲器大小,我们也可以通过使用可读流的read方法来定制缓冲器大小

const readabale = fs.createReadStream(fileName);
readable.read(no_of_bytes_size);

除了拷贝本地文件,这项技术还可以应用在涉及 I/O 操作的很多领域:

  • 从 Kafka 写入数据库的数据流
  • 本地文件上传到云端压缩,然后写入服务硬盘
  • 很多很多...


源码:

如果你想亲自试一下,可以在这里找到源码:

narenaryan/node-backpressure-internalsgithub.com

结论:

我(作者)写这篇文章的本意是,即使 NodeJS 提供了和优秀的 API,但如果深入了解,一样会写出性能差劲的糟糕代码。了解更多关于背压的知识:Backpressuring in Streams | Node.js


希望你喜欢这篇文章,如果你有更多问题,可以在此留言,或者关注作者 twitter:https://twitter.com/@Narenarya3

发布于 2018-12-16
「真诚赞赏,手留余香」
还没有人赞赏,快来当第一个赞赏的人吧!


Tags: published
November 29, 2019 at 06:43PM
Open in Evernote

评论

此博客中的热门博文

使用静态Aria2二进制文件快速安装Aria2,及使用方法 - Rat's Blog

Oldghost's Blog » 群晖(Synology)反向代理服务器教程

使用Holer远程登录家里或公司内网的电脑 – Rat’s Blog