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

本文概述

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上现有的基准测试项目进行测试。以下是测试结果:


file                               time(ms)  memory(MB)
callbacks-baseline.js                   380       70.83
promises-bluebird.js                    554       97.23
promises-bluebird-generator.js          585       97.05
async-bluebird.js                       593      105.43
promises-es2015-util.promisify.js      1203      219.04
promises-es2015-native.js              1257      227.03
async-es2017-native.js                 1312      231.08
async-es2017-util.promisify.js         1550      228.74

Platform 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高的性能。

在某些情况下, 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资源的消耗, 从而在代码中找到密集的计算并找到性能瓶颈。

微信公众号
手机浏览(小程序)
0
分享到:
没有账号? 忘记密码?