如何加快 Node.js 应用的启动速度

咱们平时在开发部署 Node.js 应用的过程当中,对于应用进程启动的耗时不多有人会关注,大多数的应用 5 分钟左右就能够启动完成,这个过程当中会涉及到和集团不少系统的交互,这个耗时看起来也没有什么问题。前端

目前,集团 Serverless 大潮已至,Node.js serverless-runtime 做为前端新研发模式的基石,也发展的如火如荼。Serverless 的优点在于弹性、高效、经济,若是咱们的 Node.js FaaS 还像应用同样,一次部署耗时在分钟级,没法快速、有效地响应请求,甚至在脉冲请求时引起资源雪崩,那么一切的优点都将变成灾难。node

全部提供 Node.js FaaS 能力的平台,都在绞尽脑汁的把冷/热启动的时间缩短,这里面除了在流程、资源分配等底层基建的优化外,做为其中提供服务的关键一环 —— Node.js 函数,自己也应该参与到这场时间攻坚战中。服务器

Faas平台从接到请求到启动业务容器并可以响应请求的这个时间必须足够短,当前的总目标是 500ms,那么分解到函数运行时的目标是 100ms。这 100ms 包括了 Node.js 运行时、函数运行时、函数框架启动到可以响应请求的时间。巧的是,人类反应速度的极限目前科学界公认为 100ms。框架

Node.js 有多快

在咱们印象中 Node.js 是比较快的,敲一段代码,立刻就能够执行出结果。那么到底有多快呢?less

以最简单的 console.log 为例(例一),代码以下:函数

// console.js
console.log(process.uptime() * 1000);

在 Node.js 最新 LTS 版本 v10.16.0 上,在咱们我的工做电脑上:工具

node console.js
// 平均时间为 86ms
time node console.js
// node console.js  0.08s user 0.03s system 92% cpu 0.114 total

看起来,在 100ms 的目标下,留给后面代码加载的时间很少了。。。测试

在来看看目前函数平台提供的容器里的执行状况:优化

node console.js
// 平均时间在 170ms
time node console.js
// real    0m0.177s
// user    0m0.051s
// sys     0m0.009s

Emmm… 状况看起来更糟了。ui

咱们在引入一个模块看看,以 serverless-runtime 为例(例二):

// require.js
console.time('load');
require('serverless-runtime');
console.timeEnd('load');

本地环境:

node reuqire.js
// 平均耗时 329ms

服务器环境:

node require.js
// 平均耗时 1433ms

我枯了。。。
这样看来,从 Node.js 自己加载完,而后加载一个函数运行时,就要耗时 1700ms。
看来 Node.js 自己并无那么快,咱们 100ms 的目标看起来很困难啊!

为何这么慢

为何会运行的这么慢?并且两个环境差别这么大?咱们须要对整个运行过程进行分析,找到耗时比较高的点,这里咱们使用 Node.js 自己自带的 profile 工具。

node --prof require.js
node --prof-process isolate-xxx-v8.log > result
[Summary]:
ticks  total  nonlib   name
     60   13.7%   13.8%  JavaScript
    371   84.7%   85.5%  C++
     10    2.3%    2.3%  GC
      4    0.9%          Shared libraries
      3    0.7%          Unaccounted
[C++]:
ticks  total  nonlib   name
    198   45.2%   45.6%  node::contextify::ContextifyScript::New(v8::FunctionCallbackInfo<v8::Value> const&)
     13    3.0%    3.0%  node::fs::InternalModuleStat(v8::FunctionCallbackInfo<v8::Value> const&)
      8    1.8%    1.8%  void node::Buffer::(anonymous namespace)::StringSlice<(node::encoding)1>(v8::FunctionCallbackInfo<v8::V
alue> const&)
      5    1.1%    1.2%  node::GetBinding(v8::FunctionCallbackInfo<v8::Value> const&)
      4    0.9%    0.9%  __memmove_ssse3_back
      4    0.9%    0.9%  __GI_mprotect
      3    0.7%    0.7%  v8::internal::StringTable::LookupStringIfExists_NoAllocate(v8::internal::String*)
      3    0.7%    0.7%  v8::internal::Scavenger::ScavengeObject(v8::internal::HeapObjectReference**, v8::internal::HeapObject*)
      3    0.7%    0.7%  node::fs::Open(v8::FunctionCallbackInfo<v8::Value> const&)

对运行时启动作一样的操做

[Summary]:
ticks  total  nonlib   name
    236   11.7%   12.0%  JavaScript
   1701   84.5%   86.6%  C++
     35    1.7%    1.8%  GC
     47    2.3%          Shared libraries
     28    1.4%          Unaccounted
[C++]:
ticks  total  nonlib   name
    453   22.5%   23.1%  t node::fs::Open(v8::FunctionCallbackInfo<v8::Value> const&)
    319   15.9%   16.2%  T node::contextify::ContextifyContext::CompileFunction(v8::FunctionCallbackInfo<v8::Value> const&)
     93    4.6%    4.7%  t node::fs::InternalModuleReadJSON(v8::FunctionCallbackInfo<v8::Value> const&)
     84    4.2%    4.3%  t node::fs::Read(v8::FunctionCallbackInfo<v8::Value> const&)
     74    3.7%    3.8%  T node::contextify::ContextifyScript::New(v8::FunctionCallbackInfo<v8::Value> const&)
     45    2.2%    2.3%  t node::fs::InternalModuleStat(v8::FunctionCallbackInfo<v8::Value> const&)
   ...

能够看到,整个过程主要耗时是在 C++ 层面,相应的操做主要为 Open、ContextifyContext、CompileFunction。这些调用一般是出如今 require 操做中,主要覆盖的内容是模块查找,加载文件,编译内容到 context 等。

看来,require 是咱们能够优化的第一个点。

如何更快

从上面得知,主要影响咱们启动速度的是两个点,文件 I/O 和代码编译。咱们分别来看如何优化。

▐ 文件 I/O

整个加载过程当中,可以产生文件 I/O 的有两个操做:

1、查找模块

由于 Node.js 的模块查找实际上是一个嗅探文件在指定目录列表里是否存在的过程,这其中会由于判断文件存不存在,产生大量的 Open 操做,在模块依赖比较复杂的场景,这个开销会比较大。

2、读取模块内容

找到模块后,须要读取其中的内容,而后进入以后的编译过程,若是文件内容比较多,这个过程也会比较慢。

那么,如何可以减小这些操做呢?既然模块依赖会产生不少 I/O 操做,那把模块扁平化,像前端代码同样,变成一个文件,是否能够加快速度呢?

说干就干,咱们找到了社区中一个比较好的工具 ncc,咱们把 serverless-runtime 这个模块打包一次,看看效果。

服务器环境:

ncc build node_modules/serverless-runtime/src/index.ts
node require.js
// 平均加载时间 934ms

看起来效果不错,大概提高了 34% 左右的速度。

可是,ncc 就没有问题嘛?咱们写了以下的函数:

import * as _ from 'lodash';
import * as Sequelize from 'sequelize';
import * as Pandorajs from 'pandora';
console.log('lodash: ', _);
console.log('Sequelize: ', Sequelize);
console.log('Pandorajs: ', Pandorajs);

测试了启用 ncc 先后的差别:

能够看到,ncc 以后启动时间反而变大了。这种状况,是由于太多的模块打包到一个文件中,致使文件体积变大,总体加载时间延长。可见,在使用 ncc 时,咱们还须要考虑 tree-shaking 的问题。

▐ 代码编译

咱们能够看到,除了文件 I/O 外,另外一个耗时的操做就是把 Javascript 代码编译成 v8 的字节码用来执行。咱们的不少模块,是公用的,并非动态变化的,那么为何每次都要编译呢?能不能编译好了以后,之后直接使用呢?

这个问题,V8 在 2015 年已经替咱们想到了,在 Node.js v5.7.0 版本中,这个能力经过 VM.Script 的 cachedData暴露了出来。并且,这些 cache 是跟 V8 版本相关的,因此一次编译,能够在屡次分发。

咱们先来看下效果:

//使用 v8-compile-cache 在本地得到 cache,而后部署到服务器上
node require.js
// 平均耗时 868ms

大概有 40% 的速度提高,看起来是一个不错的工具。

但它也不够完美,在加载 code cache 后,全部的模块加载不须要编译,可是仍是会有模块查找所产生的文件 I/O 操做。

▐ 黑科技

若是咱们把 require 函数作下修改,由于咱们在函数加载过程当中,全部的模块都是已知已经 cache 过的,那么咱们能够直接经过 cache 文件加载模块,不用在查找模块是否存在,就能够经过一次文件 I/O 完成全部的模块加载,看起来是很理想的。

不过,可能对远程调试等场景不够优化,源码索引上会有问题。这个,以后会作进一步尝试。

近期计划

有了上面的一些理论验证,咱们准备在生产环境中将上述优化点,如:ncc、code cache,甚至 require 的黑科技,付诸实践,探索在加载速度,用户体验上的平衡点,以取得速度上的提高。

其次,会 review 整个函数运行时的设计及业务逻辑,减小由于逻辑不合理致使的耗时,合理的业务逻辑,才能保证业务的高效运行。

最后,Node.js 12 版本对内部的模块默认作了 code cache,对 Node.js 默认进程的启动速度提高比较明显,在服务器环境中,能够控制在 120ms 左右,也能够考虑引用尝试下。

将来思考

其实,V8 自己还提供了像 Snapshot 这样的能力,来加快自己的加载速度,这个方案在 Node.js 桌面开发中已经有所实践,好比 NW.js、Electron 等,一方面可以保护源码不泄露,一方面还能加快进程启动速度。Node.js 12.6 的版本,也开启了 Node.js 进程自己的在 user code 加载前的 Snapshot 能力,但目前看起来启动速度提高不是很理想,在 10% ~ 15% 左右。咱们能够尝试将函数运行时以 Snapshot 的形式打包到 Node.js 中交付,不过效果咱们暂时尚未定论,现阶段先着手于比较容易取得成果的方案,硬骨头后面在啃。

另外,Java 的函数计算在考虑使用 GraalVM 这样方案,来加快启动速度,能够作到 10ms 级,不过会失去一些语言上的特性。这个也是咱们后续的一个研究方向,将函数运行时总体编译成 LLVM IR,最终转换成 native 代码运行。不过又是另外一块难啃的骨头。



本文做者:杜佳昆(凌恒) 

阅读原文

本文为云栖社区原创内容,未经容许不得转载。

相关文章
相关标签/搜索