極限挑戰(zhàn):使用 Go 打造百億級文件系統(tǒng)的實踐之旅
JuiceFS 企業(yè)版是一款為云環(huán)境設(shè)計的分布式文件系統(tǒng),單命名空間內(nèi)可穩(wěn)定管理高達百億級數(shù)量的文件。
構(gòu)建這個大規(guī)模、高性能的文件系統(tǒng)面臨眾多復(fù)雜性挑戰(zhàn),其中最為關(guān)鍵的環(huán)節(jié)之一就是元數(shù)據(jù)引擎的設(shè)計。JuiceFS 企業(yè)版于 2017 年上線,經(jīng)過幾年的不斷迭代和優(yōu)化,在單個元數(shù)據(jù)服務(wù)進程使用 30 GiB 內(nèi)存的情況下,能夠管理約 3 億個文件,并將元數(shù)據(jù)請求的平均處理時間維持在 100 微秒量級。在當前線上某個生產(chǎn)集群中,包含了十個擁有 512 GB 內(nèi)存的元數(shù)據(jù)節(jié)點,它們共同管理著超過 200 億個文件。
為了實現(xiàn)極致的性能,JuiceFS 元數(shù)據(jù)引擎采用的是全內(nèi)存方案,并通過不斷的優(yōu)化來減小文件元數(shù)據(jù)的內(nèi)存占用。目前,在管理相同文件數(shù)的情況下,JuiceFS 所需內(nèi)存大概只有 HDFS NameNode 的 27%,或者 CephFS MDS 的 3.7 %。這種極高的內(nèi)存效率,意味著在相同的硬件資源下,JuiceFS 能夠處理更多的文件和更復(fù)雜的操作,從而實現(xiàn)更高的整體系統(tǒng)性能。
本文將詳細介紹我們在元數(shù)據(jù)引擎方面進行的各項探索和優(yōu)化措施,希望這能讓 JuiceFS 用戶對其有更多的了解,在應(yīng)對極限場景時能有更強的信心。同時我們也希望它能拋磚引玉,為設(shè)計大規(guī)模系統(tǒng)的同行提供有價值的參考。
一、JuiceFS 簡介
JuiceFS 主要分為三大組件:
-
? 客戶端:它是與業(yè)務(wù)交互的接入層。JuiceFS 支持多種協(xié)議,包括 POSIX、Java SDK、Kerbenetes CSI Driver 和 S3 Gateway 等。
-
? 元數(shù)據(jù)引擎:負責維護文件系統(tǒng)的目錄樹結(jié)構(gòu),以及各個文件的屬性等。
-
? 數(shù)據(jù)存儲:負責存儲普通文件的具體內(nèi)容,通常由亞馬遜 S3、阿里云 OSS 等對象存儲擔任。

JuiceFS 架構(gòu)圖
目前 JuiceFS 擁有社區(qū)版和企業(yè)版兩個版本,它們的架構(gòu)基本一致,主要區(qū)別在于元數(shù)據(jù)引擎的實現(xiàn)。社區(qū)版的元數(shù)據(jù)引擎一般使用現(xiàn)有的數(shù)據(jù)庫服務(wù)(如架構(gòu)圖中所示),如 Redis、MySQL、TiKV 等;而企業(yè)版則使用一個自主研發(fā)的元數(shù)據(jù)引擎。這個引擎能在更低資源消耗的情況下提供更高的性能,同時也能額外支持一些企業(yè)級需求。下文將介紹我們在研發(fā)這款元數(shù)據(jù)引擎過程中的思考與實踐。
二、元數(shù)據(jù)引擎設(shè)計
2.1 使用 Go 作為開發(fā)語言
底層系統(tǒng)級軟件的開發(fā)通常以 C/C++ 為主,而 JuiceFS 選擇了 Go 作為開發(fā)語言,這主要是考慮到:
-
1. 開發(fā)效率更高:Go 語法相較 C 語言更為簡潔,表達能力更強;同時 Go 自帶內(nèi)存管理功能,以及如 pprof 等強大的工具鏈。
-
2. 程序執(zhí)行性能出色:Go 本身也是一門編譯型語言,編寫的程序性能在絕大部分情況下并不遜色于 C 程序。
-
3. 程序可移植性更佳:Go 對靜態(tài)編譯支持的更好,更容易讓程序在不同操作系統(tǒng)上直接運行。
-
4. 支持多語言 SDK:借助原生的 cgo 工具 Go 代碼也能編譯成共享庫文件(.so 文件),方便其他語言加載。
當然,Go 語言在帶來便利的同時,也隱藏了一些底層細節(jié),一定程度上會影響程序?qū)τ布Y源的使用效率(尤其是 GC 對內(nèi)存的管理),因此在性能關(guān)鍵處我們需要進行針對性優(yōu)化。
2.2 性能提升策略:全內(nèi)存,無鎖服務(wù)
要提升性能,我們首先需要理解元數(shù)據(jù)引擎在分布式文件系統(tǒng)中的核心職責。通常來說,它主要承擔以下兩項關(guān)鍵任務(wù):
-
1. 管理海量文件的元數(shù)據(jù)
要完成這項任務(wù)常見的有兩種設(shè)計方案。一種是將所有文件的元數(shù)據(jù)都加載到內(nèi)存中,如 HDFS 的 NameNode,這樣能提供很好的性能,但勢必需要大量的內(nèi)存資源。另一種是僅緩存部分元數(shù)據(jù)在內(nèi)存,如 CephFS 的 MDS。當請求的元數(shù)據(jù)不在緩存中時,MDS 需要暫存該請求,并通過網(wǎng)絡(luò)從硬盤(元數(shù)據(jù)池)中讀取相應(yīng)內(nèi)容,解析后再進行重試。顯然,這很容易產(chǎn)生時延尖刺,影響業(yè)務(wù)體驗。因此,在實踐中為了滿足業(yè)務(wù)的低時延訪問需要,通常會盡量調(diào)高 MDS 內(nèi)存限制來緩存更多的文件,甚至全部文件。
JuiceFS 企業(yè)版追求極致性能,因此采用的是第一種全內(nèi)存方案,并通過不斷的優(yōu)化來減小文件元數(shù)據(jù)的內(nèi)存占用。全內(nèi)存模式通常會使用實時落盤的事務(wù)日志來保證數(shù)據(jù)的可靠性,JuiceFS 還使用了 Raft 共識算法來實現(xiàn)元數(shù)據(jù)的多機復(fù)制和自動故障切換。
-
2. 快速處理元數(shù)據(jù)請求
元數(shù)據(jù)引擎的關(guān)鍵性能指標是每秒能處理的請求數(shù)量。通常,元數(shù)據(jù)請求需要保證事務(wù)性,并涉及多個數(shù)據(jù)結(jié)構(gòu),由多線程并發(fā)處理時一般需要復(fù)雜的鎖機制以確保數(shù)據(jù)一致性和安全性。當事務(wù)的沖突比較多時,多線程并不能有效提升吞吐量,反而會因為太多的鎖操作導致延遲增加,這個現(xiàn)象在高并發(fā)場景中尤其明顯。
JuiceFS 采用了一種不同的方法,即類似于 Redis 的無鎖模式。在這種模式下,所有核心數(shù)據(jù)結(jié)構(gòu)的相關(guān)操作都在單個線程中執(zhí)行。這種單線程方法不僅保證了每個操作的原子性(避免了操作被其他線程打斷的問題),還減少了線程間的上下文切換和資源競爭,從而提高了系統(tǒng)的整體效率。同時,它大大降低了系統(tǒng)復(fù)雜度,提升了穩(wěn)定性和可維護性。得益于全內(nèi)存的元數(shù)據(jù)存儲模式,請求都可以被非常高效地處理,CPU 不容易成為瓶頸。
2.3 多分區(qū)水平擴展
單個元數(shù)據(jù)服務(wù)進程可用的內(nèi)存是有上限的,而且單進程內(nèi)存過高時也會逐漸出現(xiàn)效率下降的情況。JuiceFS 會通過聚合分布在多個節(jié)點的虛擬分區(qū)中的元數(shù)據(jù)來實現(xiàn)水平擴展,以支撐更大的數(shù)據(jù)規(guī)模和更高的性能需求。
具體來說,每個分區(qū)各自負責文件系統(tǒng)中的一部分子樹,由客戶端來協(xié)調(diào)和管理多個分區(qū)中的文件,把它們組裝成單一的命名空間;同時這些文件能夠在多個分區(qū)間根據(jù)需要進行動態(tài)遷移。例如,一個管理超過 200 億文件的集群,就使用了 10 個 512 GB 內(nèi)存的元數(shù)據(jù)節(jié)點,部署了 80 個分區(qū)。一般情況下,我們建議將單個元數(shù)據(jù)服務(wù)進程的內(nèi)存控制在 40 GiB 以內(nèi),并通過多分區(qū)水平擴展的方式來管理更多的文件。
文件系統(tǒng)的訪問通常有很強的局部性,換言之文件一般在同一個目錄或者相鄰的目錄間移動。因此 JuiceFS 實現(xiàn)的動態(tài)子樹拆分方式中會盡量維持較大的子樹,使得絕大部分元數(shù)據(jù)操作都發(fā)生在單一的分區(qū)中。這樣的好處是能大幅減少分布式事務(wù)的使用,使得集群在大規(guī)模擴展后仍然能保持跟單分區(qū)接近的元數(shù)據(jù)響應(yīng)延遲。
三、內(nèi)存優(yōu)化
隨著數(shù)據(jù)量的增加,元數(shù)據(jù)服務(wù)需要的內(nèi)存也隨之增加,這不僅會影響系統(tǒng)的性能,同時也會讓硬件成本快速上升。因此,在海量文件場景中,減少元數(shù)據(jù)的內(nèi)存占用對于維持系統(tǒng)穩(wěn)定和控制成本是非常關(guān)鍵的。
為了實現(xiàn)這一目標,我們在內(nèi)存分配和使用上進行了廣泛的探索和嘗試。接下來,我們將分享一些經(jīng)過多年迭代和優(yōu)化,被證明為有效的措施。
3.1 使用內(nèi)存池來減少分配
這是在 Go 程序中非常常見的優(yōu)化手段,主要是借助標準庫中的 sync.Pool 結(jié)構(gòu)。其基本原理是,用完的數(shù)據(jù)結(jié)構(gòu)不丟棄,而是將它放回到一個池中。當再次需要使用相同類型的數(shù)據(jù)結(jié)構(gòu)時,可以直接從池中獲取,而不需要申請。這種方法可以有效減少內(nèi)存申請和釋放的次數(shù),從而提高性能。這里有個簡單的例子:
pool := sync.Pool{
New: func() interface{} {
buf := make([]byte, 1<<17)
return &buf
},
}
buf := pool.Get().(*[]byte)
// do some work
pool.Put(buf)
在初始化時,通常需要定義一個 New 函數(shù)來創(chuàng)建新的結(jié)構(gòu)體。使用時,首先通過 Get 方法獲取對象,并轉(zhuǎn)換為相應(yīng)類型;使用完畢后,通過 Put 方法將結(jié)構(gòu)體放回池中。值得注意的是,放回去后這個結(jié)構(gòu)體僅有弱引用,也就是說它隨時可能被垃圾回收機制(GC)回收。
示例中的結(jié)構(gòu)體是一段預(yù)定長度的內(nèi)存切片,因此我們得到的其實是一個簡單的內(nèi)存池。這個池結(jié)合下一小節(jié)的精細管理手段,就能實現(xiàn)程序?qū)?nèi)存的高效使用。
3.2 自主管理小塊內(nèi)存分配
在 JuiceFS 元數(shù)據(jù)引擎中,最關(guān)鍵部分就是要維護目錄樹結(jié)構(gòu),大致如下:
目錄樹結(jié)構(gòu)示意圖
其中:
-
? 節(jié)點(node)記錄了每個文件或目錄的屬性,一般占用 50 到 100 字節(jié)
-
? 邊(edge)描述父子節(jié)點間的聯(lián)系,一般占用 60 到 70 字節(jié)
-
? 數(shù)據(jù)塊(extent)則記錄數(shù)據(jù)所在的位置,一般占用約 40 字節(jié)
可見這些結(jié)構(gòu)體都非常小,但是數(shù)量會非常龐大。Go 的 GC 不支持分代,也就是說如果將它們都交由 GC 來管理,就需要在每次進行內(nèi)存回收時都將它們掃描一遍,并且標記所有被引用的對象。這個過程會非常慢,不僅使得內(nèi)存無法及時回收,還可能消耗過多的 CPU 資源。
為了能夠高效地管理這些海量小對象,我們使用 unsafe 指針(包括 uintptr)來繞過 Go 的 GC 進行手動內(nèi)存分配和管理。實現(xiàn)時,元數(shù)據(jù)引擎每次向系統(tǒng)申請大塊的內(nèi)存,然后根據(jù)對象的大小拆分成相同尺寸的小塊來使用。在保存指向這些手動分配的內(nèi)存塊的指針時,盡量使用 unsafe.Pointer 甚至 uintptr 類型,這樣 GC 就不需要掃描這些指針,也就大幅減輕了其在執(zhí)行內(nèi)存回收時的工作量。
具體而言,我們設(shè)計了一個名為 Arena 的元數(shù)據(jù)內(nèi)存池,其中包含有多個不同的桶,用來隔離大小差異較大的結(jié)構(gòu)體。每個桶存放的是較大的內(nèi)存塊,例如 32 KiB 或 128 KiB 。需要使用元數(shù)據(jù)結(jié)構(gòu)體時,通過 Arena 接口找到相應(yīng)的桶,并從其中的內(nèi)存塊劃分一小段來使用;使用完畢后,同樣通知 Arena 將其放回內(nèi)存池。它的設(shè)計示意圖如下:
JuiceFS 元數(shù)據(jù)內(nèi)存池 Arena 示意圖
具體的管理細節(jié)較為復(fù)雜,感興趣的讀者可以了解更多關(guān)于 tcmalloc 和 jemalloc 等內(nèi)存分配器的實現(xiàn)原理,基本思路與它們類似。以下介紹 Arena 中的關(guān)鍵代碼:
// 內(nèi)存塊常駐
var slabs = make(map[uintptr][]byte)
p := pagePool.Get().(*[]byte) // 128 KiB
ptr := unsafe.Pointer(&(*p)[0])
slabs[uintptr(ptr)] = *p
其中 slabs 是一個全局的 map,它記錄了 Arena 里所有被申請的內(nèi)存塊,這樣 GC 就能知道這些大內(nèi)存塊正在被使用。下面一段是結(jié)構(gòu)體創(chuàng)建的代碼:
func (a *arena) Alloc(size int) unsafe.Pointer {...}
size := nodeSizes[type]
n := (*node)(nodeArena.Alloc(size))
// var nodeMap map[uint32, uintptr]
nodeMap[n.id] = uintptr(unsafe.Pointer(n)))
其中 Arena 的 Alloc 函數(shù)用于申請指定大小的內(nèi)存,并返回一個 unsafe.Pointer 指針。創(chuàng)建一個 node 時,我們先確定其類型所需的大小,然后將申請到的指針轉(zhuǎn)換為所需結(jié)構(gòu)體類型,即可正常使用。必要時,我們會將這個 unsafe.Pointer 轉(zhuǎn)成 uintptr 保存在 nodeMap 中。這是一個非常大的映射(map),用來根據(jù) node ID 快速找到對應(yīng)的結(jié)構(gòu)體。
在這種設(shè)計下,從 GC 角度看,會發(fā)現(xiàn)程序申請了許多 128 KiB 的內(nèi)存塊,且一直在使用,但里面具體的內(nèi)容顯然不需要它來操心。另外,雖然 nodeMap 中含有數(shù)億甚至數(shù)十億元素,但其鍵值均為數(shù)值類型,因此 GC 并不需要掃描每一個鍵值對。這種設(shè)計對 GC 非常友好,即使上百 GiB 的內(nèi)存也能輕松完成掃描。
3.3 壓縮空閑目錄
在 2.3 節(jié)中提到過,文件系統(tǒng)的訪問具有很強的局部性,應(yīng)用程序在一段時間內(nèi)通常只會頻繁訪問幾個特定的目錄,而其他部分則相對閑置,全局隨機訪問的情況較少。基于此,我們可以將不活躍的目錄元數(shù)據(jù)進行壓縮,從而達到減少內(nèi)存占用的效果。如下圖所示:
JuiceFS 目錄序列化和壓縮示意圖
當目錄 dir 處于空閑狀態(tài)時,可以將它和它下面所有一級子項的元數(shù)據(jù)按預(yù)定格式緊湊地序列化,得到一段連續(xù)的內(nèi)存緩沖區(qū);然后再將這段緩沖區(qū)進行壓縮,變成一段更小的內(nèi)存。
通常情況下,將多個結(jié)構(gòu)體一起序列化后能節(jié)省近一半的內(nèi)存,而壓縮處理則能進一步節(jié)省大約一半到三分之二的內(nèi)存。因此,這種方法大幅降低了單個文件元數(shù)據(jù)的平均占用。然而,序列化和壓縮過程會占用一定的 CPU 資源,并可能增加請求的延遲。為了平衡效率,我們在程序內(nèi)部監(jiān)控 CPU 狀態(tài),僅在 CPU 有閑余時觸發(fā)此流程,并將每次處理的文件數(shù)限制在 1000 以內(nèi),以保證其快速完成。
3.4 為小文件設(shè)計更緊湊的格式
為了支持高效的隨機讀寫,JuiceFS 中普通文件的元數(shù)據(jù)會分為三個層級來進行索引:fnode 、chunks 和 slice,其中 chunks 是一個數(shù)組,slice 則放在一個哈希表中。初始設(shè)計時,每個文件都需要分配這 3 塊內(nèi)存,但后來我們發(fā)現(xiàn)這種方式對絕大部分小文件而言并不夠高效。因為小文件通常只有一個 chunk,這個 chunk 也只有一個 slice,而且 slice 的長度跟文件的長度是一致的。
因此,我們?yōu)檫@類小文件引入了一個更緊湊高效的內(nèi)存格式。在新的格式中,我們只需要記錄 slice 的 ID,再從文件的長度得到 slice 的長度,無須存儲 slice 本身。同時,我們調(diào)整了 fnode 的結(jié)構(gòu)。原來 fnode 中保存了指向 chunks 數(shù)組的指針,而它指向的數(shù)組中只有一個 8 字節(jié)的 slice ID,現(xiàn)在我們直接將這個 ID 保存在了指針變量的位置。這種用法類似 C 語言里的 union 結(jié)構(gòu),即在同一內(nèi)存位置根據(jù)實際情況存儲不同類型的數(shù)據(jù)。經(jīng)此調(diào)整后,每個小文件就只有一個 fnode 對象,而無需其他 chunk 列表和 slices 信息。具體示意圖如下:
小文件優(yōu)化示意圖
優(yōu)化后的格式可以為每個小文件節(jié)省約 40 字節(jié)內(nèi)存。同時,這也減少了內(nèi)存的分配和索引操作,訪問起來會更快。
3.5 整體優(yōu)化效果
下圖總結(jié)了到目前為止的優(yōu)化成果:

在圖中,文件的平均元數(shù)據(jù)大小呈現(xiàn)顯著下降。最初,每個文件的元數(shù)據(jù)平均占用近 600字節(jié)。通過自行管理內(nèi)存,這一數(shù)字降至大約 300 字節(jié),并大幅縮減了 GC 的開銷。隨后,對空閑目錄進行序列化處理,進一步將其減少到約 150 字節(jié)。最后,通過內(nèi)存壓縮技術(shù),平均大小降低到了大約 50 字節(jié)。當然,元數(shù)據(jù)服務(wù)在運行時還需要負責狀態(tài)監(jiān)控、會話管理等任務(wù),并應(yīng)對網(wǎng)絡(luò)傳輸?shù)雀鞣N臨時消耗,實際的內(nèi)存占用量可能達到這個核心值的兩倍,因此我們一般按每個文件 100 字節(jié)來預(yù)估所需的硬件資源。
常見分布式文件系統(tǒng)的單文件內(nèi)存占用情況如下:
-
? HDFS:370 字節(jié)(數(shù)據(jù)來源:網(wǎng)絡(luò)文章[1])
-
? CephFS:2700 字節(jié)(數(shù)據(jù)來源:Nautilus 版本集群監(jiān)控 - 32G 內(nèi)存 1200萬文件)
-
? Alluxio (Heap模式):2100 字節(jié)(數(shù)據(jù)來源:官方文檔[2] - 64G 內(nèi)存 3000萬文件)
-
? JuiceFS 社區(qū)版 Redis 引擎:430 字節(jié)(數(shù)據(jù)來源:官方文檔[3])
-
? JuiceFS 企業(yè)版:100 字節(jié)(數(shù)據(jù)來源:線上集群監(jiān)控 - 30G 內(nèi)存 3億文件)
可以看到,JuiceFS 在元數(shù)據(jù)內(nèi)存占用方面的表現(xiàn)非常突出,僅為 HDFS NameNode 的 27%,CephFS MDS 的 3.7 %。它不僅意味著更高的內(nèi)存效率,也意味著在相同的硬件資源下,JuiceFS 能夠處理更多的文件和更復(fù)雜的操作,從而提高整體系統(tǒng)性能。
四、小結(jié)
文件系統(tǒng)的核心之一在于其元數(shù)據(jù)的管理,而當構(gòu)建一款能夠處理百億文件數(shù)規(guī)模的分布式文件系統(tǒng)時,這一設(shè)計任務(wù)變得尤為復(fù)雜。本文介紹了 JuiceFS 在設(shè)計元數(shù)據(jù)引擎時所做的關(guān)鍵決策,并詳細介紹了內(nèi)存池、自主管理小塊內(nèi)存、壓縮空閑目錄以及優(yōu)化小文件格式這 4 個內(nèi)存優(yōu)化手段。這些措施是我們在不斷探索、嘗試和迭代的過程中得出的成果,最終使 JuiceFS 的文件元數(shù)據(jù)平均內(nèi)存占用下降至 100 字節(jié),令其更能夠應(yīng)對更多更極限的使用場景。
關(guān)于作者
負責豆瓣數(shù)據(jù)平臺的功能開發(fā)和維護直播回顧 :https://www.bilibili.com/video/BV1wX4y1X7em/直播回顧:https://www.bilibili.com/video/BV1wX4y1X7em/直播回顧:https://www.bilibili.com/video/BV1wX4y1X7em/直播回顧:https://www.bilibili.com/video/BV1wX4y1X7em/
Sandy
JuiceFS 核心系統(tǒng)工程師
引用鏈接
[1] 網(wǎng)絡(luò)文章: https://blog.csdn.net/lingbo229/article/details/81079769[2] 官方文檔: https://docs.alluxio.io/ee-da/user/stable/en/operation/Metastore.html[3] 官方文檔: https://juicefs.com/docs/zh/community/redis_best_practices
推薦閱讀:
節(jié)后首場meet up,議題征集及現(xiàn)場招聘正式啟動
Go區(qū)不大,創(chuàng)造神話,科目三殺進來了
想要了解Go更多內(nèi)容,歡迎掃描下方??關(guān)注公眾號, 回復(fù)關(guān)鍵詞 [實戰(zhàn)群] ,就有機會進群和我們進行交流
分享、在看與點贊Go
