Node.js 内存性能优化 - 知乎
- 获取链接
- X
- 电子邮件
- 其他应用
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-internals问题:拷贝大文件
使用 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 缓冲器数据量,防止超过内存读写文件的处理速度上限
正如我们开篇提到的,我们需要使用流防止系统过载
上图描述了两种流,可读流与可写流,你现在不用理解这张图,在阅读后续的几个例子后,再回到这里,就会显得很清晰易懂。.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 进程:
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 文件,看一下内存占用情况:
哇!现在 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-internals结论:
我(作者)写这篇文章的本意是,即使 NodeJS 提供了和优秀的 API,但如果深入了解,一样会写出性能差劲的糟糕代码。了解更多关于背压的知识:Backpressuring in Streams | Node.js
希望你喜欢这篇文章,如果你有更多问题,可以在此留言,或者关注作者 twitter:https://twitter.com/@Narenarya3
Tags: published
November 29, 2019 at 06:43PM
Open in Evernote
评论
发表评论