前言
今天的晨读文章授权罗辑思维前端团队@浩男分享。
正文从这里开始~~
背景
废话不多说,上图,是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 配置文件五分钟。

const profiler = require('v8-profiler-node8');
const fs = require('fs');
const Bluebird = require('bluebird');
class PackageController extends Controller {
async cpuProf() {
console.log('开始收集');
// Start Profiling
profiler.startProfiling('CPU profile');
await Bluebird.delay(60000 * 5);
const profile = profiler.stopProfiling();
profile.export()
.pipe(fs.createWriteStream(`cpuprofile-${Date.now()}.cpuprofile`))
.on('finish', () => profile.delete());
this.ctx.status = 204;
}
}
然后立即使用 ab 压力测试对服务施加压力。
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提供的方法,相关源码如下。
/*!
* hydrates many documents
*
* @param {Model} model
* @param {Array} docs
* @param {Object} fields
* @param {Query} self
* @param {Array} [pop] array of paths used in population
* @param {Function} callback
*/
function completeMany(model, docs, fields, userProvidedFields, pop, callback) {
var arr = [];
var count = docs.length;
var len = count;
var opts = pop ? { populated: pop } : undefined;
var error = null;
function init(_error) {
if (error != null) {
return;
}
if (_error != null) {
error = _error;
return callback(error);
}
--count || callback(null, arr);
}
for (var i = 0; i < len; ++i) {
arr[i] = helpers.createModel(model, docs[i], fields, userProvidedFields);
arr[i].init(docs[i], opts, init);
}
}
completeMany 方法会把每个传入的 doc 通过 helpers.createModel 转换成 mongoose Document,我们看一下 completeMany 方法在哪里被调用,可以看到在 find 方法中会判断 options.lean 是否等于 true,如果不等于 true 就会调用 completeMany 方法对查询结果进行打包。
/**
* Thunk around find()
*
* @param {Function} [callback]
* @return {Query} this
* @api private
*/
Query.prototype._find = function(callback) {
this._castConditions();
if (this.error() != null) {
callback(this.error());
return this;
}
this._applyPaths();
this._fields = this._castFields(this._fields);
var fields = this._fieldsForExec();
var options = this._mongooseOptions;
var _this = this;
var userProvidedFields = _this._userProvidedFields || {};
var cb = function(err, docs) {
if (err) {
return callback(err);
}
if (docs.length === 0) {
return callback(null, docs);
}
if (!options.populate) {
// 看这里 重点重点!
return !!options.lean === true
? callback(null, docs)
: completeMany(_this.model, docs, fields, userProvidedFields, null, callback);
}
var pop = helpers.preparePopulationOptionsMQ(_this, options);
pop.__noPromise = true;
_this.model.populate(docs, pop, function(err, docs) {
if (err) return callback(err);
return !!options.lean === true
? callback(null, docs)
: completeMany(_this.model, docs, fields, userProvidedFields, pop, callback);
});
};
return Query.base.find.call(this, {}, cb);
};
在文档中搜索lean mongoose query lean。文档中说,如果使用lean,查询将返回一个javascript对象,而不是Mongoose文档。原文如下。
启用 theleanoption 后查询返回的文档是普通的 javascript 对象,而不是 Mongoose 文档。它们没有保存方法、getters/setters、虚拟函数或其他 Mongoose 功能。
文档还提到,精益模式对于高性能只读情况非常有用。
优化
回到问题本身,看到mongoose Document的问题,突然意识到为什么在7月2号到7月3号的时候CPU突然上升了,之前回顾了一下代码,看到代码没有问题,但是忽略了这个版本因为业务调整导致查询压力上升很大,可能是过去的好几倍,然后我就把查询改成精简模式,只需要进行如下几个简单的修改即可。
await model.Package.find(query).lean();
说到频繁处理 mongoose Document 带来的性能问题,其实还有一个可以做的优化点。其实就是在查询的时候,使用 find 的第二个参数projection,把需要返回的 key 投影出来。投影什么就投影什么,不要一下子就把所有的 key 值都返回。处理完这一系列之后,在本地重写同样的压力测试五分钟,生成火焰图。下图一是这五分钟内的火焰图,图二是 speedscope 分析之后的 leftHeavy 图,直接观察到重灾区。
从图一的火焰图上我们看不出明显的差别,但是一看到图二我们就知道我们的优化是有效的。从最直观的角度看,左边红框里的completeMany部分没有了,总的CPU占用时间也从近两分钟减少到了36秒。优化效果还是很明显的。
如图所示,优化后 CPU 使用率得到了很大改善,稳定在 15% 以内。问题得到解决,服务器降级后一切恢复正常。不过这次故障也让我在使用 mongoos 等 ODM 时更加谨慎。它给我们带来无限便利的同时,也可能因为一些额外的操作给我们的服务带来额外的负担。一般情况下,这种性能差距不容易察觉。但在高峰期或者大型活动时,这个小坑可能对服务造成更大的影响。
记住。





























