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

本文涉及的产品
函数计算FC,每月15万CU 3个月
简介: 实现分钟到毫秒的转化

image.png
镜像下载、域名解析、时间同步请点击 阿里巴巴开源镜像站

作者 | 杜佳昆(凌恒)
出品 | 阿里巴巴新零售淘系技术部

我们平时在开发部署 Node.js 应用的过程中,对于应用进程启动的耗时很少有人会关注,大多数的应用 5 分钟左右就可以启动完成,这个过程中会涉及到和集团很多系统的交互,这个耗时看起来也没有什么问题。
目前,集团 Serverless 大潮已至,Node.js serverless-runtime 作为前端新研发模式的基石,也发展的如火如荼。Serverless 的优势在于弹性、高效、经济,如果我们的 Node.js FaaS 还像应用一样,一次部署耗时在分钟级,无法快速、有效地响应请求,甚至在脉冲请求时引发资源雪崩,那么一切的优势都将变成灾难。
所有提供 Node.js FaaS 能力的平台,都在绞尽脑汁的把冷/热启动的时间缩短,这里面除了在流程、资源分配等底层基建的优化外,作为其中提供服务的关键一环 —— Node.js 函数,本身也应该参与到这场时间攻坚战中。
Faas平台从接到请求到启动业务容器并能够响应请求的这个时间必须足够短,当前的总目标是 500ms,那么分解到函数运行时的目标是 100ms。这 100ms 包括了 Node.js 运行时、函数运行时、函数框架启动到能够响应请求的时间。巧的是,人类反应速度的极限目前科学界公认为 100ms。

Node.js 有多快

在我们印象中 Node.js 是比较快的,敲一段代码,马上就可以执行出结果。那么到底有多快呢?
以最简单的 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… 情况看起来更糟了。
我们在引入一个模块看看,以 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 的有两个操作:
一、查找模块
因为 Node.js 的模块查找其实是一个嗅探文件在指定目录列表里是否存在的过程,这其中会因为判断文件存不存在,产生大量的 Open 操作,在模块依赖比较复杂的场景,这个开销会比较大。
二、读取模块内容
找到模块后,需要读取其中的内容,然后进入之后的编译过程,如果文件内容比较多,这个过程也会比较慢。
那么,如何能够减少这些操作呢?既然模块依赖会产生很多 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 前后的差异:
image.png
可以看到,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 代码运行。不过又是另一块难啃的骨头。

阿里巴巴开源镜像站 提供全面,高效和稳定的镜像下载服务。钉钉搜索 ' 21746399 ‘ 加入镜像站官方用户交流群。”

相关实践学习
【文生图】一键部署Stable Diffusion基于函数计算
本实验教你如何在函数计算FC上从零开始部署Stable Diffusion来进行AI绘画创作,开启AIGC盲盒。函数计算提供一定的免费额度供用户使用。本实验答疑钉钉群:29290019867
建立 Serverless 思维
本课程包括: Serverless 应用引擎的概念, 为开发者带来的实际价值, 以及让您了解常见的 Serverless 架构模式
相关文章
|
1月前
|
前端开发 机器人 API
前端大模型入门(一):用 js+langchain 构建基于 LLM 的应用
本文介绍了大语言模型(LLM)的HTTP API流式调用机制及其在前端的实现方法。通过流式调用,服务器可以逐步发送生成的文本内容,前端则实时处理并展示这些数据块,从而提升用户体验和实时性。文章详细讲解了如何使用`fetch`发起流式请求、处理响应流数据、逐步更新界面、处理中断和错误,以及优化用户交互。流式调用特别适用于聊天机器人、搜索建议等应用场景,能够显著减少用户的等待时间,增强交互性。
252 2
|
1月前
|
JavaScript 前端开发 API
探索后端技术:Node.js的优势和实际应用
【10月更文挑战第6天】 在当今数字化时代,后端开发是任何成功软件应用的关键组成部分。本文将深入探讨一种流行的后端技术——Node.js,通过分析其核心优势和实际应用案例,揭示其在现代软件开发中的重要性和潜力。
121 2
|
18天前
|
数据可视化 JavaScript 前端开发
数据可视化进阶:D3.js在复杂数据可视化中的应用
【10月更文挑战第26天】数据可视化是将数据以图形、图表等形式呈现的过程,帮助我们理解数据和揭示趋势。D3.js(Data-Driven Documents)是一个基于JavaScript的库,使用HTML、SVG和CSS创建动态、交互式的数据可视化。它通过数据驱动文档的方式,将数据与DOM元素关联,提供高度的灵活性和定制性,适用于复杂数据的可视化任务。 示例代码展示了如何使用D3.js创建一个简单的柱状图,展示了其基本用法。D3.js的链式调用和回调函数机制使代码简洁易懂,支持复杂的布局和交互逻辑。
56 3
|
1月前
|
机器学习/深度学习 自然语言处理 JavaScript
信息论、机器学习的核心概念:熵、KL散度、JS散度和Renyi散度的深度解析及应用
在信息论、机器学习和统计学领域中,KL散度(Kullback-Leibler散度)是量化概率分布差异的关键概念。本文深入探讨了KL散度及其相关概念,包括Jensen-Shannon散度和Renyi散度。KL散度用于衡量两个概率分布之间的差异,而Jensen-Shannon散度则提供了一种对称的度量方式。Renyi散度通过可调参数α,提供了更灵活的散度度量。这些概念不仅在理论研究中至关重要,在实际应用中也广泛用于数据压缩、变分自编码器、强化学习等领域。通过分析电子商务中的数据漂移实例,展示了这些散度指标在捕捉数据分布变化方面的独特优势,为企业提供了数据驱动的决策支持。
62 2
信息论、机器学习的核心概念:熵、KL散度、JS散度和Renyi散度的深度解析及应用
|
23天前
|
JavaScript 前端开发 开发者
探索JavaScript原型链:深入理解与实战应用
【10月更文挑战第21天】探索JavaScript原型链:深入理解与实战应用
28 1
|
1月前
|
JavaScript 前端开发 API
Vue.js:打造高效前端应用的最佳选择
【10月更文挑战第9天】Vue.js:打造高效前端应用的最佳选择
18 2
|
1月前
|
设计模式 JavaScript 前端开发
探索JavaScript中的闭包:从基础概念到实际应用
在本文中,我们将深入探讨JavaScript中的一个重要概念——闭包。闭包是一种强大的编程工具,它允许函数记住并访问其所在作用域的变量,即使该函数在其作用域之外被调用。通过详细解析闭包的定义、创建方法以及实际应用场景,本文旨在帮助读者不仅理解闭包的理论概念,还能在实际开发中灵活运用这一技巧。
|
1月前
|
缓存 JavaScript 前端开发
深入了解JavaScript的闭包:概念与应用
【10月更文挑战第8天】深入了解JavaScript的闭包:概念与应用
|
15天前
|
前端开发 JavaScript
JavaScript新纪元:ES6+特性深度解析与实战应用
【10月更文挑战第29天】本文深入解析ES6+的核心特性,包括箭头函数、模板字符串、解构赋值、Promise、模块化和类等,结合实战应用,展示如何利用这些新特性编写更加高效和优雅的代码。
32 0
|
30天前
|
运维 JavaScript Linux
容器内的Nodejs应用如何获取宿主机的基础信息-系统、内存、cpu、启动时间,以及一个df -h的坑
本文介绍了如何在Docker容器内的Node.js应用中获取宿主机的基础信息,包括系统信息、内存使用情况、磁盘空间和启动时间等。核心思路是将宿主机的根目录挂载到容器,但需注意权限和安全问题。文章还提到了使用`df -P`替代`df -h`以获得一致性输出,避免解析错误。