Node.js性能优化的8个技巧

本文概述

  • 1.更新到最新版本的Node.js
  • 2.使用fast-json-stringify加快JSON序列化
  • 3.改善promise表现
  • 4.正确编写异步代码
  • 5.优化V8 GC
  • 6.正确使用流
  • 7. C ++扩展比JavaScript快吗?
  • 8.使用node-clinic快速定位性能问题
1.更新到最新版本的Node.js 你只需升级Node.js版本就可以轻松提高性能, 因为几乎所有针对Node.js的较新版本都比旧版本更好。
每个版本的Node.js的性能改进主要来自两个方面:
  • V8的版本更新;
  • Node.js内部代码的更新优化。
例如, 在最新的V8 7.1中, 它在某些情况下优化了闭包的转义分析, 并提高了Array某些方法的性能:
Node.js性能优化的8个技巧

文章图片
随着版本的升级, Node.js的内部代码已得到显着优化。例如, 下图显示了Node.js版本升级时require的性能发生了变化:
Node.js性能优化的8个技巧

文章图片
在审查时, 将考虑提交给Node.js的每个PR是否会降低当前性能。还有一个专门的基准测试团队来监视性能变化, 你可以在此处检查每个版本的Node.js的性能变化:https://benchmarking.nodejs.org/
因此, 你完全不必担心新版本的node.js的性能, 如果发现新版本的性能下降, 欢迎提交问题。
如何选择Node.js的版本?
以下是Node.js的版本策略:
Node.js的版本主要分为Current和LTS。当前是指仍在开发中的Node.js的当前版本; LTS表示稳定的长期维护版本; Node.js将每六个月(每年的四月和十月)发布一次主要版本升级, 该主要版本将带来一些不兼容的升级;每年4月发行的版本(版本号为偶数, 例如v10)是LTS版本, 是长期受支持的版本。从发布之年的10月起, 社区将继续保持18 + 12个月(有效LTS + Maintenancece LTS);每年10月发布的版本(版本号为奇数, 例如当前的v11)只有8个月的维护期。
例如, 现在(2018年11月), Node.js Current的版本为v11, LTS版本为v10和v8。较旧的v6位于Maintenace LTS, 从明年4月起将不再维护。去年10月发布的v9版本已于今年6月终止维护。
发布 状态 代码名称 初始发行 主动LTS启动 维护LTS开始 生命的尽头
6.x Maintenance LTS Boron 2016-04-26 2016-10-18 2018-04-30 Aprial 2019
8.x 主动LTS Carbon 2017-05-30 2017-10-31 2019年四月 December 2019
10.x 主动LTS Dubnium 2018-04-24 2018-10-30 April 2020 April 2021
11.x Current Release 2018-10-23 2019年六月
12.x Pending 2019-04-23 2019年十月 2021年4月 2022年4月
对于生产环境, Node.js正式推荐最新的LTS版本。
2.使用fast-json-stringify加快JSON序列化 在JavaScript中, 生成JSON字符串非常方便:
const json = JSON.stringify(obj)

但是很少有人知道, 性能也可以在此处进行优化, 即使用JSON Schema加快序列化。
我们需要为JSON序列化识别大量字段类型。例如, 对于字符串类型, 我们需要在两边都添加“;对于数组类型, 我们需要遍历数组, 在对对象进行序列化后, 用分隔每个对象, 然后在每一侧添加[]。
但是, 如果你事先通过Schema知道了每个字段的类型, 则无需遍历和标识字段类型, 因为你可以直接序列化相应的字段, 从而大大减少了计算开销。这就是fast-json-stringfy的原理。
根据项目中的结果, 在某些情况下, 它甚至可以比JSON.stringify快10倍!
Node.js性能优化的8个技巧

文章图片
这是一个简单的示例:
const fastJson = require('fast-json-stringify') const stringify = fastJson({ title: 'Example Schema', type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' }, books: { type: 'array', items: { type: 'string', uniqueItems: true } } } })console.log(stringify({ name: 'Starkwang', age: 23, books: ['C++ Primier', 'John Alex'] })) //=> {"name":"Starkwang", "age":23, "books":["C++ Primier", "John Alex"]}

在Node.js的中间件业务中, 通常有很多数据可以使用JSON, 并且JSON的结构非常相似(如果使用TypeScript, 则更是如此)。在这些情况下, 使用JSON模式进行优化非常适合。
3.改善promise表现 promise是解决回调嵌套地狱的灵丹妙药。特别是由于async / await的全面普及, 它们与Promise一起已成为JavaScript异步编程的最终解决方案, 并且许多项目现在开始使用这种模式。
但是, 优雅的语法背后隐藏着性能成本。我们可以使用github上现有的基准测试项目进行测试。以下是测试结果:
filetime(ms)memory(MB) callbacks-baseline.js38070.83 promises-bluebird.js55497.23 promises-bluebird-generator.js58597.05 async-bluebird.js593105.43 promises-es2015-util.promisify.js1203219.04 promises-es2015-native.js1257227.03 async-es2017-native.js1312231.08 async-es2017-util.promisify.js1550228.74Platform info: Darwin 18.0.0 x64 Node.JS 11.1.0 V8 7.0.276.32-node.7 Intel(R) Core(TM) i5-5257U CPU @ 2.70GHz × 4

从结果中我们可以看到, 本地async / await + Promise的性能比回调差很多, 并且具有更大的内存占用。对于具有大量异步逻辑的中间件项目, 这里的性能开销是不容忽视的。
还可以发现, 性能损失主要来自Promise对象本身的实现。在V8中原生实现的Promise比第三方实现的Promise库(例如bluebird)要慢得多。而且异步/等待的语法不会导致过多的性能损失。
因此, 对于具有大量异步逻辑的轻量级计算中间件项目, 可以在代码中将全局Promise更改为bluebird的实现:
global.Promise = require('bluebird');

4.正确编写异步代码 使用async / await之后, 异步代码看起来会很漂亮:
const foo = await doSomethingAsync(); const bar = await doSomethingElseAsync();

但是有时候, 我们可能会忘记Promise带给我们的其他功能, 例如Promise.all()的并行功能:
// bad async function getUserInfo(id) { const profile = await getUserProfile(id); const repo = await getUserRepo(id) return { profile, repo } }// good async function getUserInfo(id) { const [profile, repo] = await Promise.all([ getUserProfile(id), getUserRepo(id) ]) return { profile, repo } }

而且Promise.any()(此方法不在ES6 Promise标准中, 你也可以使用标准Promise.race()代替)可以轻松实现更可靠, 更快速的调用:
async function getServiceIP(name) { // Get the service IPs from DNS and ZooKeeper, and use the one which returns successfully first. // Unlike Promise.race, an error is thrown only when both calls are rejected. return await Promise.any([ getIPFromDNS(name), getIPFromZooKeeper(name) ]) }

5.优化V8 GC 关于V8的垃圾回收机制, 有许多类似的文章, 因此在此不再赘述。
在开发代码时, 有一些陷阱:
陷阱1:将大对象用作缓存, 导致旧空间中的垃圾收集速度变慢。
例:
const cache = {} async function getUserInfo(id) { if (!cache[id]) { cache[id] = await getUserInfoFromDatabase(id) } return cache[id] }

在这里, 我们使用变量缓存作为缓存来加速用户信息的查询。多次查询后, 缓存对象将进入旧空间, 并且它将变得非常大。由于旧空间使用三色标记+ DFS进行GC的方式, 大型物体将增加直接花费在GC上的时间(并且还存在内存泄漏的风险)。
解:
  • 使用外部缓存(例如Redis)。实际上, 像Redis这样的内存数据库非常适合这种情况。
  • 限制本地缓存对象的大小。使用诸如FIFO或TTL之类的机制来清理对象中的缓存。
陷阱2:Young Space不足会导致GC频繁出现。
默认情况下, Node.js将64MB(64位计算机)的内存分配给Young Generation。但是, 由于Young Generation GC使用Scavenge算法, 因此实际上只能使用一半的内存, 即32MB。
当业务代码频繁生成大量小对象时, 该空间将很容易被填充, 从而触发GC。尽管年轻一代GC的速度比老一代GC快得多, 但是频繁使用GC仍然会对性能产生重大影响。在极端情况下, GC甚至可能占用总计算时间的大约30%。
解决方案是在启动Node.js时修改Young一代的内存上限并减少GC的数量:
node --max-semi-space-size=128 app.js

你可能会问, 年轻一代的记忆越大越好吗?
随着内存的增加, GC的数量会减少, 但是每个GC所需的时间也会增加。因此, 内存越大越好。
一般来说, 为Yong一代分配64MB或128MB是合理的。
6.正确使用流 Stream是Node.js中最基本的概念之一。 Node.js中与IO相关的大多数模块(例如http, net, f??s和repl)都基于各种Streams构建。
大多数开发人员都应该知道下面的经典示例。对于大文件, 我们不需要将其完全读取到内存中, 而可以使用Stream将其流式传输出来:
const http = require('http'); const fs = require('fs'); // bad http.createServer(function (req, res) { fs.readFile(__dirname + '/data.txt', function (err, data) { res.end(data); }); }); // good http.createServer(function (req, res) { const stream = fs.createReadStream(__dirname + '/data.txt'); stream.pipe(res); });

在业务代码中正确使用Stream可以大大提高性能。当然, 在实际业务中我们可能会忽略它。例如, 我们可以将renderToNodeStream用于通过React服务器端渲染的项目:
const ReactDOMServer require('react-dom/server') const http = require('http') const fs = require('fs') const app = require('./app')// bad const server = http.createServer((req, res) => { const body = ReactDOMServer.renderToString(app) res.end(body) }); // good const server = http.createServer(function (req, res) { const stream = ReactDOMServer.renderToNodeStream(app) stream.pipe(res) })server.listen(8000)

使用管道管理流
在过去的Node.js中, 处理流非常麻烦。例如:
source.pipe(a).pipe(b).pipe(c).pipe(dest)

一旦源, a, b, c和dest中的任何一个发生错误或关闭, 整个管道将停止。然后, 我们需要手动销毁所有流, 这在代码级别非常麻烦。
因此, 像水泵这样的库进入社区来自动控制流的破坏。 Node.js v10.0中有一个新功能:stream.pipeline, 它可以代替Pump以帮助我们更好地管理流。
一个官方的例子:
const { pipeline } = require('stream'); const fs = require('fs'); const zlib = require('zlib'); pipeline( fs.createReadStream('archive.tar'), zlib.createGzip(), fs.createWriteStream('archive.tar.gz'), (err) => { if (err) { console.error('Pipeline failed', err); } else { console.log('Pipeline succeeded'); } } );

实施自己的高性能流
你可能还需要在业务中实施自己的Stream。你可以参考文档:
  • 实施可读流
  • 实施可写流
尽管Stream很棒, 但是当你自己实现Stream时, 仍然存在性能隐患。例如:
class MyReadable extends Readable { _read(size) { while (null !== (chunk = getNextChunk())) { this.push(chunk); } } }

当我们调用new MyReadable()。pipe(xxx)时, 通过getNextChunk()获得的块将被推出直到读取结束。但是, 如果管道的下一个处理速度较慢, 则数据将累积在内存中, 从而导致内存使用量增加而GC速度降低。
方法是根据this.push()的返回值选择适当的行为。当返回的值为false时, 这意味着堆叠的块现在已满, 则应该停止读取。
class MyReadable extends Readable { _read(size) { while (null !== (chunk = getNextChunk())) { if (!this.push(chunk)) { return false } } } }

在官方的Node.js文章中已经描述了此问题:流中的反压
7. C ++扩展比JavaScript快吗? Node.js非常适合IO密集型应用程序和计算密集型业务, 许多人认为通过编写C ++ Addon来优化性能。但是实际上, C ++扩展并不是万能的, 而且V8的性能没有你想像的那么差。
例如, 我于今年9月将Node.js的net.isIPv6()从C ++迁移到JS的实现, 然后大多数测试用例的性能提高了10%到250%(在此处查看PR)。
JavaScript在V8上的运行速度比C ++扩展更快。这主要发生在与字符串和正则表达式相关的场景中, 因为V8中使用的正则表达式引擎是irregexp, 并且此正则表达式引擎比boost附带的引擎(boost :: regex)要快得多。
还值得注意的是, 在进行类型转换时, Node.js的C ++扩展会消耗很多性能。因此, 如果你不注意C ++代码, 则性能可能会大大降低。
这是另一篇比较相同算法下C ++和JS的性能的文章:如何使用Node.js本机插件增强性能。值得注意的结论是, 在C ++代码将参数中的字符串转换(将String :: Utf8Valu转换为std :: string)之后, 性能甚至不及JS实现的一半。只有使用NAN提供的类型封装后, 才能实现比JS高的性能。
【Node.js性能优化的8个技巧】在某些情况下, C ++扩展不一定比本机JavaScript更有效。如果你对C ++不太自信, 建议使用JavaScript, 因为V8的性能比你想象的要好得多。
8.使用node-clinic快速定位性能问题 有什么可以直接使用的东西吗?当然有
Node-clinic是Node.js性能诊断工具, 由NearForm开源, 可用于快速查找性能问题。
npm i -g clinic npm i -g autocannon

你首先需要启动服务过程:
clinic doctor -- node server.js

然后, 我们可以使用任何负载测试工具来运行负载测试, 例如来自同一创建者的自动加农炮(当然, 你也可以使用ab, curl或其他工具来执行负载测试。):
autocannon http://localhost:3000

负载测试完成后, 我们按ctrl + c键关闭由诊所启动的过程, 然后将自动生成报告。例如, 这是我们的一种中间件服务的性能报告:
Node.js性能优化的8个技巧

文章图片
从CPU使用率曲线可以看出, 中间件服务的性能瓶颈不是其自身的内部计算, 而是I / O的缓慢环节。诊所还告诉我们, 发现了潜在的I / O问题。
让我们使用诊所bubbleprof检测I / O问题:
clinic bubbleprof -- node server.js

在另一次负载测试之后, 我们得到了一个新的报告:
Node.js性能优化的8个技巧

文章图片
从报告中可以看到, 在整个运行期间, http.Server处于96%的时间处于挂起状态。如果检查详细信息, 我们会发现调用堆栈中有很多空框架。由于网络I / O的限制, CPU有很多空闲, 这在中间件业务中很常见。它还表明优化的方向不是服务, 而是服务器网关和相关服务的速度。
检查此处以了解如何阅读诊所bubbleprof生成的报告:https://clinicjs.org/bubblepr …
同样, 诊所也可以检测出服务内部的计算性能问题。让我们做一些事情, 使服务的性能瓶颈出现在CPU计算中。
让我们向某些中间件添加破坏性代码, 使空闲时间达到1亿次, 这非常占用CPU:
function sleep() { let n = 0 while (n++ < 10e7) { empty() } } function empty() { }module.exports = (ctx, next) => { sleep() // ...... return next() }

然后, 使用诊所医生并重复上述步骤以生成另一个性能报告:
Node.js性能优化的8个技巧

文章图片
这是同步计算阻塞异步队列的一个非常典型的示例。主线程上执行了大量计算, 这导致无法及时触发JavaScript的异步回调, 并且事件循环的延迟非常高。
对于此类应用程序, 我们可以继续使用诊所的火焰来准确确定进行密集计算的位置:
clinic flame -- node app.js

经过负载测试后, 我们得到了火焰图(此处的空转次数减少到了100万次, 因此火焰图像看起来并不那么极端):
Node.js性能优化的8个技巧

文章图片
顶部有一个大白条, 它表示空闲功能使空闲状态所消耗的CPU时间。基于这样的火焰图, 我们可以很容易地看到CPU资源的消耗, 从而在代码中找到密集的计算并找到性能瓶颈。

    推荐阅读