当前位置: 首页 > 后端技术 > Node.js

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

时间:2023-04-03 14:41:14 Node.js

我们平时在开发部署Node.js应用的时候,很少有人关注到应用进程启动的耗时问题。大多数应用程序可以在大约5分钟内启动。这个过程会涉及到集团很多系统的交互,这个耗时似乎不是问题。目前,集团的serverless趋势已经到来,Node.jsserverless-runtime作为前端新研发模式的基石,也在如火如荼地发展。Serverless的优势在于弹性、效率和经济性。如果我们的Node.jsFaaS就像一个应用,一个部署需要几分钟,不能快速有效地响应请求,甚至在突发请求时会引发资源雪崩。优势将变成灾难。所有提供Node.jsFaaS能力的平台都在绞尽脑汁缩短冷/热启动时间。除了优化流程和资源分配等底层基础设施外,这是提供服务的关键部分——Node.js功能本身也应该参与这场时间战。FaaS平台从收到请求到启动业务容器,能够响应请求的时间一定要足够短。当前的总目标是500ms,所以分解成函数运行时的目标是100ms。这100ms包含了从Node.js运行时、函数运行时、函数框架启动到能够响应请求的时间。巧合的是,目前科学界公认的人类反应速度的极限是100ms。Node.js有多快?在我们的印象中,Node.js是比较快的。输入一段代码后,可以立即执行结果。那么多快呢?以最简单的console.log为例(例1),代码如下://console.jsconsole.log(process.uptime()*1000);在Node.js最新的LTS版本v10.16.0上,在我们个人工作的电脑上:nodeconsole.js//平均时间为86mstimenodeconsole.js//nodeconsole.js0.08suser0.03ssystem92%cpu0.114total看来在100ms的目标下,留给后面代码加载的时间用完了。..我们来看看当前函数平台提供的容器中的执行情况:nodeconsole.js//平均时间为170mstimenodeconsole.js//real0m0.177s//user0m0.051s//sys0m0。009sEmmm...视情况而定变得更糟了。下面引入一个模块,以serverless-runtime为例(例2)://require.jsconsole.time('load');require('serverless-runtime');console.timeEnd('load');本地环境:nodereuqire.js//平均耗时329ms服务器环境:noderequire.js//平均耗时1433ms,累死我了。..从这点来看,从Node.js本身加载,再加载一个函数运行,需要1700ms。看来Node.js本身并没有那么快,我们100ms的目标看起来很难!为什么这么慢?为什么运行这么慢?而且两个环境差别这么大?我们需要对整个运行过程进行分析,找到耗时点。这里我们使用Node.js自带的配置文件工具。node--profrequire.jsnode--prof-processisolate-xxx-v8.log>结果[摘要]:ticks非库名称总数6013.7%13.8%JavaScript37184.7%85.5%C++102.3%2.3%GC40.9%共享库30.7%Unaccounted[C++]:tickstotalnonlibname19845.2%45.6%node::contextify::ContextifyScript::New(v8::FunctionCallbackInfoconst&)133.0%3.0%node::fs::InternalModuleStat(v8::FunctionCallbackInfoconst&)81.8%1.8%voidnode::Buffer::(匿名命名空间)::StringSlice<(node::encoding)1>(v8::FunctionCallbackInfoconst&)51.1%1.2%node::GetBinding(v8::FunctionCallbackInfoconst&)40.9%0.9%__memmove_ssse3_back40.9%0.9%__GI_mprotect30.7%0.7%v8::内部::StringTable::LookupStringIfExists_NoAllocate(v8::internal::String*)30.7%0.7%v8::internal::Scavenger::ScavengeObject(v8::internal::HeapObjectReference**,v8::internal::HeapObject*)30.7%0.7%node::fs::Open(v8::FunctionCallbackInfoconst&)对运行时启动做同样的操作[总结]:tickstotalnonlibname23611.7%12.0%JavaScript170184.5%86.6%C++351.7%1.8%GC472.3%共享库281.4%Unaccounted[C++]:ticks总非库名称45322.5%23.1%tnode::fs::Open(v8::FunctionCallbackInfoconst&)31915.9%16.2%T节点::contextify::ContextifyContext::CompileFunction(v8::FunctionCallbackInfoconst&)934.6%4.7%tnode::fs::InternalModuleReadJSON(v8::FunctionCallbackInfoconst&)844.2%4.3%tnode::fs::Read(v8::FunctionCallbackInfoconst&)743.7%3.8%T节点::contextify::ContextifyScript::New(v8::FunctionCallbackInfoconst&)452.2%2.3%tnode::fs::InternalModuleStat(v8::FunctionCallbackInfoconst&)...可以可以看出整个过程主要是C++层面的耗时。对应的操作主要有Open、ContextifyContext、CompileFunction。这些调用通常出现在require操作中。涵盖的主要内容是模块搜索、加载文件和将内容编译到上下文。看来require是我们可以优化的第一点。如何更快从上面我们知道影响我们启动速度的因素主要有两个,文件I/O和代码编译。下面我们分别来看如何优化。?在文件I/O的整个加载过程中,有两个操作可以产生文件I/O:1.查找模块因为Node.js模块查找实际上是一个嗅探指定目录列表中是否存在文件的过程,这个其中,因为判断文件是否存在,会产生大量的Open操作。在模块依赖比较复杂的场景下,这个开销会比较大。2、读取模块内容找到模块后,需要读取其中的内容,然后进入后续的编译过程。如果文件内容较多,这个过程会比较慢。那么,如何减少这些操作呢?由于模块依赖会产生大量的I/O操作,那么能不能像前端代码一样把模块扁平化,变成一个文件,可以加快速度呢?就这么干吧,我们在社区找到了一个比较好的工具ncc,我们把serverless-runtime模块打包了一次,看看效果。服务器环境:nccbuildnode_modules/serverless-runtime/src/index.tsnoderequire.js//934ms的平均加载时间看起来不错,大约快了34%。但是,ncc就没有问题了吗?我们编写了以下函数:import*as_from'lodash';import*asSequelizefrom'sequelize';import*asPandorajsfrom'pandora';console.log('lodash:',_);console.log('Sequelize:',Sequelize);console.log('Pandorajs:',Pandorajs);测试开启ncc前后的区别:可以看到ncc开启后的启动时间变长了。在这种情况下,太多的模块被打包到一个文件中,导致文件更大,整体加载时间更长。可见,在使用ncc的时候,我们还需要考虑tree-shaking的问题。?代码编译我们可以看到,除了文件I/O,还有一个比较耗时的操作就是将Javascript代码编译成v8字节码执行。我们的很多模块都是公开的,不是动态的,那为什么每次都要编译呢?以后可以直接编译使用吗?V8在2015年就已经为我们想到了这个问题,在Node.jsv5.7.0版本中,通过VM.Script的cachedData暴露了这个能力。而且,这些缓存是和V8版本相关的,所以一次编译,可以多次分发。先来看看效果://使用v8-compile-cache在本地获取缓存,然后部署到服务端节点require.js//平均耗时868ms,提速40左右%。看起来是个好工具。但它并不完美。加载代码缓存后,所有模块加载都不需要编译,但仍然会有模块查找产生的文件I/O操作。?黑科技如果我们修改require函数,因为函数加载过程中已知所有模块都被缓存了,那么我们可以直接通过缓存文件加载模块,而不用检查模块是否存在,看起来比较理想所有模块加载都可以通过单个文件I/O完成。但是对于远程调试等场景可能优化不够,源码索引会有问题。这将在稍后进一步尝试。随着近期计划中的上述一些理论验证,我们准备将上述优化点,如:ncc、代码缓存,甚至是require的黑科技,在生产环境中付诸实践,探索加载速度和用户体验之间的平衡。获得速度。其次,对整个函数运行时的设计和业务逻辑进行审查,减少逻辑不合理带来的耗时。只有合理的业务逻辑才能保证业务的高效运行。最后,Node.js12版本默认有内部模块的代码缓存,默认Node.js进程的启动速度有显着提升。在服务器环境下,可以控制在120ms左右,也可以考虑引用试试。对未来的思考实际上,V8本身也提供了像Snapshot这样的能力来加快自身的加载速度。这种方案在Node.js桌面开发中已经实践过,比如NW.js、Electron等,一方面可以保护源代码不被Leakage,一方面也可以加快进程启动速度。Node.js12.6版本在用户代码加载前也开启了Node.js进程自身的Snapshot能力,但目前看来启动速度的提升不是很理想,大概在10%到15%左右。我们可以尝试将函数运行时以Snapshot的形式打包到Node.js中进行交付,但效果目前还没有定论。在这个阶段,我们会先从比较容易出结果的方案入手,啃硬骨头。另外,Java的函数计算正在考虑使用GraalVM等解决方案来加快启动速度,可以做到10ms级别,但会损失一些语言特性。这也是我们后续的研究方向,将functionruntime整体编译成LLVMIR,最后转成nativecode运行。又一个棘手的难题。本文作者:杜家坤(凌恒)阅读原文本文为云栖社区原创内容,未经允许不得转载。