前提
Node.js
是天才屌丝程序员Ryan Dahl
于2009年发布,经过几年的发展,Node.js
已经是成熟的JavaScript
运行时了。用Node.js
开发的应用被分发到世界各地的云主机上,随着公司的发展和壮大、应用PV
和UV
的剧增,如何保障Node.js
应用的高性能是如今作为一个Node.js
开发者必须面对的问题。
通过V8/Node
自带的profiler
能力
通过v8/Node
的profiler
能力,能够列出各函数的执行占比。
我们通过对一段经典简单的http服务的示例代码进行分析:
1 | // index.js |
1 | // util.js |
这个例子响应/test
路由,返回计算密集型处理的数据。
以--prof
参数标识启动Node
应用:
1 | $ node --prof index.js |
使用性能压测工具对接口http://127.0.0.1:3000/test
进行压测,这里使用wrk
,使用8个线程运行30秒的基准测试,并保持打开200个HTTP连接:
1 | $ wrk -t8 -c200 -d30s http://127.0.0.1:3000/test |
跑完基准测试,得到评估结果:
1 | Running 30s test @ http://127.0.0.1:3000/test |
从上面的接口评估结果可以看到,延迟平均有将近900ms
,qps
平均只有15.05
,这是非常坏的结果。
通过--prof
标识得到一个v8的log文件:isolate-0x103001000-v8.log
,该文件人眼难阅读:
1 | v8-version,6,8,275,32,-node.36,0 |
通过--prof-process
处理该文件:
1 | $ node --prof-process isolate-0x103001000-v8.log > profile.txt |
先查看总览部分:
1 | [Summary]: |
可以看到,在收集的样本中有97%
发生在C++
代码中,再去看看C++
代码中发生了什么事:
1 | [C++]: |
排名前三的条目占用了83.3%
的CPU时间和82.6%
的栈调用。从这个输出可以看到HashUpdate
函数占用了45%
的CPU时间,从该函数看不出是由哪里的代码产生的问题,接下来看看[Bottom up (heavy) profile]
部分, 这部分提供了每个函数的主要调用者的信息:
1 | 12361 44.6% T node::crypto::Hash::HashUpdate(v8::FunctionCallbackInfo<v8::Value> const&) |
在上面的每个调用栈中,看到每个函数占用父类的百分比:
util.js:23:26(server.on)
占用了Builtin_HandleApiCall
函数98%
的时间,Builtin_HandleApiCall
函数占用了HashUpdate
函数100%
的时间util.js:23:26(server.on)
占用了Builtin_JsonStringify
函数99%
的时间,Builtin_JsonStringify
函数占用了SerializeString
函数100%
的时间util.js:23:26(server.on)
占用了Builtin_HandleApiCall
函数98%
的时间,Builtin_HandleApiCall
函数占用了New
函数100%
的时间
综合上面的百分比可以看到util.js
中的server.on
是我们此次优化的目标。再看到util.js
中,结合上面的信息,热点代码出现在events
上,先看下面的代码:
1 | require('events').defaultMaxListeners = Infinity |
这里设置了events
的默认最大句柄数是1e309
, 如果不修改默认配置,Node.js
配置的events
的默认最大句柄数是10
, 也就是一个实例只能监听同一个事件10
次。把这行代码注释掉,然后以--trace-warnings
标识启动应用,该标识可以打印进程警告的堆栈跟踪:
1 | $ node --trace-warnings index.js |
再次进行压测,可以看到警告信息:
1 | (node:11356) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 after listeners added. Use emitter.setMaxListeners() to increase limit |
句柄达到了11
个,可以知道代码中有大量发生server.on
监听事件的行为。
查看代码中逻辑,看到了一个问题:
1 | function etagger () { |
看到有个条件永远满足,所以每次请求就会产生一次server.on("after", () => {})
,解决这个bug:
1 | // if (attachAfterEvent === true) return |
再次进行压测,得到评估结果:
1 | Running 30s test @ http://127.0.0.1:3000/test |
qps
平均值增加了48
倍,延迟平均值减少了23
倍!后面还有一些优化空间,比如:
JSON.stringify
crypto.createHash
crypto.rng
这几个API都是CPU计算大户,换另外种实现方式可以将qps
再次增加几倍。
通过火焰图可视化分析
火焰图(flamegraph)
是brendangregg
处理MySQL性能问题时发明的。它利用常规的剖析器/示踪器得到的文本产生可视化,允许快速准确地识别最频繁的代码路径, 可以更快的找出热点代码和调用堆栈之间的关系。恢复上面代码原来的样子,我们使用0x(github@davidmarkclements)
这个工具进行分析,它可以通过分析CPU profile
文件生成svg火焰图。
举个例子说明火焰图的调用堆栈,伪代码如下:
1 | function a() { |
对应的火焰图如下:
以下命令启动应用:
1 | $ 0x -o index.js |
然后用wrk
进行压测:
1 | $ wrk -t8 -c200 -d30s http://127.0.0.1:3000/test |
评估报告参考第一次压测结果。然后ctrl + c
退出当前进程, 等待分析并生成火焰图:
横轴表示抽样数,宽度越大表示该函数的抽取次数越多,也就是占用CPU总时间越多。每一层表示一个调用栈一个函数,调用栈越深火焰越高,每一层的父类在下一层。一个出现平顶的火焰图表示有可能瓶颈就是出现在最顶层的函数上。
*
表示经过v8
优化的代码,~
表示未经过优化的代码,如果优化状态对我们不重要,可以点击merge
按钮合并:
可以看到server.on
占用了大部分的CPU时间。现在开启的分析结果是app、依赖包、和node核心,现在点击v8
和cpp
开启内置的模块调用栈:
Node.js
的JSON解析器是直接用了v8的json引擎,所以看不到js的函数调用,只是看到了c++的调用函数SerializeString
,现在试着把JSON.stringify
去掉:
1 | server.on('after', (req, res) => { |
再次压测,得到的火焰图如下:
可以看到平顶的情况已经好很多了,但是更突出了一个问题,emit
函数成为了热点代码, 原因可能就是events
的句柄太多,去掉require('events').defaultMaxListeners = Infinity
这一行代码,检查代码并修复一处bug:
1 | if (afterEventAttached === true) return |
再次压测,得到的评估报告:
1 | Running 30s test @ http://127.0.0.1:3000/test |
得到的火焰图:
该火焰图表示已经改善了巨大的平顶的函数了,现在的热点代码是在node核心的定时器上了,现在得到的火焰图已经修复了最大的问题区域了,当然还是可以继续优化下去,直到火焰图的平顶函数越来越少。
如果长时间无法加载,请针对 disq.us | disquscdn.com | disqus.com 启用代理