深入淺出 tnpm rapid 模式 - 如何比 pnpm 快 10 秒

語言: CN / TW / HK

背景

作為一名老前端,不得不感慨,前端變得越來越複雜,依賴安裝的速度很慢很慢。

前幾天我們也在螞蟻 SEE Conf 2022 發表了主題演講:《 一種秒級安裝 npm 的方式 》。

本文從另一個角度來闡述下關於 前端依賴安裝提速 整個優化工作的背景、思考、結果以及未來。

npm 為什麼會慢?

在現代 npm 生態體系下,模組數量和依賴關係日趨複雜化:

  • 模組數量眾多,截止 2021 年底, npm 包數量已經超過 180 萬 ,數倍於其他語言的模組數量。
  • 模組關係錯綜複雜,存在重複依賴,小檔案很多,浪費磁碟空間並拖慢寫入速度。

就如同一個硬幣的兩面,追求小而美的 Node.js 的模組生態,推動社群發展空前繁榮的同時,也使得依賴關係變得非常的複雜,一定程度上造成了依賴安裝非常慢。

生態現狀正確與否,不在今天我們探討的範疇之內,讓我們聚焦在當下如何來提升安裝速度。

一個應用的依賴安裝過程大致如上, 關鍵操作 主要有:

  • 查詢子依賴的包資訊,獲取下載地址。
  • 下載 tgz 包到本地,解壓安裝。
  • 構造 node_modules 目錄結構,寫入檔案。

依賴包安裝

我們以一個比較有代表性的測試物件 [email protected] 為例:

實際依賴數大概 1000 個左右 ,磁碟佔用 170M,檔案數量 18542 個。

但若使用 [email protected] 按傳統巢狀目錄方式安裝後,實際安裝依賴數高達 3626 個,有 兩千多個重複的依賴 ,磁碟佔用 523M,檔案數量高達 60257 個。

然而,檔案 IO,尤其是海量小檔案的讀寫是非常耗時的。

[email protected] 先提出了優化思路: 『扁平化依賴』能力 ,所有子依賴都被拍平到了根目錄的 node_modules 下,試圖解決了重複依賴以及層級太深的問題。

但它也帶來了額外的新問題:

  • 幽靈依賴問題 (phantom dependencies )。
  • 多重身問題 ,無法徹底解決重複依賴,譬如還存在 183 個重複依賴。(doppelgangers)。
  • 依賴結構的不確定性 。(通過 依賴關係圖 可以解決)
  • 扁平化演算法的複雜度和效能損耗。

鑑於扁平化依賴的諸多副作用,pnpm 提出了 另一種解決思路,通過 軟連結 + 硬連結 的方式

這種方式能很好的實現了:

  • 減少包的重複問題,相容 Node.js 的定址邏輯,未引入幽靈依賴、多重身等問題。
  • 全域性快取的硬連結方式,能減少檔案複製,節省磁碟佔用。

從資料也看到:1109 個模組,18747 個檔案,5435 個目錄,3150 個軟連結,磁碟佔用 175M。

我們的 cnpm 當年也受到 pnpm 的啟發,重構並實現了 cnpm/npminstall 這個庫,同樣是通過軟連結方式,但沒有用到硬連結,也未把子依賴提升到同級。

這種方式潛在的一些問題:

  • 軟鏈會導致一些 IDE 出現 死迴圈的 indexing 問題 。(隨著 IDE 的優化,目前已經好了很多)
  • 子依賴提升到同級的目錄結構的 相容性問題 ,雖然由於 Node.js 的父目錄上溯定址邏輯,可以實現相容。但對於類似 Egg、Webpack 的外掛載入邏輯,在用到相對路徑的地方,需要去適配。
  • 不同應用的依賴是硬連結到同一份檔案,除錯時修改了檔案有可能會無意中影響到其他專案。
  • 軟鏈在不同作業系統的實現不太一樣,且在非 SSD 的硬碟上,還是會有一定的磁碟 IO 損耗的。

此外,yarn 也提出了 Plug'n'Play 等優化方式,但鑑於它太過於激進,無法相容 Node.js 現存生態,在此我們不展開討論。

包資訊查詢

我們再來觀察下依賴安裝過程:

  • 每個依賴都需要 1 次包資訊查詢,1 次 tgz 下載,共 2 次 HTTP 請求。
  • 同包不同版本時,僅查詢 1 次資訊,然後每個版本 tgz 下載 1 次。

由於當前生態下,依賴個數是非常多的,從而 HTTP 請求次數會對應的被放大,造成可觀的耗時增加。譬如上面的例子,[email protected] 會發起 2500 多次 HTTP 請求。

目前的優化共識是:通過事先計算好的依賴關係圖,可以直接去下載 tgz,無需查詢包資訊,從而減少了一大半的網路耗時

npm 先提出了 shrinkwrap 的概念,隨即被 yarn 提出 lockfile 所代替,pnpm 也有對應的支援但配置格式不一樣。雖然它們最初的出發點是鎖版本,但意外地發現還可以作為 依賴關係圖 來提速下載。

但它存在的問題是:

  • 首次安裝不會提速,除非把 lockfile 存入原始碼管理。
  • 鎖版本在大規模實踐中會帶來了一定的治理問題。

小結

總結下,若要提升安裝速度,我們需要思考:

  • 如何更快的獲取依賴關係?(解析策略)
  • 如何更快的下載 tgz 包?(網路 IO)
  • 如何更快的寫入到硬碟?重複的依賴如何處理?(檔案 IO)

目前已達成的共識:

  • 通過依賴關係圖,來優化網路 IO 時序,實現更高效的併發下載。
  • 通過某些方式去簡化 node_modules 目錄,優化重複依賴帶來的檔案 IO 問題。
  • 全域性快取,減少網路 IO 下載量。

存在的問題:

  • lockfile 一定程度上會帶來維護成本問題,鎖版本和不鎖版本都不是銀彈。
  • 扁平化依賴 和 軟連結 方式都存在各自的一些相容性問題。
  • 全域性快取未達成共識,解壓複製方式產生大量檔案 IO,硬連結方式會有潛在的衝突問題。

參考閱讀: JavaScript 包管理簡史

tnpm 和 cnpm 是什麼?

如上圖,簡單的說:

  • cnpm 是我們開源的 npm 實現,支援官方 npm registry 的映象同步,以及私有包能力。
  • npmmirror 是社群基於 cnpm 部署的一個公益專案,為中國前端開發者提供映象服務。
  • tnpm 是我們在阿里巴巴及螞蟻集團的企業服務,同樣基於 cnpm 之上做了企業級的能力定製。

優化效果

在阿里巴巴及螞蟻集團,對工程師來說,研發效能是一個很重要的指標,而前端依賴的安裝速度,是一個很大的影響因子。

因此我們在 2021 年發起了 包管理與構建專項,目標之一就是優化依賴的安裝速度,最終 成功地提速了 3 倍,斬獲了螞蟻集團魯班獎

接下來,將帶大家一起剖析下 tnpm rapid 模式 的優化思路及結果。

測試場景

效能調優法則:無度量,不優化。

PS:我們可能是業界首個把 Mac mini m1 重灌為 Linux 組成前端構建叢集的企業,它讓我們的整體構建速度額外提升了一倍。

測試結果

先不對該結果做解讀,等我們對 tnpm rapid 模式的優化思路逐一討論後,再來討論會更有體感。

背後的資料

回憶之前我們在最開始分析慢的原因時給出的資料,完整如下:

測試物件 檔案資料 磁碟 IO 網路 IO
[email protected] 523M,61947 個檔案 - -
[email protected] 179M,19883 個檔案 102694 次,耗時 28.5 秒 速度:max: 5672 KB/s,avg: 1478 KB/s請求數:meta: 997,tgz: 1292
[email protected] 175M,18745 個檔案 67182 次,耗時 7 秒 速度:max: 3882 KB/s,avg: 1152 KB/s請求數:meta: 1128,tgz: 1128
[email protected] 167M,18542 個檔案 271033 次,耗時 34.5 秒 速度:max: 5887 KB/s,avg: 1431 KB/s請求數:meta: 1128,tgz: 1128
[email protected] 125M,18542 個檔案 68957 次,耗時 3.3 秒 速度:max: 5981 KB/s,avg: 5981 KB/s請求數:meta: 1,tgz: 1128

無 lock + cache 情況,通過strace 和charles 採集了相關資料,及統計對應的檔案數和體積。

簡單解讀下:

  • 檔案數:扁平化依賴 和 軟硬連結 的數量基本上差不多,都大幅減少了磁碟佔用。
  • 磁碟 IO:一個重要的指標,檔案寫入次數直接關係到安裝速度。
  • 網路速度:體現的是安裝過程是否能儘可能的跑滿頻寬,越大越好。
  • 請求數:包括 tgz 下載數和查詢包資訊數,基本上都近似為模組個數。

從資料中可以看到,tnpm 對 磁碟 IO 和 網路 IO 都有較大的優化。

我們是如何優化的?

網路 IO

對於網路 IO 的優化,我們只有一個目標: 如何最大化的跑滿頻寬?

第一個優化點是 依賴關係圖 (dependencies graph):

  • 目前的共識都是通過它來避免在 CLI 近端側去請求包資訊,從而極大的減少了 HTTP 請求數。
  • 我們的特殊之處在於: 在服務端側生成依賴關係圖,並實現了多級快取策略。
  • 使用 @npmcli/arborist ,遵循 npm 規範。

在我們的企業級大規模實踐中的經驗和理念是不提倡本地鎖版本,僅在迭代推進工作流中會複用上一階段的依賴關係圖,如 開發環境 → 測試環境,或緊急迭代等。(鎖不鎖版本是一個時常爭吵的話題,並沒有銀彈,根據企業團隊情況尋找各自的平衡點,在此不展開討論。)

第二個優化點是 HTTP 請求預熱

  • 一次 tgz 的下載過程,會先訪問 registry,然後被 302 到 oss 下載地址。
  • 通過提前預熱,可以提高併發度,從而減少總的 HTTP 耗時。
  • 期間還踩過一個 DNS 間歇性 5 秒延遲的坑。

npm registry 是沒有這一次 302 跳轉的,我們把下載流量的邏輯從 registry 分離了出去,重定向到有 CDN 快取的 OSS 儲存地址,這能提升穩定性,以及支援應急止血治理等企業級訴求。

第三個優化點是合併檔案

  • 我們在測試時發現無法跑滿頻寬,分析後發現: 在海量的依賴包的情況下,小檔案的頻繁寫入會導致檔案 IO 瓶頸。
  • 僅把 tgz 解壓為 tar 檔案,鑑於 tar 是歸檔檔案格式,很容易在寫入磁碟時適當地合併檔案。
  • 反覆測試得到的經驗值是合併為 40 個 tar 包。即 1000 多個 tgz 最終僅儲存為 40 個 tar。

第四個優化是用 Rust 重新實現了下載和解壓邏輯:

  • 併發 40 個協程,流式下載,解壓併合並寫入為 tar 包。
  • 由於內建的底層庫有所差異,就目前而言,Rust 的下載和解壓效能會優於 Node.js。於是我們用 Rust 封裝了 napi 模組供 tnpm 呼叫。

FUSE 檔案系統

我們認為 Node.js 最初的 巢狀目錄 優於 扁平化 方案,但又希望能解決軟鏈帶來的相容性問題,如何魚與熊掌兼得呢?

先來引入一個黑科技:FUSE (FileSystem in Userspace),即 使用者態檔案系統

似乎比較抽象?我們回想一個前端很熟悉的場景:使用 ServiceWorker 來精細化地定製 HTTP Cache Control 邏輯。

是的,前端同學可以 把 FUSE 理解為檔案系統版的 ServiceWorke r, 通過 FUSE 可以接管一個目錄的檔案系統操作邏輯。

如上圖:

  • 我們基於 nydus 實現了 npmfs 守護程序。
  • 將 npmfs 註冊為作業系統的 fuse 守護程序,掛載了虛擬對映目錄。
  • 當讀取該目錄的檔案時,作業系統會把控制權轉交給我們的程序。
  • 我們的程序通過查詢 依賴關係圖 來從全域性快取找到並返回對應的檔案內容。

通過這種方式,我們實現了:

  • 所有的系統檔案操作指令,都會把這個目錄視為真實的目錄。
  • 每個檔案都被視為是獨立的檔案,不會像硬連結那樣會互相影響。

nydus 目前不支援 macOS,故我們實現 nydus 到 macfuse 的適配層,待完善後會開源出來。

冷知識:nydus 是星際裡的一個兵種,負責挖洞。

OverlayFS

日常開發時,我們有可能會需要臨時修改 node_modules 下的程式碼,以便除錯。這也是軟硬連結方案潛在的問題,會導致不同應用在無意間互相干擾。

FUSE 支援自定義寫入操作,但實現起來比較複雜,我們直接使用了OverlayFS 聯合檔案系統

  • OverlayFS 可以聚合多種不同的掛載點到一個目錄。
  • 常用的場景是:在一個只讀層上覆蓋一個讀寫層,達到讓只讀層能夠讀寫。
  • Docker 中的映象就是這麼實現的,映象中的 layer 可以複用於不同的容器,且不互相影響。

所以,我們進一步實現了:

  • 把 FUSE 目錄作為 OverlayFS 的 Lower Dir,構造出一個可以讀寫的檔案系統,並掛載為應用的 node_modules 目錄。
  • 利用其 COW(copy-on-write) 特性,我們可以複用底層檔案,達到節省空間的目的,並支援獨立的檔案修改,隔離不同應用的互相干擾, 安全的全域性複用一份快取

檔案 IO

接下來我們再聊聊 全域性快取 ,目前業界主要有 2 種方案:

  • npm:把 tgz 解包成 tar 作為全域性快取,再次安裝依賴時解壓到 node_modules。
  • pnpm:把 tgz 解壓為檔案,以 hash 方式全域性快取, 同個包的不同版本的同個檔案也能共享,再次安裝時直接硬連結過去。

它們的共同點都是會在某個階段解壓為檔案,而解壓產生的海量小檔案會造成海量的檔案 IO。

某天我們突然開了個腦洞, 乾脆別解壓了?

所以,我們又進化了一步:

  • 直接把 node_modules 通過 FUSE + 依賴關係圖 對映到 tar 歸檔檔案,省去了解壓的檔案 IO。
  • 同時基於 FUSE 的高度可控性,我們可以很容易支援 巢狀目錄扁平化 兩種結構,按需切換。
  • 想象空間:如何未來雲端儲存的訪問效能進一步提升,我們甚至可以不用下載 tgz 了?

曾經的另一些嘗試:我們一度想把 tar + gzip 轉換為 stargz + lz4,但收益不是很大:stargz 比 tar 多了索引能力,但實際上獨立的依賴關係圖也能實現類似的目的,沒必要打包在一起。lz4 比 gzip 有很高的效能提升,但在我們目前的實踐中發現, ROI 不高。

額外成本

任何方案都不可能是完美無缺的,我們的方案存在一些額外的成本:

第一點是 FUSE 的成本

  • 跨系統相容性成本,雖然有各個作業系統的支援庫,但相容性上還需要時間檢驗。
  • 企業內部場景需要支援特權容器。
  • 社群場景要看 GitHub Actions 和 Travis 是否支援 FUSE。

第二點是服務端維護成本

  • 應用的依賴關係圖分析能力,僅能在企業內部私有化部署的 Registry 開啟。
  • 由於服務端資源限制,該能力不對公共映象站服務開放,會 fallback 到 CLI 近端側生成方式。

總結

核心思路

綜上,我們的方案的核心優勢:

  • 網路 IO
模組數 * HTTP 耗時
  • 檔案 IO
  • (模組數 - 40) * 磁碟操作耗時
    (檔案個數 + 目錄個數 + 軟硬鏈個數) * 磁碟操作耗時
  • 相容性
    • Node.js 標準目錄結構,無軟鏈,無扁平化副作用。

    資料解讀

    經過上面的分析,大家應該基本清楚 tnpm rapid 模式 的優化思路,現在再讓我們回過頭來,解讀下前面的測試結果資料。

    注意:目前 tnpm rapid 模式還處於小範圍測試和持續迭代完善階段,故該測試資料僅供參考。

    NO. action cache lockfile node_modules npm pnpm Yarn Yarn PnP tnpm
    1 install 48s 14.2s 1m 1.8s 54.5s 5s ~ 11.4s
    2 install 1.6s 878ms 1.9s n/a n/a
    3 install 5.3s 2.6s 4.9s 1.4s n/a
    4 install 14.9s 5.9s 19.4s 10s n/a
    5 install 25.1s 10.1s 23.6s 43.8s 6s
    6 install 1.8s 1.2s 13.1s n/a n/a
    7 install 1.3s 1.1s 20.8s n/a n/a
    8 install 2.3s 4.7s 54.1s n/a n/a
    9 update n/a n/a n/a 921ms 12.5s 3.1s 14.5s 6.1s

    簡單解讀下:

    第一點:生成 依賴關係圖 的耗時。

    • 可以通過觀察 1 和 5 兩項測試,它的差值即為對應的包管理器的耗時。
    • pnpm 是近端側 HTTP 分析方式,大概是 4 秒多一點(查詢包資訊和下載是並行的)。
    • tnpm 是服務端側分析方式,目前是 5 秒,它比 pnpm 少了網路延遲但速度卻一樣,後面我們還需要繼續優化

    在企業場景中,依賴的模組是相對收斂的,由於 tnpm 的依賴關係圖有快取機制,故在命中快取情況下,第一項測試 tnpm 的耗時應該為 5 秒,比 pnpm 提速 3 倍。

    第二點: 檔案 IO 耗時

    • 在實際場景中, CI 場景和迭代場景,有依賴關係圖 + 無全域性快取 ,可以近似認為是 測試 5。
    • 該情況下主要耗時 = tgz 下載時間 + 檔案 IO 時間,前者數量基本一致,故差距主要是檔案 IO。
    • 資料中觀察到: tnpm 比 pnpm 快 4 秒,歸因是 FUSE 省掉解壓寫入檔案的耗時 + TAR 合併

    第三點: 本地開發常態

    • 對於 日常開發場景,有依賴關係圖 + 有全域性快取。
    • 對應於 測試 2(依賴未有新版本,二次開發),測試 3(二次開發,重灌依賴),測試 4(新應用首次開發)。
    • 從原理上分析,耗時 = 依賴關係圖更新 + 寫入 node_modules 檔案 + 少量包的下載更新。
    • 由於 tnpm 還在開發中,本次未能測試該項,不過從以上公式分析,tnpm 比 pnpm 有 IO 優勢。

    小結下:tnpm 比起 pnpm 的速度優勢在於 依賴關係圖 的 5 秒 + FUSE 免解壓的 4 秒。

    未來寄望

    前端的包管理已經發展了近十年了,從 npm 拓荒時的積極進取,到 bower 等認輸後 npm 的四顧茫然原地蹉跎,到鯰魚 yarn 出現後的群雄逐鹿,再到 pnpm 的精益求精。

    我們認為前端依賴的優化之路和治理之路,還任重道遠,希望能和國內外同行,繼續加強合作,一起推動 npm 的進化。

    cnpm 在 2022 年的規劃:

    • 在 tnpm rapid 模式完善後,將把對應的能力,以及 npmfs 套件開源出來。(因此目前社群同學還沒辦法體驗)
    • cnpm/npmcore 在重構中,支援更好的私有化部署。(歡迎參與開源)
    • 關於 npm 在企業級實踐中的經驗分享,希望有時間能寫成小冊分享出來。

    同時我們也呼籲: 前端的包管理的規範化

    • 有類似 ECMA 之類的標準,來規範各個包管理器的行為。
    • 有類似 Test 262 的測試用例規範。
    • 處於薛定諤階段的 ESM 和 CommonJS 規範的加速演進。
    • 前端 和 Node.js 不同場景依賴的差異性的混亂局面得到解決。

    寫在最後

    經過這一年的優化,我們收穫很多,也在思考和總結:為什麼我們能做到這事?

    我們的 優勢之一是 雲 + 端 的全域性掌控力 :不僅僅是一個近端的 CLI,還比其他包管理器多了一個遠端的 registry 服務,可以更深度的進行優化。

    其次,我們的團隊成員更加的 多元化,具備來自不同領域的知識 ,讓我們可以跳出前端視野侷限,從作業系統、檔案系統、網路調優等方面去碰撞靈感。

    借用之前 死月 分享的一句話: 黑魔法和黑科技的區別在於:前者用"又不是不能用"的髒活來實現目的,後者用跨領域的知識來實現降維打擊。

    在企業級應用場景裡面,前端構建提速的優化之路,不僅僅是依賴安裝這一環節,它是一個系統化的工程,還有非常多的優化點,欲知詳情,歡迎加入我們。

    關於我

    我是天豬,目前在螞蟻體驗技術部 廣州分部,負責前端基礎設施的建設,團隊主要以 Node.js 為主,區域性會用 go 寫 mesh,用 rust 寫模組,開源了 eggjs, cnpm 等專案,等你加入。

    『Node.js 在前端領域是一個不可或缺的基礎設施,或許未來前端的變革使得一切工程問題從根本上得到解決,但不管怎樣,我只是希望當下能認真記錄自己以及同行者們在這個領域的所見所想,與正在經歷前端工業化演進並被此過程困擾的同學交流心得。』-- by 天豬