40+倍提升,詳解 JuiceFS 後設資料備份恢復效能優化之路

JuiceFS發表於2022-07-15

JuiceFS 支援多種後設資料儲存引擎,且各引擎內部的資料管理格式各有不同。為了便於管理,JuiceFS 自 0.15.2 版本提供了 dump 命令允許將所有後設資料以統一格式寫入到 JSON 檔案進行備份。同時,JuiceFS 也提供了 load 命令,允許將備份恢復或遷移到任意後設資料儲存引擎。命令的詳細資訊可以參考這裡。基本用法:

$ juicefs dump redis://192.168.1.6:6379/1 meta.json
$ juicefs load redis://192.168.1.6:6379/2 meta.json

該功能自 0.15.2 版本釋出後到現在 v1.0 RC2 經歷了 3 次比較大的優化,效能得到了幾十倍的提升, 我們主要在以下三個方向做了優化:

  1. 減小資料處理的的粒度:通過將大物件拆分為小物件處理,可以大幅減少記憶體的佔用。另外拆分還有利於做細粒度的併發處理。
  2. 減少 io 的操作次數:使用 pipline 來批量傳送請求減少網路 io 的耗時。
  3. 分析系統中的耗時瓶頸:序列改為並行,提高 cpu 利用率。

這些優化思路比較典型,對於類似網路請求比較多的場景具有一定的通用性,所以我們希望分享下我們的具體實踐,希望能給大家一定的啟發。

後設資料格式

在分享 dump load 功能之前,我們先看下檔案系統長什麼樣,如下圖所示,檔案系統是一個樹形結構,頂層根目錄,根目錄下有子目錄或者檔案,子目錄下面又有子目錄或者檔案。所以如果想要知道檔案系統裡面的所有檔案和資料夾,只需要遍歷這顆樹就行了。

瞭解了檔案系統的特點後,我們再看 JuiceFS 的後設資料儲存的特點,JuiceFS 後設資料的儲存主要是幾張不同的 hash 表,每個 hash 表的 key 都是單個檔案的 inode ,而inode 資訊可以通過檔案樹的遍歷得到。所以只需要遍歷檔案樹拿到所有的inode,再根據 inode 為索引就可以拿到所有的後設資料了。另外為了閱讀性更好,並且保留原本的檔案系統的樹形結構,我們將匯出的格式定為了 json。
將上面示例檔案系統 dump 出來的 json 檔案如下所示,其中 hardLink 為 file 的硬連結

json 內容:

Dump優化流程

dump 如何實現?

首先從後設資料的格式來看,所有的後設資料都是以inode為部分變數的為 key,也就是說我們知道了 inode 的具體值就可以通過 redis 獲取到它的所有後設資料資訊。所以根據檔案系統的特點,我們可以構建一棵FSTree,從根目錄以深度優先遍歷掃描填充這顆樹,先掃描根目錄(inode 為 1)下的所有entry,依次遍歷,根據其 inode 獲取其後設資料資訊,如果發現其是目錄,就遞迴掃描,否則就分別請求 redis 拿其各個維度的後設資料,拼裝成一個 entry 的結構,作為父目錄的 entry list 中的一員。當遞迴遍歷完成後,這棵FSTree就已經建立完畢。我們再加上setting 等相對靜態的後設資料作為一個物件,然後將其整個序列化為 json 字串。最後將 json 字串寫入到檔案中,整個 dump 就算完成了。

效能

我們以包含110 萬檔案後設資料的 redis 為例進行測試,測試結果為 dump 過程耗時 7 分 47 秒,記憶體佔用為 3.18G。(為了保證測試結果的可比性,本文的所有測試都是使用同一份後設資料)

下圖為執行中的記憶體佔用變化。記憶體佔用剛開始緩慢上升,此時是在將深度優先遍歷的過程中每掃描到一個 entry 就會將其存入記憶體中,所以記憶體緩慢增加。當構造完整個 FSTree 物件後開始進行 json 序列化,此時是 FSTree 物件大約 750M,將一個物件序列化為 json 字串,過程大約需要 2 倍的物件大小,最後的 json 字串大約等於一倍原始物件的大小,所以記憶體大約增加了 3 倍的 FSTree 物件的大小,急速攀升到 3.18G。最終記憶體佔用峰值大約需要 4 倍的 FSTree 的大小。

上面的實現會什麼問題?

根據上面的思路我們可以看出我們的核心是為了構建一個 FSTree 物件,因為 json 的序列化方法可以直接將一個物件序列化為j son 格式的字串。所以一旦我們構建出來了 FSTree 物件,剩餘的事情就可以交給 json 包來做了,非常方便。可是對於一個檔案系統來說,檔案可能非常多,非常大,帶來的是後設資料非常大,而 FSTree 儲存的就是整個整個系統的entry 的後設資料資訊,所以dump 的程式佔用記憶體就會比較高,另外在將物件序列化為 json 字串後,這個 json 字串也會非常大,其實相當於 dump 程式需要至少 2 倍的後設資料的大小。如果 dump 程式所在的客戶端可能並沒有這麼大的記憶體可以使用,那麼 dump 程式可能會被作業系統因為 OOM 殺掉。

如何優化記憶體佔用過高?

FSTree 由 很多個 Entry 組成,非常大,我們不能對其整個序列化,怎麼辦,我們可以減小資料處理的的粒度,將大物件拆分為小物件處理,分別對組成 FSTree 的 entry 進行序列化,將得到的 json 字串寫入到 json的檔案末尾。具體做法就是深度優先遞迴掃描 FSTree,然後如果是個 entry,就將其序列化並且寫入到 json 檔案內,如果是個資料夾,那麼就遞迴進去。這樣得到的 json 檔案中的 FSTree 仍舊是與 FSTree 物件保持一一對應的,entry 的樹形結構與順序並沒有被破壞。這樣我們 dump 記憶體中就只保留了一倍後設資料大小的物件——FSTree,相比最開始節省了一半的記憶體,效果很明顯。那剩下的這一倍記憶體可以省掉嗎?答案是可以的,我們回想下 FSTree 是如何被構建的,是通過深度優先遞迴掃描根目錄,所以 entry 是按照深度優先遞迴遍歷的順序被建立,深度優先遞迴遍歷的順序不也是我們序列化 FSTree 中每個 entry 的順序嗎?既然這兩者順序一致,那我們就可以在剛構建出 entry 的時候就將其序列化寫入到 json 檔案,這樣遍歷完整個檔案系統的時候,所有的 entry 也被序列化完了,也就沒有必要構建儲存整棵 FSTree 了,最終優化的結果就是 FSTree 物件我們也不用構建了,每個 entry 只會被訪問一遍,序列化後就扔掉它。這樣佔用的記憶體就是更少了。

效能

經過記憶體優化後的測試結果為 dump 過程耗時 8 分鐘,記憶體佔用為 62M。耗時相當,記憶體由 3.18G降低到62M,記憶體優化效果高達 5100%!
下圖為記憶體變化佔用情況

怎麼優化dump 耗時太長?

從上面的測試結果來看,一百萬 dump 大約需要 8 分鐘,如果 1 億檔案就是 13 個小時之久,可見如果資料量太大,耗時就非常長。這麼長的時間,生產上是不能被接受的。記憶體不夠尚且可以通過鈔能力解決,但是太耗時的話,鈔能力也效果不大,所以根治還是要從內部程式來優化。我們先分析一下現在的耗費最多的環節是什麼。

一般耗時分兩個方面,大量的計算操作,大量的 io 操作,很明顯我們屬於大量的網路 IO 操作,dump 程式每掃描到一個 entry就需要請求其後設資料資訊,每次請求耗時由 RTT(Round Trip Time)+命令計算時間組成,redis 基於記憶體操作計算時間是非常快的,所以主要耗時是 RTT 上。N 個 entry 就是 N 個 RTT,耗時非常多。

如何減少RTT 的次數那?答案是使用 redis 的 pipline 技術,pipline 的基本原理就是將N個命令一次性傳送過去,redis計算完 N 個命令後將結果按照順序打包一次性返回給客戶端,所以 N 個命令的耗時為 1 個RTT 加 N 條命令計算時間。從實踐來看,pipline 的優化是非常可觀的。順著這個思路,我們可以使用 pipline 將存在 redis 中的後設資料全部拿到記憶體中存起來,類似在記憶體中做個 redis的快照,程式碼上實現就是將其放入map 裡面,原邏輯需要請求 redis 的現在直接從map中拿到。這樣即用了 pipline 批量拉取資料減少了 RTT,原本的邏輯又不需要改變太多,只需要把 redis 請求操作改為讀 map 即可。

效能

經過“快照”方式優化後的 dump 效能測試結果:耗時 35 秒,記憶體佔用 700M,耗時從 8 分鐘減少到 35 秒,提升高達 1270%,但是記憶體佔用卻因為我們在記憶體中構造了後設資料快取而增加到了 700M,從上面的測試可知這大約是一倍的後設資料大小,這也符合預期。

低記憶體與低耗時能否兼得?

在記憶體中做 redis 的快照版本雖然速度快了很多,但是我們相當於把 redis 的資料全部放到了記憶體中,這樣記憶體佔用又回到到了一倍的後設資料大小。當後設資料太大的時候,dump 佔用記憶體非常高。所以針對耗時的優化是犧牲了記憶體為代價的。一倍的記憶體佔用與耗時長對於生產都是不可接受的,所以我們需要一個魚和熊掌兼得的優化方法。我們回想之前的兩次優化,針對記憶體佔用高使用流式寫入解決,針對耗時長通過使用 redis pipline 減少 RTT 次數解決。這兩個優化手段都是必須的,關鍵在於如何將兩者結合起來一起使用。

我們可以在針對優化記憶體佔用過高做的流式寫入這版上思考如何加上 pipline。流式寫入版本其實可以看著是一個流水線處理,源端負責按照順序構造 entry,接收端負責按照順序序列化 entry,entry 的順序就是 FSTree 的深度優先遍歷的順序。要使用 pipline,就必須走批量處理,那麼我們可以邏輯上將 entry 按照順序劃分為多個批次,每個批次長度 100,將流水線的處理邏輯單元變成一個批次,這樣流程變為:

  1. 當源端處理完 1個批次後通知接收端開始序列化這個批次
  2. 接收端序列化完這 1 個批次後再通知源端構造下一個批次
  3. 以此反覆到結束

每一個批次都通過 pipline 來加速獲取結果,這樣就做到了pipline 與流式寫入共存了。
關於記憶體的優化已經結束了,那關於耗時還能再優化嗎?我們分析現在的流水線的執行情況,當源端傳送 pipline 請求後設資料時,此時接收端在做什麼?在無事可做,因為沒有資料可以序列化,那麼當接收端在序列化的時候源端在做什麼,也是無事可做。所以其實流水線是走走停停的,這樣的是序列計算。如果將這兩者並行,提高 cpu 利用率,速度就可以進一步提升。接下來我們思考怎麼才能讓源端與序列化端並行?同一個批次資料產生與處理肯定是無法並行的,能並行的只能是未請求回來後設資料的的批次與待序列化的批次。也就是說源端不用等等序列化端是否處理完畢了,源端只管開足馬力拿資料就好了,拿到的資料按照順序放入到流水線上,序列化端按照順序序列化,如果發現某個批次還沒拿到,就等源端告訴自己這個批次ready 了再處理。同時考慮到構造批次的速度慢於序列化批次的時間,所以我們還可以給源端加上併發。源端同時序列化多個批次來減少序列化端的等待時間。

我們可以看著下圖,模擬一下流程,假設我們當前源端併發度為 2,那麼首先 1 號協程 2 號協程會同時分別構建批次 1,批次 2,而序列化端與在等待批次 1 是否構造完畢,一旦 1 號協程構造完畢批次 1 就會通知序列化端端開始依次序列化批次 1。當批次 1序列化完畢時,序列化端會通知 1 號協程構造批次 3(因為批次 2,批次 4 是該協程 2 處理的,每個協程按照一定規則分配批次序列化端才可以按照規則反過來推算出該通知哪個協程開始構造下一個批次),通知完 1 協程後就會開始序列化批次 2(先檢查批次 2 是否 ready,如果沒 ready 就等協程 2 通知ready,一般來講此時批次 2 已經 ready 了),序列化完批次 2 就通知協程 2 開始構造批次 4以此類推。這樣就做到了序列化端在序列化 entry 時源端在並行的處理 entry 以便跟上序列化的速度。

上面的邏輯步驟在樹形的檔案系統上執行的真實的過程如下圖所示

效能

經過“魚和熊掌”兼得的優化方式後測試效能,耗時為 19 秒,記憶體佔用 75M,都達到了各自優化時的最佳效果。真正做到了“兩個都要”。

Load 優化流程

load如何做

與 dump 相比,load 邏輯相對簡單,最直接的方法,我們將 json 檔案內容全部讀入記憶體,然後反序列化到 FSTree 的物件上,深度優先遍歷 FSTree 樹,然後把每個 entry 的各個維度的後設資料分別插入到 redis 中。但是如果這麼做就會存在一個問題,以上面的示例 json 檔案內容的檔案樹為例,在 dump 這個檔案系統的時候存在某種情況,此時 file1 已經掃描到,redis 返回 file1的 nlink 為 2(因為 hardLink 硬連結到了 file1),此時使用者刪除了 hardLink ,file1 的 nlink 在 redis 中被修改為了 1,但是因為其在 dump 中已經被掃描過了,所以最終 dump 出來的 json 檔案中 nlink 仍舊為 2,導致 nlink 錯誤,nlink 對於檔案系統來說非常重要,其值的錯誤會導致刪不掉或者丟資料等問題,所以這種會導致 nlink 錯誤的方式不太行。

為了解決這個問題,我們需要在 load 的時候重新計算 nlink 值,這就需要我們再 load 前記錄下所有的inode 資訊,所以我們在記憶體中構建了一個 map,key 為 inode,value 為 entry 的所有後設資料,在遍歷 entry 樹的時候將所有掃描到的檔案型別的 entry 放入 map 中而不是直接插入 redis,每次放入 map 前判斷這個 inode 是否已經存在,如果存在意味著是這是一個硬連結,需要將這個 inode 的 nlink++。同樣的情況也可能出現在子目錄上,所以需要在遍歷到子目錄的時候將父目錄的 nlink++。遍歷完 entry 後nlink 也就全部重新計算完畢了。此時遍歷 entry map,將所有的 entry 的後設資料插入到 redis 中即可。當然為了加快插入速度,我們需要使用 pipline 的方式插入。

效能

按照上面的思路的程式碼測試結果如下,耗時 2 分 15 秒,記憶體佔用 2.18G。

優化耗時

並不是用了 pipline 後,耗時就減少到了極致,我們仍舊可以通過其他方法進一步減少時間。眾所周知 redis 是非常快的,即使是使用了 pipline,命令的處理速度仍然遠小於 RTT 時間,而 load 程式構造 pipline 也是一個記憶體的操作,構建 pipline 的時間也遠小於 RTT 時間。我們可以通過一個舉一個極端的例子分析時間到底浪費到了哪裡:假設如果構建 pipline與 redis 處理 pipline 的時間都是 10 ms,而 RTT 時間是 80ms,這樣就意味著 load 程式每花費 10ms 構建一個 pipline 給 redis 都要等待 90ms 才能構建下一個 pipline,所以其 cpu 利用率為10%,redis 也同樣如此,可見雙方的 cpu 利用率之低。所以我們可以通過併發 pipline 插入,提高雙方 cpu 利用率來節省時間。

效能

經過新增併發優化後的測試結果,耗時 1 分鐘,記憶體佔用 2.17G,記憶體基本持平,耗時優化效果 125%

優化記憶體

經過上面的測試應該明白了記憶體的優化主要在序列化上下功夫,首先讀取整個 json 檔案反序列化到結構體上,這個就動作就需要大約 2 倍後設資料的記憶體,一倍的 json 字串,一倍的結構體。可見整個讀入的代價太高了,所以我們要以流式讀取的方式來處理,每次讀取並反序列一個最小的 json 物件,這樣記憶體佔用就非常低了。load 的另一個問題是我們把所有的 entry 存到了記憶體中來重新計算 nlink,這個也是導致記憶體佔用非常高的原因之一。解決方法也非常簡單,nlink 固然是需要重新計算的,不過把 entry 的所有屬性都記錄下其實是沒有必要的,我們回想重新計算的邏輯,每次將檔案型別的 entry 放入 map 前根據 inode 判斷 entry 是否存在,如果存在就意味著這是一個硬連結,將這個 inode 的 nlink++。所以將 map 的 value 型別改為 int64 即可,每次放入時 value 值+1,這樣比較大的 map 也就不存在了,記憶體佔用進一步減少。

效能

經過了流式讀取優化的測試結果如下,耗時 40s,記憶體佔用 518M。記憶體優化效果 330%

總結

當前 1.0-rc2 版本與最初版優化效果

  • Dump 耗時 7 分 47 秒,記憶體佔用為 3.18G ,優化為耗時 19 秒,記憶體佔用 75M,優化效果分別為 2300%和 4200%
  • Load 耗時 2 分 15 秒,記憶體佔用 2.18G, 優化後為耗時 40 秒,記憶體佔用 518M。優化效果分別為 230%和 330%

可以看到優化效果是非常明顯的。

以上就是我們的優化的思路與結果了,如果遇到類似的場景,希望這些實踐經驗也可以幫助大家擴充優化的思路,提升系統的效能!

如有幫助的話歡迎關注我們專案 Juicedata/JuiceFS 喲! (0ᴗ0✿)

相關文章