全域性虛擬儲存
只安裝一次。每個專案都連結到同一份副本。
你機器上的每個專案都相依那幾項沉重的公共包 —— typescript、next、webpack、@babel/*、react-dom。如果沒有共享儲存,每個 node_modules 都會各自儲存一份副本,而每次新的 bun install 又會把它們全部重新寫一遍。
全域性虛擬儲存將其變為安裝一次,到處連結。包檔案儲存在一個共享快取中;每個專案的 node_modules 都是一棵指向它的符號連結淺層樹。第二次檢出、新分支工作樹、CI 工作區——它們都指向那份已經在磁碟上的副本。
結果是:熱安裝速度大約快 7 倍(每個包只需一個符號連結,而不是複製每個檔案),而且 node_modules 會從每個專案的數百 MB 縮減到幾 MB 的連結。
啟用
全域性虛擬儲存預設關閉。它僅適用於隔離連結器;hoisted 連結器不會使用它。
要為某個專案啟用它:
[install]
linker = "isolated"
globalStore = true
或者通過環境變數在每次呼叫時啟用:
BUN_INSTALL_GLOBAL_STORE=1 bun install --linker isolated
要顯式關閉它(預設行為),請設定 globalStore = false 或 BUN_INSTALL_GLOBAL_STORE=0。
為什麼它很快
如果沒有全域性儲存,隔離連結器會在每次安裝時為每個包呼叫 clonefileat()(macOS)或 link()/copyfile()(其他平臺),即使包快取是熱的,且唯一缺少的是 node_modules。在 macOS 上對一個包含 1,400 個包的測試樣例進行熱安裝效能分析後發現,主執行緒有 95.4 % 的時間都花在 clonefileat 中:
按棧頂排序(bun install --linker isolated,熱快取):
clonefileat 891 / 934 samples
__openat 15
__read_nocancel 10
APFS 上的 clonefileat 會持有卷級核心鎖,因此增加執行緒幾乎沒有幫助。八個執行緒只將 2,830 個目錄的克隆時間從 959 ms 縮短到 743 ms。解決方法是在熱路徑中完全不呼叫它。
有了全域性儲存,熱路徑對每個包只需要一次 access()(全域性條目是否存在?)再加一次 symlink()(把專案指向它)。
基準測試
在包含 1,400 個包的 React/webpack/Babel/jest 測試樣例上進行的熱 CI 安裝,運行於 Apple Silicon macOS,使用 hyperfine --warmup 3 --runs 10。鎖檔案存在,包快取是熱的,並且每次執行之間都會刪除 node_modules:
| 牆鍾時間 | 系統時間 | clonefileat | 系統呼叫總數 | |
|---|---|---|---|---|
--linker hoisted | 823.9 ms | 477 ms | 1,387 | 7,857 |
--linker isolated, globalStore=false | 840.9 ms | 1,256 ms | 1,387 | — |
--linker isolated,全域性儲存 | 124.8 ms | 94 ms | 0 | 4,957 |
相比未啟用全域性儲存的相同連結器,快 6.7 倍;相比 hoisted,快 6.6 倍。
磁碟
同一測試樣例的 node_modules 大小(在 APFS 上執行 du -sh node_modules;clonefile 複製是寫時複製,所以 hoisted/每專案的數字是 邏輯 大小——在不支援 CoW 的檔案系統上,這也就是物理大小):
每個專案的 node_modules | 磁碟上的共享佔用 | |
|---|---|---|
--linker hoisted | 391 MB | — |
--linker isolated, globalStore=false | 391 MB | — |
--linker isolated,全域性儲存 | 約 5 MB 的符號連結 | 391 MB 僅一份 |
把同一個專案克隆五份,差別就是大約 2 GB 的重複包檔案 versus 總計約 400 MB。盈虧平衡點是一個專案;從那之後,每增加一個檢出、分支工作樹或 CI 工作區都是免費的。
真實世界冷態→熱態
克隆後的真實儲存庫從冷態到熱態之間的耗時(macOS arm64):
| 專案 | 包數量 | 冷態 | 熱態 |
|---|---|---|---|
| cal.com | ~3,580 | 37.4 s | 4.7 s |
| remix | ~1,750 | 23.1 s | 2.0 s |
| excalidraw | ~1,332 | 5.9 s | 1.1 s |
| hono | ~790 | 4.5 s | 1.3 s |
next build (create-next-app) | ~382 | 1.0 s | 0.35 s |
目錄結構
與隔離安裝相比,磁碟上的佈局多加了一層間接層:
~/.bun/install/cache/
├── react@18.3.1@@@1/ # 包快取(不變)
│ └── ...包檔案...
└── links/ # 全域性虛擬儲存
└── react@18.3.1-5664d3cd670b3205/ # <儲存路徑>-<條目雜湊>
└── node_modules/
├── react/ # 包檔案(只建立一次)
├── loose-envify -> ../../loose-envify@1.4.0-ea24…/node_modules/loose-envify
└── .bin/
└── ...
project/node_modules/
├── .bun/
│ ├── node_modules/ # 隱藏的提升層(每個專案)
│ │ └── react -> ../react@18.3.1/node_modules/react
│ └── react@18.3.1 -> ~/.bun/install/cache/links/react@18.3.1-5664d3cd670b3205
└── react -> .bun/react@18.3.1/node_modules/react
16 位十六進位制的 entry_hash 字尾編碼了條目的已解析相依閉包:包自身的儲存路徑和 tarball 完整性,以及它連結到的每個相依的雜湊。兩個將 react@18.3.1 解析到相同傳遞版本集合的專案會共享同一個全域性目錄。若某個專案將傳遞相依解析到不同版本,則會獲得單獨的全域性條目,其相依符號連結會指向正確的同級條目。參與相依迴圈的包會共享一個基於整個強連通分量計算出的雜湊。因此,該鍵不受某個專案相依圖中最先訪問到哪個成員的影響。
哪些內容保持專案本地
只有在條目能夠安全共享時,它才會存在於全域性儲存中。以下情況會回退到每個專案各自的 node_modules/.bun/<storepath>/ 目錄:
- 包通過
bun patch應用了 patch —— 補丁後的內容是專案專屬的; - 包列在
trustedDependencies中(或通過bun add --trust被信任)—— 它的生命週期指令碼可能會修改安裝目錄,而通過專案符號連結執行指令碼會修改共享副本; - 該包,或它連結到的任何相依,是
workspace:、file:或link:相依 —— 這些會解析到專案本地路徑,其他專案看不到。
不符合條件的情況會向上傳播:如果 your-app 相依的 internal-utils 是一個工作區包,那麼 internal-utils 是專案本地的,所有連結到它的條目也都是專案本地的。如果某個條目在兩次安裝之間失去資格(新應用了補丁或新加入信任列表),Bun 會將其從全域性儲存中分離,並在下次安裝時於專案本地重建。共享條目保持不變。
peer 相依
Bun 會將已解析的 peer 相依(必需和可選)作為相依符號連結納入每個全域性條目。這些 peer 相依會參與條目雜湊的計算。對於只在 peerDependenciesMeta 中列出某個名稱、但沒有對應 peerDependencies 條目的包,Bun 會合成一個隱式的 "*" 可選 peer(與 pnpm 和 yarn 一致)。因此,像 webpack 這樣僅在 peerDependenciesMeta 中宣告 webpack-cli 的包,在專案中安裝了 webpack-cli 時,仍會在其全域性條目中獲得一個 webpack-cli 符號連結。
取捨
幽靈相依回退
當包位於專案的 node_modules/.bun/ 下時,Node 的模組解析會沿著 node_modules/.bun/node_modules/ —— 這個隱藏的 hoisted 層 —— 向上查詢,然後才到專案根目錄。有了全域性儲存後,包會 realpath 到 <cache>/links/,因此從包內部看,這一層就不再位於解析路徑上。
實際中這隻會影響真正的幽靈相依:某個包 require('helper'),但它從未在 dependencies、peerDependencies 或 peerDependenciesMeta 中宣告過這個東西。如果遇到這種情況,把 helper 加到消費方包的相依裡(這是正確修復方式),或者設定 globalStore = false。
publicHoistPattern 和 hoistPattern 會將相依提升到專案的 node_modules 中,而全域性儲存中的包無法訪問該目錄。它們仍可用於從你自己的原始碼中解析已提升的包。
node_modules 大多是符號連結
不會跟隨符號連結就掃描 node_modules 的工具,或者通過字串相等比較檔案路徑的工具,可能會表現不同。這與任何 pnpm 風格佈局的注意事項相同。
磁碟使用
每個唯一的 (package, version, resolved-dependency-set) 三元組都會在 <cache>/links/ 中獲得一個目錄。在許多專案之間,這能帶來很大的淨收益:磁碟上只保留一份副本,而不是每個檢出都保留一份。但隨著新版本和新的 peer 相依組合不斷加入,儲存也會逐漸增大。執行 bun pm cache rm 可以清除包括全域性儲存在內的快取;下次安裝時只會重新填充該專案所需的內容。
並行
多個 bun install 行程(並行 CI 任務、並行工作區建置)可能會爭相填充同一個全域性條目。每個行程都會在私有的 <entry>.tmp-<random>/ 暫存目錄中建置整個條目(包檔案、相依符號連結、bin 連結)。最後一步會將該目錄重新命名到目標位置。重新命名失敗的行程會收到 EEXIST,然後丟棄其相同的暫存樹。建置過程中崩潰的寫入行程只會留下一個未被引用的暫存目錄,下次安裝時會忽略它。因此,已釋出的條目始終是完整的;不需要單獨的完整性標記。
相關文件
- 套件管理器 > 隔離安裝 — 全域性儲存所相依的連結器
- 套件管理器 > 全域性快取 — 已下載包的儲存位置
- 執行環境 > bunfig —
bunfig.toml參考