一、背景
话不多说,先上图,这是得到 App 静态资源更新服务的 CPU 使用率监控,可以看到 7 月 2 号到 7 月 3 号后,cpu 使用率发生了爆涨,在八点的早高峰和下午六点的晚高峰,几乎可以把 cpu 打满。发现问题当机立断,升级配置将 2 核 4g 升级至 4 核 8g,先保证服务稳定,我们再继续查问题。
下图是升级配置后的截图,所以看到的图已经温柔很多了,本人当时看到监控的时候,所有波峰都是打在红线以上的,虽然还没有引起报警,但是默默掏出小本本记下找时间查问题。
因为有很明显的发生变化的时间点,直接能找到这一次的改动,经过一点点的代码级 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 自带的分析工具:
- 启动应用的时候,node 需要带上—-prof 参数
- 然后就会将性能相关信息收集到 node 运行目录下生成 isolate-xxxxxxxxxxxxx-v8.log 文件
- npm 有一个包可以方便的直接将 isolate 文件转换成,html 形式的火焰图GitHub - mapbox/flamebearer: Blazing fast flame graph tool for V8 and Node
完成以上步骤火焰图果不其然的跑了出来
可是仔细一看好像不是那么一回事,因为项目用的是 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 在使用时需要更加小心谨慎,他给我们带来了无限的便利的同时,可能也会因为一些额外的操作,让我们的服务承受额外的负担,正常情况下这一点性能差距不易察觉,然而到了高峰期,或者大型活动的时侯,可能就会因为这一点小坑,对服务造成更大的影响。
谨记。