![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLicmbw5SZjlzYhRTO4IjY4EWMlZmYhRTO0cjNzUmMyIjZlFDOz8CX5d2bs92Yl1iclB3bsVmdlR2LcNWaw9CXt92Yu4GZjlGbh5yYjV3Lc9CX6MHc0RHaiojIsJye.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 前後的差異:
可以看到,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 ‘ 加入鏡像站官方使用者交流群。”