[Issue 1943] Node 使用火焰图优化 CPU 激增cpu使用率高

发布时间:2024-07-14浏览:8

前言

今天的晨读文章授权罗辑思维前端团队@浩男分享。

正文从这里开始~~

背景

废话不多说,上图,是App静态资源更新服务的CPU使用率监控,可以看到从7月2号到7月3号,CPU使用率一路暴涨,早上8点的高峰和下午6点的晚高峰,CPU几乎可以满负荷运行。发现问题后,我们马上把配置从2核4G升级到4核8G,先保证服务的稳定性,再继续排查问题。

下图是升级配置之后的截图,所以画面看起来平缓了很多,看到监控的时候,所有的峰值都在红线以上,虽然没有引起报警,但我还是悄悄拿出笔记本记下来,找时间检查一下问题所在。

问题分析

因为有明确的变更时间点,所以我能直接找到变更的地方,经过一点代码级别的review,并没有发现变更的代码有什么问题,作为一个小前端开发者,我从来没有遇到过这样的问题,完全不知道该怎么办,于是就把救星锁在火焰图上,看看时间都花在了哪里,CPU都被什么占用了。

火焰图

那么如何生成火焰图就成了我最大的问题,我开始在 Google 上搜索“如何生成火焰图”、“node flame graph”、“node cpu profiler”、“node flamegraph”。我看的所有文章都是一样的,其中 95% 以上都有以下解决方案。

1. Linux 性能

参考文章:nodejs调试指南perf+FlameGraph

Linux 自带一个系统性能分析工具,功能挺多的,我就不多说了,有兴趣的可以去看看nodejs调试指南第一页。由于使用限制,我不是Linux用户,第一步连apt install linux-tools-common都安装不了,如果要放到虚拟机里跑,又太麻烦了,下面给出解决办法。

2. Node.js 内置分析工具

参考文章:轻松分析 Node.js 应用程序 | Node.js

从Node.js 4.4.0开始,node本身可以记录进程中V8引擎的性能信息(profiler),只需要在启动的时候加上参数--prof就可以了。

Node自身的分析工具:

但是仔细一看,好像并不是这样,因为项目用的是egg框架,火焰图里所有的信息都是egg启动的东西,我的五分钟接口压力测试,火焰图里根本没有体现出来,我一拍脑袋,才想起来,我之前用node --prof形式采集到的性能数据都是egg主进程上的东西,而我们所有接口都打在egg worker上,根本就采集不到任何数据。对了,egg提供了单进程模式 RFC Add 单进程启动模式 · Issue #3180 · eggjs/egg · GitHub,不过还在实验阶段,Plan 2又夭折了,不过至少能看到个图。

3. 使用 DTrace 收集性能数据

直接找到应用的pid,直接采集pid即可。然后还可以把采集到的数据做成火焰图,具体操作就不赘述了,最终的图如下

都是一些v8低级玩意儿,好像没什么我想看的。计划三枚棋子。

好了,以上都是我从 Google 上搜到的解决方案,但是一个个踩坑之后,全部都以失败告终。其实还有一些更简单的解决方案,比如直接对接 alinode,使用阿里云的平台。一方面项目没有接入阿里云,使用的节点镜像不是阿里的,另一方面如果在开发环境就能发现问题,我也不想再上网查问题了。

解决方案 4:v8-profiler

Node.js 基于 V8 引擎。V8 暴露了一些 profiler API。我们可以使用 v8-profiler 来收集一些运行时 CPU 和内存数据。

在安装v8-profiler的时候,遇到了一些问题,安装总是失败,解决不了。还好有大神基于v8-profiler发布了v8-profiler-node8。下面是对v8-profiler-node8的说明。

基于 v8-profiler-node8@5.7.0,解决了 node-v8.x 中的 v8-profiler 段错误。v8-profiler-node8 为 v8 profiler 提供节点绑定并与 node-inspector 集成收集数据:简单地 npm install v8-profiler-node8 后,开始收集 CPU 配置文件五分钟。

cpu使用_cpu使用率高_zbarcpu使用率高

  1. const profiler = require('v8-profiler-node8');

  2. const fs = require('fs');

  3. const Bluebird = require('bluebird');


  4. class PackageController extends Controller {

  5. async cpuProf() {

  6. console.log('开始收集');

  7. // Start Profiling

  8. profiler.startProfiling('CPU profile');

  9. await Bluebird.delay(60000 * 5);

  10. const profile = profiler.stopProfiling();

  11. profile.export()

  12. .pipe(fs.createWriteStream(`cpuprofile-${Date.now()}.cpuprofile`))

  13. .on('finish', () => profile.delete());

  14. this.ctx.status = 204;

  15. }

  16. }

然后立即使用 ab 压力测试对服务施加压力。

  1. ab -t 300-c 10-p post.txt -T "application/json" http://localhost:7001/xxx/xxxxx/xxxxxx/xxxxx

采集完成后会得到一个cpuProfile文件,Chrome自带了分析CPU profile日志的工具,打开Chrome -> 调出开发者工具(DevTools) -> 点击右上角三个点按钮 -> 更多工具 -> JavaScript Profiler -> Load,加载刚刚生成的cpuprofile文件,可以直接用Chrome的性能分析读取这个文件,打开进行分析。

这里给大家推荐speedscope,一款根据cpuProfile生成火焰图的工具,它生成的火焰图比较清晰,并且还有leftHeavy模式,直接把CPU占用最高的CPU放在最左边,一目了然,快速定位问题。

使用火焰图解决问题

下面是火焰图的 leftHeavy 模式

看火焰图,曲线越锐利,越正常,横条越长,使用时间越长。从图中我们可以看到,在五分钟的压力测试中,CPU 占用时间长达两分钟,其中大部分被红色框框占据。下面是一张大图

这张火焰图从上到下展示了调用顺序,乍一看,并没有我业务代码中出现的内容,仔细一看,fetchDocs、Cursor.next、completeMany、Document.init好像都是mongo的东西,我当时开心得像个傻子,赶紧去搜源码。

该案例从mongoose中的方法completeMany开始解决,该方法用于将查询结果打包,使结果中的每个文档都成为一个mongoose文档,以便可以继续使用mongoose提供的方法,相关源码如下。

  1. /*!

  2. * hydrates many documents

  3. *

  4. * @param {Model} model

  5. * @param {Array} docs

  6. * @param {Object} fields

  7. * @param {Query} self

  8. * @param {Array} [pop] array of paths used in population

  9. * @param {Function} callback

  10. */

  11. function completeMany(model, docs, fields, userProvidedFields, pop, callback) {

  12. var arr = [];

  13. var count = docs.length;

  14. var len = count;

  15. var opts = pop ? { populated: pop } : undefined;

  16. var error = null;

  17. function init(_error) {

  18. if (error != null) {

  19. return;

  20. }

  21. if (_error != null) {

  22. error = _error;

  23. return callback(error);

  24. }

  25. --count || callback(null, arr);

  26. }

  27. for (var i = 0; i < len; ++i) {

  28. arr[i] = helpers.createModel(model, docs[i], fields, userProvidedFields);

  29. arr[i].init(docs[i], opts, init);

  30. }

  31. }

completeMany 方法会把每个传入的 doc 通过 helpers.createModel 转换成 mongoose Document,我们看一下 completeMany 方法在哪里被调用,可以看到在 find 方法中会判断 options.lean 是否等于 true,如果不等于 true 就会调用 completeMany 方法对查询结果进行打包。

  1. /**

  2. * Thunk around find()

  3. *

  4. * @param {Function} [callback]

  5. * @return {Query} this

  6. * @api private

  7. */

  8. Query.prototype._find = function(callback) {

  9. this._castConditions();

  10. if (this.error() != null) {

  11. callback(this.error());

  12. return this;

  13. }

  14. this._applyPaths();

  15. this._fields = this._castFields(this._fields);

  16. var fields = this._fieldsForExec();

  17. var options = this._mongooseOptions;

  18. var _this = this;

  19. var userProvidedFields = _this._userProvidedFields || {};

  20. var cb = function(err, docs) {

  21. if (err) {

  22. return callback(err);

  23. }

  24. if (docs.length === 0) {

  25. return callback(null, docs);

  26. }

  27. if (!options.populate) {

  28. // 看这里 重点重点!

  29. return !!options.lean === true

  30. ? callback(null, docs)

  31. : completeMany(_this.model, docs, fields, userProvidedFields, null, callback);

  32. }

  33. var pop = helpers.preparePopulationOptionsMQ(_this, options);

  34. pop.__noPromise = true;

  35. _this.model.populate(docs, pop, function(err, docs) {

  36. if (err) return callback(err);

  37. return !!options.lean === true

  38. ? callback(null, docs)

  39. : completeMany(_this.model, docs, fields, userProvidedFields, pop, callback);

  40. });

  41. };

  42. return Query.base.find.call(this, {}, cb);

  43. };

在文档中搜索lean mongoose query lean。文档中说,如果使用lean,查询将返回一个javascript对象,而不是Mongoose文档。原文如下。

启用 theleanoption 后查询返回的文档是普通的 javascript 对象,而不是 Mongoose 文档。它们没有保存方法、getters/setters、虚拟函数或其他 Mongoose 功能。

文档还提到,精益模式对于高性能只读情况非常有用。

优化

回到问题本身,看到mongoose Document的问题,突然意识到为什么在7月2号到7月3号的时候CPU突然上升了,之前回顾了一下代码,看到代码没有问题,但是忽略了这个版本因为业务调整导致查询压力上升很大,可能是过去的好几倍,然后我就把查询改成精简模式,只需要进行如下几个简单的修改即可。

  1. await model.Package.find(query).lean();

说到频繁处理 mongoose Document 带来的性能问题,其实还有一个可以做的优化点。其实就是在查询的时候,使用 find 的第二个参数projection,把需要返回的 key 投影出来。投影什么就投影什么,不要一下子就把所有的 key 值都返回。处理完这一系列之后,在本地重写同样的压力测试五分钟,生成火焰图。下图一是这五分钟内的火焰图,图二是 speedscope 分析之后的 leftHeavy 图,直接观察到重灾区。

从图一的火焰图上我们看不出明显的差别,但是一看到图二我们就知道我们的优化是有效的。从最直观的角度看,左边红框里的completeMany部分没有了,总的CPU占用时间也从近两分钟减少到了36秒。优化效果还是很明显的。

如图所示,优化后 CPU 使用率得到了很大改善,稳定在 15% 以内。问题得到解决,服务器降级后一切恢复正常。不过这次故障也让我在使用 mongoos 等 ODM 时更加谨慎。它给我们带来无限便利的同时,也可能因为一些额外的操作给我们的服务带来额外的负担。一般情况下,这种性能差距不容易察觉。但在高峰期或者大型活动时,这个小坑可能对服务造成更大的影响。

记住。

热点资讯