Node.js 应用开发实战 - 高级前端开发工程师 - 拉勾教育
模块一的《06 | 哪些因素会影响 Node.js 性能?》,我们详细讲解了影响到 Node.js 性能的一些因素,但是在实际开发过程中,我们应该如何去定位影响性能的关键因素呢?定位到性能问题后,又该如何去优化这部分功能呢?以上就是本讲要介绍的核心知识点。
工具介绍
在讲解性能分析实践之前,我们先来看看性能分析所应用的两个比较关键的工具:
- 压测所使用到的 WRK(Windows Research Kernel);
- 性能分析所使用到的 Chrome 分析工具 JavaScript Profile。
WRK 的安装及参数
在压测工具上可选择的比较多,比如 Apache-ab 压测工具、Siege 及本讲所应用的 WRK。为了能够更好地利用多核的多线程并发测试,这里我们选择使用 WRK 来作为压测工具。我们看下该工具的安装以及一些常用参数。
在Mac上使用软件包管理工具 Homebrew 来安装,使用如下命令即可:
在Linux上依次执行…… 的命令安装就可以(如果 Linux 上没有安装 GCC、Make 或者 Git,就需要先安装这几个工具)。
git clone https://github.com/wg/wrk.git
cd wrk
make
- 在Windows上就非常遗憾了,因为这个工具不支持 Windows。但如果你是 Windows 10,可以切换到 Ubuntu 子系统的方式来安装,或者在 Windows 上安装 Linux 虚拟机也是可以的。
成功安装后,你可以在命令行使用…… 命令查看具体的参数说明和介绍:
这一讲因为需要进行并发请求的验证,所以我们会使用下面的压测命令:
wrk -t4 -c300 -d20s https://www.baidu.com/
其参数说明如下:
- -t 代表的是启动 4 个线程;
- -c 代表的是并发数,300 个并发请求;
- -d 代表的是持续时长,20s 就是 20 秒。
我们运行上面的命令后,会有相应的压测结果,如下所示:
Running 20s test @ https://www.baidu.com/
10 threads and 300 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 789.93ms 393.84ms 2.00s 76.57%
Req/Sec 14.33 10.17 59.00 72.31%
2252 requests in 20.10s, 34.49MB read
Socket errors: connect 60, read 0, write 0, timeout 481
Requests/sec: 112.05
Transfer/sec: 1.72MB
上面的结果,我们核心应该关注的是 Requests/sec 为 QPS,其次也需要了解平均耗时的情况,也就是上面的 Avg 789.93ms,以及失败超时的情况,即上面 socket errors 的 timeout 481。
Chrome 分析工具 JavaScript Profiler
在压测下,如果发现请求 QPS 非常低、平均耗时非常长,或者失败率非常高的话,这时就需要将 CPU 信息进行保存,然后用 Chrome 的 JavaScript Profiler 工具来进行分析。
方法也很简单,用 Chrome 的开发者工具 More-tools → JavaScript Profiler → Load,读取 CPU Profile,查看火焰图(如图 1 所示)。
图 1 Chrome 打开 JavaScript Profiler 指引图
要使用这个功能,需要在 Node.js 中对 CPU 进行采集,采集的方式需要使用 v8-profiler 这个库(如果你的 Node.js 版本大于 10,则需要使用 v8-profiler-next 这个库),在上一讲中已经应用过该工具分析过内存泄漏问题。
简单 Demo 演示
为了演示这个分析方式,我们写一个最简单的测试代码,如下所示:
const http = require('http');
const v8Profiler = require('./lib/v8_profiler');
*
* 创建 http 服务,简单返回
*/
const server = http.createServer((req, res) => {
res.write('hello world');
res.end();
});
*
* 启动服务,并开始执行 v8 profiler 的采集工作
*/
server.listen(3000, () => {
console.log('server start http://127.0.0.1:3000');
v8Profiler.start();
});
在上面代码中的第二行,我们引入了一个自身写的 v8-profiler 库,接下来看看这个库的逻辑。
'use strict';
const v8Profiler = require('v8-profiler-next');
const fs = require('fs');
const title = 'example';
module.exports = {
'start' : () => {
v8Profiler.startProfiling(title, true);
setTimeout(() => {
const profile = v8Profiler.stopProfiling(title);
profile.export(function (error, result) {
fs.writeFileSync(`./cpu_profiler/${title}.cpuprofile`, result);
profile.delete();
});
}, 30 * 1000);
}
};
上面代码中的 start 方法,就是核心的采集代码。v8-Profiler 开始采集,使用 title 作为唯一标示,在 30 秒后,停止这个 title 的采集,并获取数据保存在文件中。
为了验证效果,你可以根据我们下面的步骤来进行分析(代码源码保存在GitHub中,自行下载后,可按照下面步骤执行)。
(1)打开项目,进入项目根目录,执行命令启动服务。
(2)打开另外一个命令窗口,开始执行压测程序。
wrk -t2 -c300 -d20s http:
(3)大概 30 秒后,项目目录下的 cpu_profiler 文件夹下会生成 example.cpuprofile 文件。
(4)打开 Chrome 工具中的 JavaScript Profiler,然后 load 刚才项目目录下的 cpu_profiler/example.cpuprofile 文件,就可以看到如图 2 所示的结果。
图 2 cpuprofile 演示结果
从上面的结果可以看到相应的单个执行时间和总的耗时。如果性能较慢,你可以参照标准的结果来进行分析,或者对比一个性能较好、一个性能较差的执行结果。为了让你更清晰地了解这些,下面我将从实践来进行分析。
实践分析
在分析任何数据之前,首先必须有一个标准的数据进行比较,如果你用的是 Express、Eggjs 等框架,需要做一个完全空转的数据作为标准分析数据。在本讲,由于我们没有用任何框架,所以需要设计一个完全空转的 HTTP 服务来作为标准的分析数据。
标准数据
这里我们还是用上面 “简单 Demo 演示” 中的代码,然后通过 WRK 下面的压测命令来压测,看下具体的数据情况。
wrk -t2 -c300 -d20s http:
压测得到的结果如表 1 所示。
有了这份数据后,我们再来逐个分析以下问题。
CPU 计算耗时
为了验证效果,这里我们写一个 CPU 计算耗时的逻辑,然后继续压测。这点在模块一的《06 | 哪些因素会影响 Node.js 性能?》已经有一些例子,我们拿一个出来尝试一下,比如 MD5 计算的逻辑,代码如下:
crypto = require('crypto');
module.exports = (content) => {
return crypto.createHash('md5').update(content).digest("hex")
}
核心逻辑就是应用 crypto 来生成 MD5 加密数据,为了效果更好,我们在入口文件 index.js 中多调用几次,这里只修改 server 中的代码逻辑,代码如下:
const server = http.createServer((req, res) => {
let ret;
const md5List = ['hello', 'Node.js', 'lagou', 'is', 'great'];
md5List.forEach( (str)=> {
if(ret){
ret = `${ret} ${getMd5(str)}`;
} else {
ret = getMd5(str)
}
});
res.write(ret);
res.end();
});
修改完成后,我们再按照简单 Demo 中的四个步骤压测数据即可:
- 启动服务
- 开始压测
- 等待 CPU 采集
- 分析压测数据
接下来我们看下压测后的数据,如表 2 所示。
这一对比可以非常清晰地看到,相对于标准服务,CPU 耗时服务在各方面(平均耗时、最低耗时、最大耗时以及失败率)都差很多,在性能上两者是有比较大的落差的,如果我们不知道是因为 MD5 影响到 CPU 计算导致的,那么就需要分析 CPU 耗时的情况了。
接下来我们打开 Chrome JavaScript Profiler 工具,可以看到如图 3 所示的结果。
图 3 CPU 压测 CPU 耗时分析
几个耗时较长的函数,例如 digest、Hash 以及 update 等,都是在 MD5 计算中的逻辑,因此可以非常清晰地了解到,在 MD5 计算方面会对 Node.js 的服务有一个比较大的性能影响,因此在开发时尽量减少或者避免这种类似的计算服务。
网络 I/O
为了演示效果,我们先创建一个新的服务,这个服务在原来的 “简单 Demo 演示” 基础上增加了一个延迟返回的效果,具体代码在 api_server 文件项目中,核心代码如下:
const http = require('http');
*
* 创建 http 服务,简单返回
*/
const server = http.createServer((req, res) => {
setTimeout(() => {
res.write('this is api result');
res.end();
}, 1 * 1000);
});
*
* 启动服务,并开始执行 v8 profiler 的采集工作
*/
server.listen(4000, () => {
console.log('server start http://127.0.0.1:4000');
});
核心代码是在 setTimeout 延迟返回,其次修改了以下监听端口,从 3000 修改为 4000。
接下来我们创建一个 network_io,实现调用 http://127.0.0.1:4000 这个服务,从而实现网络 I/O 操作。首先还是实现一个 call_api 的服务,该服务会应用到 request 这个 npm 库(后续这个库不会维护了,暂时还没有替代方案,除非自己手动实现),代码如下:
const request = require('request');
*
* request 调用外部 api
* @param {*} apiLink string
* @param {*} callback funtion
*
*/
module.exports = (apiLink, callback) => {
request(apiLink, function (error, response, body) {
if(error) {
callback(false);
} else {
callback(body);
}
});
}
这部分代码主要是应用 request 模块调用 apiLink 服务,并获取执行结果。通过回调的方式返回具体的数据,也就是上面的参数 callback。
最后我们再来看下 index.js 中的核心部分 server 的修改,代码如下:
*
* 创建 http 服务,简单返回
*/
const server = http.createServer((req, res) => {
callApi('http://127.0.0.1:4000', (ret) => {
if(ret) {
res.write(ret);
} else {
res.write('call api server error');
}
res.end();
});
});
接下来就启动两个服务,分别打开地址:
看下是否正常响应,正常返回数据后,我们再启动对 3000 服务的压测,运行如下命令:
wrk -t2 -c300 -d20s http:
接下来我们看下压测后的数据,如表 3 所示:
拿到压测数据后,同样按照 CPU 分析方法,可以看到如图 4 所示的性能分析结果。
图 4 网络 I/O 性能分析数据
其中有一个 connect,该模块在 net.js 中,这里就可以得到是网络 I/O 引起的问题。
最后我们再来看下 磁盘 I/O 的问题。
磁盘 I/O
这里主要使用 Node.js 的 fs 模块来读取本地的文件,并显示返回文件的内容,核心代码是 server 回调部分,代码如下:
const server = http.createServer((req, res) => {
let ret = fs.readFileSync('./test_file.conf');
res.write(ret);
res.end();
});
接下来,我们同样用如下命令来启动压测服务:
wrk -t2 -c300 -d20s http:
压测后将得到如表格 4 所示的结果。
从结果看也是存在一定损耗的,具体在哪方面影响到性能,同样用 Chrome 工具载入该服务采集的 CPU 信息,如图 5 所示。
图 5 磁盘 I/O CPU 采集信息
从图 5 中可以非常清晰地看到前面几个耗时较长的都是关于文件读写相关的模块,如上面红色圈里面的信息。
优化以及效果
上面已经介绍到了那么多性能影响的部分,那么接下来看看如何进行一些优化,来提升性能。
CPU 计算耗时
这部分只能说减少操作,或者减少运算。像我们上面的例子,如果都是一样的 MD5 计算,那么增加一个短时间的缓存就可以了。当然这里我们可以直接用内存来缓存(实际开发过程中,不能使用内存的方式,因为会造成内存使用越来越大,一般使用共享内存,并短时间保存即可),代码如下(代码保存在 cpu_opt 中):
crypto = require('crypto');
const md5Cache = {};
module.exports = (content) => {
if(md5Cache[content]) {
return md5Cache[content]
}
md5Cache[content] = crypto.createHash('md5').update(content).digest("hex");
return md5Cache[content];
}
核心就是在原来的基础上,增加计算的缓存,避免多次相同的计算,接下来我们压测后看看实际的效果数据,并与之前的进行对比,得到的结果如表 5 所示。
对比数据后,可以看到已经非常好了,已经和标准数据相差无几,QPS 和 标准服务也基本一致(这里比标准服务高,是因为本机测试,会有一定的起伏,是正常情况)。也就代表本次优化是达到了效果的。
网络 I/O
网络 I/O 同样的办法也是增加缓存,避免重复的请求导致的问题。这里我们同样用缓存的方式来保存请求结果,优化的代码如下(代码保存在 network_io_opt 中):
const request = require('request');
const apiCacheData = {};
*
* request 调用外部 api
* @param {*} apiLink string
* @param {*} callback funtion
*
*/
module.exports = (apiLink, callback) => {
if(apiCacheData[apiLink]) {
return callback(apiCacheData[apiLink]);
}
request(apiLink, function (error, response, body) {
if(error) {
callback(false);
} else {
apiCacheData[apiLink] = body;
callback(body);
}
});
}
其次为了服务性能考虑,我们可以考虑放弃部分超时请求,从而提升服务性能。避免因为部分请求返回慢,导致整体服务被 block 住,修改 request 部分增加超时处理,代码如下:
module.exports = (apiLink, callback) => {
if(apiCacheData[apiLink]) {
return callback(apiCacheData[apiLink]);
}
request(apiLink, {timeout: 3000}, function (error, response, body) {
if(error) {
callback(false);
} else {
apiCacheData[apiLink] = body;
callback(body);
}
});
}
实际情况需要根据具体的接口性能来设置这个超时时间,避免超时时间过度影响服务,也避免时间过长无法达到效果。
优化完成后,同样我们再跑一遍测试数据:
- 首先还是先去 api_server 中启动 api 服务;
- 接下来再启动本文件夹 network_io_opt 的服务;
- 启动完成后,再进行压测。
可以得到表 6 的压测对比数据。
从结果中可以看出优化效果非常明显,一个简单的优化就可以将原来 272.96 的 QPS 提升到 31503.58。
最后我们再来看看磁盘 I/O 的优化。
磁盘 I/O
磁盘 I/O 的优化分为读优化和写优化,优化的策略有:
- 为了提升性能需要将同步修改异步,避免影响主线程的性能;
- 读优化,必须增加必要的缓存,减少相同文件的重复读取;
- 写优化,可以使用异步写文件的方式,先将写内容缓存到队列(如我们第一部分的第 08 讲的方案);
- 合并多次写操作,避免频繁打开文件,读写文件内容。
当然对于本讲,我们着重优化 2 点:
- 修改为异步
- 增加缓存
优化代码如下(代码在 disk_io_opt 中):
const server = http.createServer((req, res) => {
fs.readFile('./test_file.conf', (err, data) => {
if (err) {
res.write('error read file');
res.end();
} else {
res.write(data);
res.end();
}
});
});
接下来继续压测看下效果,如表 7 所示:
从上面看得出异步 I/O 对服务性能提升还是比较突出,也是比较关键的。如果文件大会更突出,因此在平时代码中要非常注重这点,减少同步读写的操作。
那么如果我们继续优化,增加缓存呢?我们来看下效果,修改下面代码:
let fileCache;
*
* 创建 http 服务,简单返回
*/
const server = http.createServer((req, res) => {
if(fileCache) {
res.write(fileCache);
res.end();
return;
}
fs.readFile('./test_file.conf', (err, data) => {
if (err) {
res.write('error read file');
res.end();
} else {
fileCache = data;
res.write(data);
res.end();
}
});
});
并且重新压测看下数据,如表格 8 所示。
从结果看已经和标准的数据非常接近,因此在 Node.js 开发过程中,要特别注意文件读取,避免相同文件的重复读取。从表格 8 中的异步和缓存数据对比来看,通过缓存的处理优化,就可以在 QPS 上从 18353.39 提升至 35058.79,有 91% 以上的性能提升。
总结
学完本讲,你应该要掌握两个工具的应用,对于服务端研发来说这些工具是非常重要的,我希望你能深入去实践应用这两个工具。其次了解 3 种影响性能因素的优化策略,同时在日常开发中,应尽量避免影响性能的代码逻辑。
那你在实际的工作中,是如何提升性能的呢,欢迎在评论区分享你的经验。
这一讲就讲完了,下一讲将讲解 “常见网络攻击以及防护策略”,到时见~
《大前端高薪训练营》
对标阿里 P7 技术需求 + 每月大厂内推,6 个月助你斩获名企高薪 Offer。点击链接,快来领取!