你是否遇到过这样的困境: 借助 Node.js 的事件驱动与异步I/O以为能顺风顺水的提高服务的吞吐量,实际测试结果却每秒只能处理 5个 请求? 在实际将 Node.js 应用于生产环境中,你是否心里面也有一个大大的问号关于 Node.js 的性能瓶颈?本文通过一个案例分析帮你加深理解 Node.js 性能分析,帮助你应对 Node.js 带给我们便利的同时带来的新的挑战。 本文通过 profiling node.js 应用展开深入分析,挖掘出那些对性能有影响的关键瓶颈,并实施改进措施,以实现吞吐量的大幅提升。
开胃菜
在开发 Node.js 应用时,类库的使用是必不可少的环节。其中,像 fs、http 这类原生类库,它们借助 C++ binding 与操作系统底层交互来实现功能,这一实现方式确保了文件操作和网络通信等功能的高效执行,这是毋庸置疑的事实。
然而,我们需要深入思考一个问题:是否只有原生类库才运用这种高效的实现方式呢?显然不是。当前市场上的类库繁多,其中不乏许多通过 C++ 实现以提升性能的类库,比如加密类库 bcrypt ,一个典型的 CPU 密集型操作。
安装这类库的时候,他有个典型的特征: 他都需要下载和编译在 npm i 的时候,比如你在安装 canvas 时,通过 npm 添加 npm i canvas --verbose 参数你会看到以下信息:
npm info run canvas@2.11.2 install node_modules/canvas node-pre-gyp install --fallback-to-build --update-binary
npm info run canvas@2.11.2 install { code: 0, signal: null }
node-pre-gyp 运行的时候,就说明你这个库用到了 C++ 插件调用了操作系统底层,需要在安装时候才能根据你是什么操作系统,给你支持什么操作性系统的包,node-pre-gyp 先对这个 C++ 插件进行编译后,才能在你的 node.js 应用中使用。
为了提高库的安装速度,一般作者会提供几个预先编译好程序 for 主流的操作系统。那 node-pre-gyp 会直接进行下载和安装。 但是当没找到这个预编译好的程序,他会触发构建,在你安装时进行编译,比如下面这样
node-pre-gyp info it worked if it ends with ok
node-pre-gyp info using node-pre-gyp@0.14.0
node-pre-gyp info using node@14.17.0 | darwin | x64
node-gyp info find Python using Python version 3.8.2 found at "/usr/local/bin/python"
node-gyp info spawn /usr/local/bin/python
node-gyp info spawn args [
'/Users/user/.nvm/versions/node/v14.17.0/lib/node_modules/npm/node_modules/node-gyp/gyp/gyp_main.py',
'binding.gyp',
'-f',
'make',
'-I',
'/Users/user/projects/my-project/node_modules/my-native-module/build/config.gyp.i',
'-I',
'/Users/user/.nvm/versions/node/v14.17.0/lib/node_modules/npm/node_modules/node-gyp/addon.gyp.i',
'-I',
'/Users/user/Library/Caches/node-gyp/14.17.0/include/node/common.gyp.i',
'-Dlibrary=shared_library',
]
node-gyp info spawn make
node-gyp info spawn args [ 'BUILDTYPE=Release', '-C', 'build' ]
CXX(target) Release/obj.target/my-native-module/my-native-module.o
SOLINK_MODULE(target) Release/binding.node
node-pre-gyp info ok
通过这个例子,我们明白了一个道理,当你需要提供一些 CPU 密集型操作的库时,使用 C++ 模块是一个惯用做法。
服务突然卡成PPT 😅
事情是这样的,在监控大盘上看到某服务吞吐量很低。于是分析了一下服务的历史数据,发现以前是很高的,最近这段时间变得很低,推测可能是由于某个业务需求的实现导致程序性能下降。如何排查呢?几百次的 commit 记录还跨着团队,如同大海捞针。
我先在测试环境通过 ab 对服务做了一个 benchmark,确定不是监控数据出问题,而是服务性能确实有问题
ab -n 500 -c 100 https://HOST/endpoint
得出结果如下:
Requests per second 才 272, 这么多 CPU 和 内存加上去了 一秒钟才能处理 272 个请求?实锤服务性能出现了问题。本着为公司省钱的目的,开始通过技术手段来排查这个服务的性能瓶颈。
通过 Profiling Node.js 识别性能瓶颈
在思考服务器性能时我们都在做一个平衡,一方面是来自业务量带来的吞吐量要求,另一方面是如何将硬件资源更好的利用,来降低硬件的成本。对于 Node.js 应用我们也不例外,首先用 Node.js 自带的 Profiling 工具对 CPU 的使用做一个调查。
Profiling 是一种分析程序性能的技术,它可以帮助我们了解程序在运行时的行为,找出性能瓶颈所在。
下面我们修改一下程序的运行命令,在运行时加入 --prof 选项:
node --prof app.js
然后我们就可以到一个 isolate-0x7fcef2c4d000-60450-v8.log 的文件,在项目路径下如下:
通过分析这个文件我们就可以了解 Node.js 程序运行时的共享库使用、时间消耗、内存分配和代码创建等多个方面的性能相关情况。但是如上所示,目前我们根本看不懂这个文件,这个文件目前的格式只是 "便于存储", 我们需要另一个命令来分析这个文件,也是 Node.js 自带的。
node --prof-process isolate-0x7fcef2c4d000-60450-v8.log > processed.txt
分析生成的processed.txt文件,我们可以看到各个函数的执行时间、调用次数等信息,来找到性能到底是被谁拉低了。
分析结果显示:
[Summary]:
ticks total nonlib name
19557 95.7% 99.8% JavaScript
89 0.4% 0.5% C++
0 0.0% 0.0% GC
哇!JavaScript部分占用了99.8%的CPU时间, 难道是有人在用 Node.js 做 CPU 密集型的任务?这里的 ticks 不是一个具体的时间,你可以理解成时间的切片,ticks 越多通常表示它占用的时间越多。
继续查看文件, 在 JavaScript 这个部分我们找到了罪魁祸首 gaussianBlur,什么鬼?高斯模糊?这个函数占用了大量的 CPU 计算。
[JavaScript]:
ticks total nonlib name
12105 61.6% 61.8% LazyCompile: *gaussianBlur /app/server.js:15:23
...
[C++]:
ticks total nonlib name
35 0.2% node::Start(int, char**)
...
[Bottom up (heavy) profile]:
ticks parent name
12105 61.6% LazyCompile: *gaussianBlur /app/server.js:15:23
├─8473 70.0% Function: processPixel /app/server.js:35:20
│ └─4892 57.7% Function: exp native math.js:178:12
└─2560 21.1% LazyCompile: *calculateWeight /app/server.js:45:24
通过查找提交这段代码的记录很快定位到了相关开发人员,开发人员说有个需求想给图片模糊一下,想着不增加额外依赖,以最简单的方案实现,于是产生了这次性能问题。看来这个简单,其实不简单。
和开发人员沟通这里实现方案使用第三方库做模糊图片的计算,还记得前面的开胃菜吗?没错,就是通过 canvas
库来实现这个功能,为什么第三方库的性能就高呢?不是因为计算逻辑更优,而是因为这是一个 C++ 库,更适合做 CPU 密集型的操作。
开发人员修改方案后,重新对程序做了一个性能测试:
性能提高了 50%, 每秒可以处理 400 个请求, 并且返回速度也从之前的 400+ms 降低到了 200+ms。
是时候放大招了:C++插件来袭!💪
前面的解决方式是刚好可以通过开源的解决方案来处理,但如果遇到的问题没有现成的库呢? 我们可以自己实现一个 C++ 扩展来解决因为计算导致的性能问题。
先写一个高斯模糊的计算函数
// gaussian_blur.cpp
#include <node.h>
#include <cmath>
namespace gaussian_blur {
using v8::FunctionCallbackInfo;
using v8::Isolate;
using v8::Local;
using v8::Object;
using v8::Number;
using v8::Value;
void GaussianBlur(const FunctionCallbackInfo<Value>& args) {
Isolate* isolate = args.GetIsolate();
Local<Object> buffer = args[0].As<Object>();
int width = args[1].As<Number>()->Value();
int height = args[2].As<Number>()->Value();
int radius = args[3].As<Number>()->Value();
unsigned char* data = (unsigned char*)node::Buffer::Data(buffer);
// ... 这里把他用 JS 写的代码用 C++实现 ...
}
void Initialize(Local<Object> exports) {
NODE_SET_METHOD(exports, "gaussianBlur", GaussianBlur);
}
NODE_MODULE(gaussian_blur, Initialize)
}
写好这个文件后,Node.js 是不能直接调用的,大家可能都知道 Node.js 是基于 V8 的, 但是 V8 是个什么可能很多同学没有过多关注,后面会开一篇专门讲讲 V8,但是结论是,V8 不支持运行 C++ 代码。那么就会导致我们需要 间接 或者造个桥让 V8 调用 C++ 代码,这个桥就是编译器 node-gyp, 我们通过这个 python 写的工具来编译上面的 C++ 代码。
{
"targets": [{
"target_name": "gaussian_blur",
"sources": [ "gaussian_blur.cpp" ]
}]
}
编译 C++ 代码为啥不用 C++ 编译器要用 node-gyp 呢? 因为他不仅仅把 C++ 代码编译为你操作系统的二进制文件,还会创建一个 Node.js 模块,这个模块中的接口会告诉 V8 如何初始化模块,如何创建对象,设置属性,方法从而将 C++ 实现的功能暴露给 JS。 举个例子 🌰: 当 JS 传了一个数字参数给 C++ 函数,它会被转换为 C++的数字类型 int或者double, 同样 c++ 返回一个自己的数据类型,也会被转换成 V8 理解的数据类型,从而解决代码的差异性。
更详细的请见该系列后续文章,这里只要有个概念就行。
最后在Node.js中调用刚才 node-gyp 编译好的模块很简单。
const gaussianBlur = require('./build/Release/gaussian_blur');
app.post('/blur', async (req, res) => {
const image = req.body.image;
const result = gaussianBlur.gaussianBlur(image.data, image.width, image.height, 5);
res.send(result);
});
预告
本篇介绍了通过 Profiling 识别了 CPU 层面的瓶颈,实际的生产环境中,因为内存导致吞吐量降低的case也比比皆是,大家可以留言催更。
写在最后的
文本中用到的一些代码示例和真实业务差异其实很大,但是经过这次改进后,业务的效果提升非常明显 🎉
- 优化前:处理1920x1080图片需要2000ms
- 优化后:处理同样图片只需要150ms
- 吞吐量提升:原来每秒只能处理0.5张图片,现在可以处理6 - 7张!
- CPU使用率:从单核100 % 降到了30 % 左右
希望这些经验对其他 Node.js 栈的开发者有帮助,这里有一些经验 take away 🌟:
- 别迷信JavaScript,它不是万能的。CPU密集型任务考虑用C++
- 性能优化要基于数据,而不是猜测
- Node.js的生态系统提供了强大的性能分析工具,要好好利用
- 合理使用C++插件可以显著提升性能
最后提醒一下:性能优化要根据实际场景来决定。有时候可能用Worker Threads就够了,不一定要上C++这个重型武器。选择合适的优化方案比盲目追求极致性能更重要!
看到这里的同学,点个赞吧 ❤️