Node 使用火焰图优化 CPU 爆涨

Node 使用火焰图优化 CPU 爆涨

一、背景

话不多说,先上图,这是得到 App 静态资源更新服务的 CPU 使用率监控,可以看到 7 月 2 号到 7 月 3 号后,cpu 使用率发生了爆涨,在八点的早高峰和下午六点的晚高峰,几乎可以把 cpu 打满。发现问题当机立断,升级配置将 2 核 4g 升级至 4 核 8g,先保证服务稳定,我们再继续查问题。

下图是升级配置后的截图,所以看到的图已经温柔很多了,本人当时看到监控的时候,所有波峰都是打在红线以上的,虽然还没有引起报警,但是默默掏出小本本记下找时间查问题。

IMG

因为有很明显的发生变化的时间点,直接能找到这一次的改动,经过一点点的代码级 review,并没有发现变动的代码上有什么问题。作为一个小前端没遇到过这种问题呀,毫无头绪的我,把救世主锁定在了火焰图身上,想看一看到底什么地方耗时长到底 cpu 占用在了什么东西上。

二、火焰图

于是怎么生成火焰图成了我最大的难题,开始 Google 搜索,“如何生成火焰图” ,“node 火焰图”,“node cpu profiler”, “node flamegraph”。看来看去所有文章千篇一律,95%以上的文章都是如下解决方案。

方案一:Linux perf

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

Linux 自带的系统性能分析工具,一堆功能我就不多说了,有兴趣的自己去看nodejs 调试指南打开书的第一页。因为使用的局限性不是 Linux 的我,第一步 apt install linux-tools-common 都安不上,如果还要跑在虚拟机什么的上面是不是太麻烦了,方案一卒。

方案二:Node.js 自带的分析工具

参考文章:易于分析的 Node.js 应用程序 | Node.js

Node.js4.4.0 开始,node 本身就可以记录进程中 V8 引擎的性能信息(profiler),只需要在启动的时候加上参数–prof。
Node 自带的分析工具:

  1. 启动应用的时候,node 需要带上—-prof 参数
  2. 然后就会将性能相关信息收集到 node 运行目录下生成 isolate-xxxxxxxxxxxxx-v8.log 文件
  3. npm 有一个包可以方便的直接将 isolate 文件转换成,html 形式的火焰图GitHub - mapbox/flamebearer: Blazing fast flame graph tool for V8 and Node
    完成以上步骤火焰图果不其然的跑了出来

IMG

可是仔细一看好像不是那么一回事,因为项目用的是 egg 框架,火焰图里的全部信息都是 egg 启动的东西啊,我长达五分钟的接口压测,一点都没有体现在火焰图上,一拍脑袋,想起来我用 node –prof 的形式收集到的性能数据都是 egg 主进程上的东西,而我们所有的接口全都打到了 egg worker 上去了,一点都没有收集到。顺便提一句 egg 提供了单进程模式RFC 增加单进程启动模式 · Issue #3180 · eggjs/egg · GitHub但还只是实验阶段。方案二又卒,好在我起码看到了一张图。

方案三:使用 Dtrace 收集性能数据

直接查到应用的 pid 直接对 pid 进行收集,然后也可以将收集到的数据制成火焰图,具体操作就不做赘述了,最后跑出来的图如下,

图三

全部是一些 v8 底层的东西,好像也没有我想要看的内容呀,方案三卒。

好了以上就是我 Google 出来的各种方案在我一一踩坑后全部以失败告终,其实也还有一些更简单的方案,例如直接接入 alinode 用阿里云的平台就好,一方面该项目没有接入阿里云,刚好用的 node 镜像又不是 ali 的,另一方面,如果可以在开发环境查出问题,不希望再通过上线去查问题。

方案四:v8-profiler

Node.js 是基于 V8 引擎的,V8 暴露了一些 profiler API,我们可以通过 v8-profiler 收集一些运行时的 CPU 和内存数据。
在安装 v8-profiler 的时候遇到了一些问题总是安装失败,并且得不到解决。不过好在有大神基于 v8-profiler 发布了 v8-profiler-node8,下面是 v8-profiler-node8 的一段描述。

Based on v8-profiler-node8@5.7.0, Solved the v8-profiler segment fault error in node-v8.x.
v8-profiler-node8 provides node bindings for the v8 profiler and integration with node-inspector
收集数据:
简单的 npm install v8-profiler-node8 后,开始收集 CPU profile,收集时长五分钟。

1
const profiler = require("v8-profiler-node8");
2
const fs = require("fs");
3
const Bluebird = require("bluebird");
4
5
class PackageController extends Controller {
6
  async cpuProf() {
7
    console.log("开始收集");
8
    // Start Profiling
9
    profiler.startProfiling("CPU profile");
10
    await Bluebird.delay(60000 * 5);
11
    const profile = profiler.stopProfiling();
12
    profile
13
      .export()
14
      .pipe(fs.createWriteStream(`cpuprofile-${Date.now()}.cpuprofile`))
15
      .on("finish", () => profile.delete());
16
    this.ctx.status = 204;
17
  }
18
}

然后立即用 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) -> 单击右上角三个点的按钮 -> More tools -> JavaScript Profiler -> Load,加载刚才生成的 cpuprofile 文件。可以直接用 chrome 的性能分析直接读这个文件打开分析。
这里我要推荐一下 speedscope 一个根据 cpuProfile 生成火焰图的工具,他生成的火焰图,更清晰,还有 leftHeavy 模式,直接将 CPU 占用率最高的排在最左边,一目了然,快速的可以定位到问题。

图四

三、根据火焰图解决问题

下面是该火焰图的 leftHeavy 模式

图五

看火焰图的时候越图形越尖说明越正常,横条越长说明占用时间越长,从图中可以看到压测的五分钟里,CPU 占用时间长达两分钟,其中绝大多数被红框中占据,来张大图

图六

这个火焰图中是由上至下的调用顺序,一眼看过去没有我业务代码中出现的内容,再仔细一看,fetchDocs、Cursor.next、completeMany、Document.init 这好像是 mongo 的东西呀,开心的像个傻子,赶快去搜源码。
从 completeMany 这里破案了,这是 mongoose 中的一个方法,作用是将查询到的结果进行包装,使结果中的每一个文档成为 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 方法会将传入的每一个 docs 通过 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(
32
            _this.model,
33
            docs,
34
            fields,
35
            userProvidedFields,
36
            null,
37
            callback
38
          );
39
    }
40
    var pop = helpers.preparePopulationOptionsMQ(_this, options);
41
    pop.__noPromise = true;
42
    _this.model.populate(docs, pop, function(err, docs) {
43
      if (err) return callback(err);
44
      return !!options.lean === true
45
        ? callback(null, docs)
46
        : completeMany(
47
            _this.model,
48
            docs,
49
            fields,
50
            userProvidedFields,
51
            pop,
52
            callback
53
          );
54
    });
55
  };
56
  return Query.base.find.call(this, {}, cb);
57
};

去文档上搜一下 lean mongoose query lean 文档上说了如果使用了 lean 那么查询返回的将是一个 javascript objects, not Mongoose Documents 。原话如下。

Documents returned from queries with theleanoption enabled are plain javascript objects, not Mongoose Documents . They have nosavemethod, getters/setters, virtuals, or other Mongoose features.
在文档中还提到了,lean 精简模式,对于高性能只读的情况是非常有用的

四、后续优化

回到问题上来,看到 mongoose Document 的问题,7 月 2 号到 7 月 3 号后,为什么会突然导致 CPU 暴涨恍然大悟,自己之前 review
代码,看着代码没问题,但是忽略了这一个版本因为业务调整导致查询压力大大增加,可能是过去的好几倍这个问题。随即将查询改成精简模式。只需要如下很简单的几处修改即可。

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

那说到频繁的处理 mongoose Document 导致的性能问题,那其实还有一个优化点可以做,其实在查询的时候多多使用 find 的第二个参数 projection 去投影所需要返回的键,需要用什么就投影什么,不要一股脑把所有的键值一起返回了。处理完这一系列,重写在本地进行了一次同样的压测五分钟,出了一份火焰图,下面图 1 就是这五分钟期间的火焰图,图二就是经过 speedscope 解析过后的 leftHeavy 图,直接观察重灾区。

图七
图八

从图一的火焰图中,并不能看出明显的区别,但是一看到图二就知道我们的优化是有效果的,从最直观的,原本左侧红框中 completeMany 的部分直接没有了,然后 cpu 占用的总时长也由原本的接近两分钟直接降到了 36s,优化效果还是十分明显的。上线观察几天看看效果

图九

如图可以看到,cpu 使用率在优化后得到了大大提升,并且稳定在了百分之十五以内。问题解决了,一切皆大欢喜,服务器降配一切回到正常。但这次故障也让我对诸如 mongoos 这样的 ODM 在使用时需要更加小心谨慎,他给我们带来了无限的便利的同时,可能也会因为一些额外的操作,让我们的服务承受额外的负担,正常情况下这一点性能差距不易察觉,然而到了高峰期,或者大型活动的时侯,可能就会因为这一点小坑,对服务造成更大的影响。

谨记。

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×