使用 Go 打造百億級文件系統的實踐之旅

JuiceFS 企業版是一款爲雲環境設計的分佈式文件系統,單命名空間內可穩定管理高達百億級數量的文件。

構建這個大規模、高性能的文件系統面臨衆多複雜性挑戰,其中最爲關鍵的環節之一就是元數據引擎的設計。JuiceFS 企業版於 2017 年上線,經過幾年的不斷迭代和優化,在單個元數據服務進程使用 30 GiB 內存的情況下,能夠管理約 3 億個文件,並將元數據請求的平均處理時間維持在 100 微秒量級。在當前線上某個生產集羣中,包含了十個擁有 512 GB 內存的元數據節點,它們共同管理着超過 200 億個文件。

爲了實現極致的性能,JuiceFS 元數據引擎採用的是全內存方案,並通過不斷的優化來減小文件元數據的內存佔用。目前,在管理相同文件數的情況下,JuiceFS 所需內存大概只有 HDFS NameNode 的 27%,或者 CephFS MDS 的 3.7 %。這種極高的內存效率,意味着在相同的硬件資源下,JuiceFS 能夠處理更多的文件和更復雜的操作,從而實現更高的整體系統性能。

本文將詳細介紹我們在元數據引擎方面進行的各項探索和優化措施,希望這能讓 JuiceFS 用戶對其有更多的瞭解,在應對極限場景時能有更強的信心。同時我們也希望它能拋磚引玉,爲設計大規模系統的同行提供有價值的參考。

一、JuiceFS 簡介

JuiceFS 主要分爲三大組件:

JuiceFS 架構圖

目前 JuiceFS 擁有社區版和企業版兩個版本,它們的架構基本一致,主要區別在於元數據引擎的實現。社區版的元數據引擎一般使用現有的數據庫服務(如架構圖中所示),如 Redis、MySQL、TiKV 等;而企業版則使用一個自主研發的元數據引擎。這個引擎能在更低資源消耗的情況下提供更高的性能,同時也能額外支持一些企業級需求。下文將介紹我們在研發這款元數據引擎過程中的思考與實踐。

二、元數據引擎設計

2.1 使用 Go 作爲開發語言

底層系統級軟件的開發通常以 C/C++ 爲主,而 JuiceFS 選擇了 Go 作爲開發語言,這主要是考慮到:

    1. 開發效率更高:Go 語法相較 C 語言更爲簡潔,表達能力更強;同時 Go 自帶內存管理功能,以及如 pprof 等強大的工具鏈。
    1. 程序執行性能出色:Go 本身也是一門編譯型語言,編寫的程序性能在絕大部分情況下並不遜色於 C 程序。
    1. 程序可移植性更佳:Go 對靜態編譯支持的更好,更容易讓程序在不同操作系統上直接運行。
    1. 支持多語言 SDK:藉助原生的 cgo 工具 Go 代碼也能編譯成共享庫文件(.so 文件),方便其他語言加載。

當然,Go 語言在帶來便利的同時,也隱藏了一些底層細節,一定程度上會影響程序對硬件資源的使用效率(尤其是 GC 對內存的管理),因此在性能關鍵處我們需要進行鍼對性優化。

2.2 性能提升策略:全內存,無鎖服務

要提升性能,我們首先需要理解元數據引擎在分佈式文件系統中的核心職責。通常來說,它主要承擔以下兩項關鍵任務:

  1. 1. 管理海量文件的元數據

要完成這項任務常見的有兩種設計方案。一種是將所有文件的元數據都加載到內存中,如 HDFS 的 NameNode,這樣能提供很好的性能,但勢必需要大量的內存資源。另一種是僅緩存部分元數據在內存,如 CephFS 的 MDS。當請求的元數據不在緩存中時,MDS 需要暫存該請求,並通過網絡從硬盤(元數據池)中讀取相應內容,解析後再進行重試。顯然,這很容易產生時延尖刺,影響業務體驗。因此,在實踐中爲了滿足業務的低時延訪問需要,通常會盡量調高 MDS 內存限制來緩存更多的文件,甚至全部文件。

JuiceFS 企業版追求極致性能,因此採用的是第一種全內存方案,並通過不斷的優化來減小文件元數據的內存佔用。全內存模式通常會使用實時落盤的事務日誌來保證數據的可靠性,JuiceFS 還使用了 Raft 共識算法來實現元數據的多機複製和自動故障切換。

  1. 2. 快速處理元數據請求

元數據引擎的關鍵性能指標是每秒能處理的請求數量。通常,元數據請求需要保證事務性,並涉及多個數據結構,由多線程併發處理時一般需要複雜的鎖機制以確保數據一致性和安全性。當事務的衝突比較多時,多線程並不能有效提升吞吐量,反而會因爲太多的鎖操作導致延遲增加,這個現象在高併發場景中尤其明顯。

JuiceFS 採用了一種不同的方法,即類似於 Redis 的無鎖模式。在這種模式下,所有核心數據結構的相關操作都在單個線程中執行。這種單線程方法不僅保證了每個操作的原子性(避免了操作被其他線程打斷的問題),還減少了線程間的上下文切換和資源競爭,從而提高了系統的整體效率。同時,它大大降低了系統複雜度,提升了穩定性和可維護性。得益於全內存的元數據存儲模式,請求都可以被非常高效地處理,CPU 不容易成爲瓶頸。

2.3 多分區水平擴展

單個元數據服務進程可用的內存是有上限的,而且單進程內存過高時也會逐漸出現效率下降的情況。JuiceFS 會通過聚合分佈在多個節點的虛擬分區中的元數據來實現水平擴展,以支撐更大的數據規模和更高的性能需求。

具體來說,每個分區各自負責文件系統中的一部分子樹,由客戶端來協調和管理多個分區中的文件,把它們組裝成單一的命名空間;同時這些文件能夠在多個分區間根據需要進行動態遷移。例如,一個管理超過 200 億文件的集羣,就使用了 10 個 512 GB 內存的元數據節點,部署了 80 個分區。一般情況下,我們建議將單個元數據服務進程的內存控制在 40 GiB 以內,並通過多分區水平擴展的方式來管理更多的文件。

文件系統的訪問通常有很強的局部性,換言之文件一般在同一個目錄或者相鄰的目錄間移動。因此 JuiceFS 實現的動態子樹拆分方式中會盡量維持較大的子樹,使得絕大部分元數據操作都發生在單一的分區中。這樣的好處是能大幅減少分佈式事務的使用,使得集羣在大規模擴展後仍然能保持跟單分區接近的元數據響應延遲。

三、內存優化

隨着數據量的增加,元數據服務需要的內存也隨之增加,這不僅會影響系統的性能,同時也會讓硬件成本快速上升。因此,在海量文件場景中,減少元數據的內存佔用對於維持系統穩定和控制成本是非常關鍵的。

爲了實現這一目標,我們在內存分配和使用上進行了廣泛的探索和嘗試。接下來,我們將分享一些經過多年迭代和優化,被證明爲有效的措施。

3.1 使用內存池來減少分配

這是在 Go 程序中非常常見的優化手段,主要是藉助標準庫中的 sync.Pool 結構。其基本原理是,用完的數據結構不丟棄,而是將它放回到一個池中。當再次需要使用相同類型的數據結構時,可以直接從池中獲取,而不需要申請。這種方法可以有效減少內存申請和釋放的次數,從而提高性能。這裏有個簡單的例子:

pool := sync.Pool{
   New: func() interface{} {
       buf := make([]byte, 1<<17)
       return &buf
 },
}
buf := pool.Get().(*[]byte)
// do some work
pool.Put(buf)

在初始化時,通常需要定義一個 New 函數來創建新的結構體。使用時,首先通過 Get 方法獲取對象,並轉換爲相應類型;使用完畢後,通過 Put 方法將結構體放回池中。值得注意的是,放回去後這個結構體僅有弱引用,也就是說它隨時可能被垃圾回收機制(GC)回收。

示例中的結構體是一段預定長度的內存切片,因此我們得到的其實是一個簡單的內存池。這個池結合下一小節的精細管理手段,就能實現程序對內存的高效使用。

3.2 自主管理小塊內存分配

在 JuiceFS 元數據引擎中,最關鍵部分就是要維護目錄樹結構,大致如下:

目錄樹結構示意圖

其中:

可見這些結構體都非常小,但是數量會非常龐大。Go 的 GC 不支持分代,也就是說如果將它們都交由 GC 來管理,就需要在每次進行內存回收時都將它們掃描一遍,並且標記所有被引用的對象。這個過程會非常慢,不僅使得內存無法及時回收,還可能消耗過多的 CPU 資源。

爲了能夠高效地管理這些海量小對象,我們使用 unsafe 指針(包括 uintptr)來繞過 Go 的 GC 進行手動內存分配和管理。實現時,元數據引擎每次向系統申請大塊的內存,然後根據對象的大小拆分成相同尺寸的小塊來使用。在保存指向這些手動分配的內存塊的指針時,儘量使用 unsafe.Pointer 甚至 uintptr 類型,這樣 GC 就不需要掃描這些指針,也就大幅減輕了其在執行內存回收時的工作量。

具體而言,我們設計了一個名爲 Arena 的元數據內存池,其中包含有多個不同的桶,用來隔離大小差異較大的結構體。每個桶存放的是較大的內存塊,例如 32 KiB 或 128 KiB 。需要使用元數據結構體時,通過 Arena 接口找到相應的桶,並從其中的內存塊劃分一小段來使用;使用完畢後,同樣通知 Arena 將其放回內存池。它的設計示意圖如下:

JuiceFS 元數據內存池 Arena 示意圖

具體的管理細節較爲複雜,感興趣的讀者可以瞭解更多關於 tcmalloc 和 jemalloc 等內存分配器的實現原理,基本思路與它們類似。以下介紹 Arena 中的關鍵代碼:

// 內存塊常駐
var slabs = make(map[uintptr][]byte)
p := pagePool.Get().(*[]byte) // 128 KiB
ptr := unsafe.Pointer(&(*p)[0])
slabs[uintptr(ptr)] = *p

其中 slabs 是一個全局的 map,它記錄了 Arena 裏所有被申請的內存塊,這樣 GC 就能知道這些大內存塊正在被使用。下面一段是結構體創建的代碼:

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 函數用於申請指定大小的內存,並返回一個 unsafe.Pointer 指針。創建一個 node 時,我們先確定其類型所需的大小,然後將申請到的指針轉換爲所需結構體類型,即可正常使用。必要時,我們會將這個 unsafe.Pointer 轉成 uintptr 保存在 nodeMap 中。這是一個非常大的映射(map),用來根據 node ID 快速找到對應的結構體。

在這種設計下,從 GC 角度看,會發現程序申請了許多 128 KiB 的內存塊,且一直在使用,但裏面具體的內容顯然不需要它來操心。另外,雖然 nodeMap 中含有數億甚至數十億元素,但其鍵值均爲數值類型,因此 GC 並不需要掃描每一個鍵值對。這種設計對 GC 非常友好,即使上百 GiB 的內存也能輕鬆完成掃描。

3.3 壓縮空閒目錄

在 2.3 節中提到過,文件系統的訪問具有很強的局部性,應用程序在一段時間內通常只會頻繁訪問幾個特定的目錄,而其他部分則相對閒置,全局隨機訪問的情況較少。基於此,我們可以將不活躍的目錄元數據進行壓縮,從而達到減少內存佔用的效果。如下圖所示:

JuiceFS 目錄序列化和壓縮示意圖

當目錄 dir 處於空閒狀態時,可以將它和它下面所有一級子項的元數據按預定格式緊湊地序列化,得到一段連續的內存緩衝區;然後再將這段緩衝區進行壓縮,變成一段更小的內存。

**通常情況下,將多個結構體一起序列化後能節省近一半的內存,而壓縮處理則能進一步節省大約一半到三分之二的內存。**因此,這種方法大幅降低了單個文件元數據的平均佔用。然而,序列化和壓縮過程會佔用一定的 CPU 資源,並可能增加請求的延遲。爲了平衡效率,我們在程序內部監控 CPU 狀態,僅在 CPU 有閒餘時觸發此流程,並將每次處理的文件數限制在 1000 以內,以保證其快速完成。

3.4 爲小文件設計更緊湊的格式

爲了支持高效的隨機讀寫,JuiceFS 中普通文件的元數據會分爲三個層級來進行索引:fnode 、chunks 和 slice,其中 chunks 是一個數組,slice 則放在一個哈希表中。初始設計時,每個文件都需要分配這 3 塊內存,但後來我們發現這種方式對絕大部分小文件而言並不夠高效。因爲小文件通常只有一個 chunk,這個 chunk 也只有一個 slice,而且 slice 的長度跟文件的長度是一致的。

因此,我們爲這類小文件引入了一個更緊湊高效的內存格式。在新的格式中,我們只需要記錄 slice 的 ID,再從文件的長度得到 slice 的長度,無須存儲 slice 本身。同時,我們調整了 fnode 的結構。原來 fnode 中保存了指向 chunks 數組的指針,而它指向的數組中只有一個 8 字節的 slice ID,現在我們直接將這個 ID 保存在了指針變量的位置。這種用法類似 C 語言裏的 union 結構,即在同一內存位置根據實際情況存儲不同類型的數據。經此調整後,每個小文件就只有一個 fnode 對象,而無需其他 chunk 列表和 slices 信息。具體示意圖如下:

小文件優化示意圖

優化後的格式可以爲每個小文件節省約 40 字節內存。同時,這也減少了內存的分配和索引操作,訪問起來會更快

3.5 整體優化效果

下圖總結了到目前爲止的優化成果:

內存優化效果示意圖

在圖中,文件的平均元數據大小呈現顯著下降。最初,每個文件的元數據平均佔用近 600 字節。通過自行管理內存,這一數字降至大約 300 字節,並大幅縮減了 GC 的開銷。隨後,對空閒目錄進行序列化處理,進一步將其減少到約 150 字節。最後,通過內存壓縮技術,平均大小降低到了大約 50 字節。當然,元數據服務在運行時還需要負責狀態監控、會話管理等任務,並應對網絡傳輸等各種臨時消耗,實際的內存佔用量可能達到這個核心值的兩倍,因此我們一般按每個文件 100 字節來預估所需的硬件資源。

常見分佈式文件系統的單文件內存佔用情況如下:

可以看到,JuiceFS 在元數據內存佔用方面的表現非常突出,僅爲 HDFS NameNode 的 27%,CephFS MDS 的 3.7 %。它不僅意味着更高的內存效率,也意味着在相同的硬件資源下,JuiceFS 能夠處理更多的文件和更復雜的操作,從而提高整體系統性能。

四、小結

文件系統的核心之一在於其元數據的管理,而當構建一款能夠處理百億文件數規模的分佈式文件系統時,這一設計任務變得尤爲複雜。本文介紹了 JuiceFS 在設計元數據引擎時所做的關鍵決策,並詳細介紹了內存池、自主管理小塊內存、壓縮空閒目錄以及優化小文件格式這 4 個內存優化手段。這些措施是我們在不斷探索、嘗試和迭代的過程中得出的成果,最終使 JuiceFS 的文件元數據平均內存佔用下降至 100 字節,令其更能夠應對更多更極限的使用場景。

關於作者

負責豆瓣數據平臺的功能開發和維護

直播回顧: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 核心系統工程師

引用鏈接

[1] 網絡文章: 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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/uUvysfMq4L6XgbwBwTwbnw