Skip to content

12性能分析:性能影响的关键路径以及优化策略

模块一的《06 | 哪些因素会影响 Node.js 性能?》,我们详细讲解了影响到 Node.js 性能的一些因素,但是在实际开发过程中,我们应该如何去定位影响性能的关键因素呢?定位到性能问题后,又该如何去优化这部分功能呢?以上就是本讲要介绍的核心知识点。

工具介绍

在讲解性能分析实践之前,我们先来看看性能分析所应用的两个比较关键的工具:

  • 压测所使用到的 WRK(Windows Research Kernel);

  • 性能分析所使用到的 Chrome 分析工具 JavaScript Profile。

WRK 的安装及参数

在压测工具上可选择的比较多,比如 Apache-ab 压测工具、Siege 及本讲所应用的 WRK。为了能够更好地利用多核的多线程并发测试,这里我们选择使用 WRK 来作为压测工具。我们看下该工具的安装以及一些常用参数。

  • Mac上使用软件包管理工具 Homebrew 来安装,使用如下命令即可:
powershell
brew install wrk
  • Linux上依次执行......的命令安装就可以(如果 Linux 上没有安装 GCC、Make 或者 Git,就需要先安装这几个工具)。
powershell
#下载命令
git clone https://github.com/wg/wrk.git 
#切换路径到wrk目录下
cd wrk  
#使用make命令编译环境
 
make
  • Windows上就非常遗憾了,因为这个工具不支持 Windows。但如果你是 Windows 10,可以切换到 Ubuntu 子系统的方式来安装,或者在 Windows 上安装 Linux 虚拟机也是可以的。

成功安装后,你可以在命令行使用......命令查看具体的参数说明和介绍:

powershell
wrk

这一讲因为需要进行并发请求的验证,所以我们会使用下面的压测命令:

powershell
wrk -t4 -c300 -d20s https://www.baidu.com/

其参数说明如下:

  • -t 代表的是启动 4 个线程;

  • -c 代表的是并发数,300 个并发请求;

  • -d 代表的是持续时长,20s 就是 20 秒。

我们运行上面的命令后,会有相应的压测结果,如下所示:

powershell
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 演示

为了演示这个分析方式,我们写一个最简单的测试代码,如下所示:

javascript
const http = require('http');
// 引入 v8-profiler 库,可以作为一个中间件来实现
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 库,接下来看看这个库的逻辑。

javascript
'use strict';
const v8Profiler = require('v8-profiler-next');
const fs = require('fs');
// 设置采集数据保存的文件名
const title = 'example';
module.exports = {
    'start' : () => {
        // 启动采集,如果需要定时采集,可以将 title 设置为一个动态的根据时间变化的值
        v8Profiler.startProfiling(title, true);
        setTimeout(() => { // 30 秒后采集并导出
          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)打开项目,进入项目根目录,执行命令启动服务。

java
npm run test

(2)打开另外一个命令窗口,开始执行压测程序。

java
wrk -t2 -c300 -d20s http://127.0.0.1:3000/

(3)大概 30 秒后,项目目录下的 cpu_profiler 文件夹下会生成 example.cpuprofile 文件。

(4)打开 Chrome 工具中的 JavaScript Profiler,然后 load 刚才项目目录下的 cpu_profiler/example.cpuprofile 文件,就可以看到如图 2 所示的结果。

图 2 cpuprofile 演示结果

从上面的结果可以看到相应的单个执行时间和总的耗时。如果性能较慢,你可以参照标准的结果来进行分析,或者对比一个性能较好、一个性能较差的执行结果。为了让你更清晰地了解这些,下面我将从实践来进行分析。

实践分析

在分析任何数据之前,首先必须有一个标准的数据进行比较,如果你用的是 Express、Eggjs 等框架,需要做一个完全空转的数据作为标准分析数据。在本讲,由于我们没有用任何框架,所以需要设计一个完全空转的 HTTP 服务来作为标准的分析数据。

标准数据

这里我们还是用上面"简单 Demo 演示"中的代码,然后通过 WRK 下面的压测命令来压测,看下具体的数据情况。

java
wrk -t2 -c300 -d20s http://127.0.0.1:3000/

压测得到的结果如表 1 所示。

有了这份数据后,我们再来逐个分析以下问题。

CPU 计算耗时

为了验证效果,这里我们写一个 CPU 计算耗时的逻辑,然后继续压测。这点在模块一的《06 | 哪些因素会影响 Node.js 性能?》已经有一些例子,我们拿一个出来尝试一下,比如 MD5 计算的逻辑,代码如下:

javascript
crypto = require('crypto');
module.exports = (content) => {
    return crypto.createHash('md5').update(content).digest("hex")
}

核心逻辑就是应用 crypto 来生成 MD5 加密数据,为了效果更好,我们在入口文件 index.js 中多调用几次,这里只修改 server 中的代码逻辑,代码如下:

javascript
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 文件项目中,核心代码如下:

javascript
const http = require('http');
/**
 * 
 * 创建 http 服务,简单返回
 */
const server = http.createServer((req, res) => {
    setTimeout(() => { // 延迟 1 秒返回
        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 库(后续这个库不会维护了,暂时还没有替代方案,除非自己手动实现),代码如下:

javascript
// 引入 request 库,需要在 package.json 中申明,并且 npm install
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 的修改,代码如下:

javascript
/**
 * 
 * 创建 http 服务,简单返回
 */
const server = http.createServer((req, res) => {
    callApi('http://127.0.0.1:4000', (ret) => { // 调用 4000 服务,并显示返回结果
        if(ret) {
            res.write(ret);
        } else {
            res.write('call api server error');
        }
        res.end();
    });
});

接下来就启动两个服务,分别打开地址:

看下是否正常响应,正常返回数据后,我们再启动对 3000 服务的压测,运行如下命令:

java
wrk -t2 -c300 -d20s http://127.0.0.1:3000/

接下来我们看下压测后的数据,如表 3 所示:

拿到压测数据后,同样按照 CPU 分析方法,可以看到如图 4 所示的性能分析结果。

图 4 网络 I/O 性能分析数据

其中有一个 connect,该模块在 net.js 中,这里就可以得到是网络 I/O 引起的问题。

最后我们再来看下 磁盘 I/O 的问题。

磁盘 I/O

这里主要使用 Node.js 的 fs 模块来读取本地的文件,并显示返回文件的内容,核心代码是 server 回调部分,代码如下:

javascript
const server = http.createServer((req, res) => {
    let ret = fs.readFileSync('./test_file.conf');
    res.write(ret);
    res.end();
});

接下来,我们同样用如下命令来启动压测服务:

java
wrk -t2 -c300 -d20s http://127.0.0.1:3000/

压测后将得到如表格 4 所示的结果。

从结果看也是存在一定损耗的,具体在哪方面影响到性能,同样用 Chrome 工具载入该服务采集的 CPU 信息,如图 5 所示。

图 5 磁盘 I/O CPU 采集信息

从图 5 中可以非常清晰地看到前面几个耗时较长的都是关于文件读写相关的模块,如上面红色圈里面的信息。

优化以及效果

上面已经介绍到了那么多性能影响的部分,那么接下来看看如何进行一些优化,来提升性能。

CPU 计算耗时

这部分只能说减少操作,或者减少运算。像我们上面的例子,如果都是一样的 MD5 计算,那么增加一个短时间的缓存就可以了。当然这里我们可以直接用内存来缓存(实际开发过程中,不能使用内存的方式,因为会造成内存使用越来越大,一般使用共享内存,并短时间保存即可),代码如下(代码保存在 cpu_opt 中):

javascript
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 中):

javascript
// 引入 request 库,需要在 package.json 中申明,并且 npm install
const request = require('request');
// 缓存 api 请求结果数据
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 部分增加超时处理,代码如下:

javascript
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 中):

javascript
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 对服务性能提升还是比较突出,也是比较关键的。如果文件大会更突出,因此在平时代码中要非常注重这点,减少同步读写的操作。

那么如果我们继续优化,增加缓存呢?我们来看下效果,修改下面代码:

javascript
// 文件缓存
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 种影响性能因素的优化策略,同时在日常开发中,应尽量避免影响性能的代码逻辑。

那你在实际的工作中,是如何提升性能的呢,欢迎在评论区分享你的经验。

这一讲就讲完了,下一讲将讲解"常见网络攻击以及防护策略",到时见~


[

](https://shenceyun.lagou.com/t/mka)

《大前端高薪训练营》

对标阿里 P7 技术需求 + 每月大厂内推,6 个月助你斩获名企高薪 Offer。点击链接,快来领取!