如何加快 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 代碼運行。不過又是另外一塊難啃的骨頭。



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

閱讀原文

本文爲雲棲社區原創內容,未經容許不得轉載。

相關文章
相關標籤/搜索