JuiceFS 支持多種元數(shù)據(jù)存儲引擎,且各引擎內(nèi)部的數(shù)據(jù)管理格式各有不同。為了便于管理,JuiceFS 自 0.15.2 版本提供了 dump 命令允許將所有元數(shù)據(jù)以統(tǒng)一格式寫入到 JSON 文件進(jìn)行備份。同時(shí),JuiceFS 也提供了 load 命令,允許將備份恢復(fù)或遷移到任意元數(shù)據(jù)存儲引擎。命令的詳細(xì)信息可以參考這里?;居梅ǎ?/p>
$ 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 版本發(fā)布后到現(xiàn)在 v1.0 RC2 經(jīng)歷了 3 次比較大的優(yōu)化,性能得到了幾十倍的提升, 我們主要在以下三個方向做了優(yōu)化:
- 減小數(shù)據(jù)處理的的粒度:通過將大對象拆分為小對象處理,可以大幅減少內(nèi)存的占用。另外拆分還有利于做細(xì)粒度的并發(fā)處理。
- 減少 io 的操作次數(shù):使用 pipline 來批量發(fā)送請求減少網(wǎng)絡(luò) io 的耗時(shí)。
- 分析系統(tǒng)中的耗時(shí)瓶頸:串行改為并行,提高 cpu 利用率。
這些優(yōu)化思路比較典型,對于類似網(wǎng)絡(luò)請求比較多的場景具有一定的通用性,所以我們希望分享下我們的具體實(shí)踐,希望能給大家一定的啟發(fā)。
元數(shù)據(jù)格式
在分享 dump load 功能之前,我們先看下文件系統(tǒng)長什么樣,如下圖所示,文件系統(tǒng)是一個樹形結(jié)構(gòu),頂層根目錄,根目錄下有子目錄或者文件,子目錄下面又有子目錄或者文件。所以如果想要知道文件系統(tǒng)里面的所有文件和文件夾,只需要遍歷這顆樹就行了。

了解了文件系統(tǒng)的特點(diǎn)后,我們再看 JuiceFS 的元數(shù)據(jù)存儲的特點(diǎn),JuiceFS 元數(shù)據(jù)的存儲主要是幾張不同的 hash 表,每個 hash 表的 key 都是單個文件的 inode ,而inode 信息可以通過文件樹的遍歷得到。所以只需要遍歷文件樹拿到所有的inode,再根據(jù) inode 為索引就可以拿到所有的元數(shù)據(jù)了。另外為了閱讀性更好,并且保留原本的文件系統(tǒng)的樹形結(jié)構(gòu),我們將導(dǎo)出的格式定為了 json。
將上面示例文件系統(tǒng) dump 出來的 json 文件如下所示,其中 hardLink 為 file 的硬鏈接
json 內(nèi)容:

Dump優(yōu)化流程
dump 如何實(shí)現(xiàn)?
首先從元數(shù)據(jù)的格式來看,所有的元數(shù)據(jù)都是以inode為部分變量的為 key,也就是說我們知道了 inode 的具體值就可以通過 redis 獲取到它的所有元數(shù)據(jù)信息。所以根據(jù)文件系統(tǒng)的特點(diǎn),我們可以構(gòu)建一棵FSTree,從根目錄以深度優(yōu)先遍歷掃描填充這顆樹,先掃描根目錄(inode 為 1)下的所有entry,依次遍歷,根據(jù)其 inode 獲取其元數(shù)據(jù)信息,如果發(fā)現(xiàn)其是目錄,就遞歸掃描,否則就分別請求 redis 拿其各個維度的元數(shù)據(jù),拼裝成一個 entry 的結(jié)構(gòu),作為父目錄的 entry list 中的一員。當(dāng)遞歸遍歷完成后,這棵FSTree就已經(jīng)建立完畢。我們再加上setting 等相對靜態(tài)的元數(shù)據(jù)作為一個對象,然后將其整個序列化為 json 字符串。最后將 json 字符串寫入到文件中,整個 dump 就算完成了。
性能
我們以包含110 萬文件元數(shù)據(jù)的 redis 為例進(jìn)行測試,測試結(jié)果為 dump 過程耗時(shí) 7 分 47 秒,內(nèi)存占用為 3.18G。(為了保證測試結(jié)果的可比性,本文的所有測試都是使用同一份元數(shù)據(jù))
下圖為執(zhí)行中的內(nèi)存占用變化。內(nèi)存占用剛開始緩慢上升,此時(shí)是在將深度優(yōu)先遍歷的過程中每掃描到一個 entry 就會將其存入內(nèi)存中,所以內(nèi)存緩慢增加。當(dāng)構(gòu)造完整個 FSTree 對象后開始進(jìn)行 json 序列化,此時(shí)是 FSTree 對象大約 750M,將一個對象序列化為 json 字符串,過程大約需要 2 倍的對象大小,最后的 json 字符串大約等于一倍原始對象的大小,所以內(nèi)存大約增加了 3 倍的 FSTree 對象的大小,急速攀升到 3.18G。最終內(nèi)存占用峰值大約需要 4 倍的 FSTree 的大小。

上面的實(shí)現(xiàn)會什么問題?
根據(jù)上面的思路我們可以看出我們的核心是為了構(gòu)建一個 FSTree 對象,因?yàn)?json 的序列化方法可以直接將一個對象序列化為j son 格式的字符串。所以一旦我們構(gòu)建出來了 FSTree 對象,剩余的事情就可以交給 json 包來做了,非常方便??墒菍τ谝粋€文件系統(tǒng)來說,文件可能非常多,非常大,帶來的是元數(shù)據(jù)非常大,而 FSTree 保存的就是整個整個系統(tǒng)的entry 的元數(shù)據(jù)信息,所以dump 的進(jìn)程占用內(nèi)存就會比較高,另外在將對象序列化為 json 字符串后,這個 json 字符串也會非常大,其實(shí)相當(dāng)于 dump 進(jìn)程需要至少 2 倍的元數(shù)據(jù)的大小。如果 dump 進(jìn)程所在的客戶端可能并沒有這么大的內(nèi)存可以使用,那么 dump 進(jìn)程可能會被操作系統(tǒng)因?yàn)?OOM 殺掉。
如何優(yōu)化內(nèi)存占用過高?
FSTree 由 很多個 Entry 組成,非常大,我們不能對其整個序列化,怎么辦,我們可以減小數(shù)據(jù)處理的的粒度,將大對象拆分為小對象處理,分別對組成 FSTree 的 entry 進(jìn)行序列化,將得到的 json 字符串寫入到 json的文件末尾。具體做法就是深度優(yōu)先遞歸掃描 FSTree,然后如果是個 entry,就將其序列化并且寫入到 json 文件內(nèi),如果是個文件夾,那么就遞歸進(jìn)去。這樣得到的 json 文件中的 FSTree 仍舊是與 FSTree 對象保持一一對應(yīng)的,entry 的樹形結(jié)構(gòu)與順序并沒有被破壞。這樣我們 dump 內(nèi)存中就只保留了一倍元數(shù)據(jù)大小的對象——FSTree,相比最開始節(jié)省了一半的內(nèi)存,效果很明顯。那剩下的這一倍內(nèi)存可以省掉嗎?答案是可以的,我們回想下 FSTree 是如何被構(gòu)建的,是通過深度優(yōu)先遞歸掃描根目錄,所以 entry 是按照深度優(yōu)先遞歸遍歷的順序被創(chuàng)建,深度優(yōu)先遞歸遍歷的順序不也是我們序列化 FSTree 中每個 entry 的順序嗎?既然這兩者順序一致,那我們就可以在剛構(gòu)建出 entry 的時(shí)候就將其序列化寫入到 json 文件,這樣遍歷完整個文件系統(tǒng)的時(shí)候,所有的 entry 也被序列化完了,也就沒有必要構(gòu)建保存整棵 FSTree 了,最終優(yōu)化的結(jié)果就是 FSTree 對象我們也不用構(gòu)建了,每個 entry 只會被訪問一遍,序列化后就扔掉它。這樣占用的內(nèi)存就是更少了。
性能
經(jīng)過內(nèi)存優(yōu)化后的測試結(jié)果為 dump 過程耗時(shí) 8 分鐘,內(nèi)存占用為 62M。耗時(shí)相當(dāng),內(nèi)存由 3.18G降低到62M,內(nèi)存優(yōu)化效果高達(dá) 5100%!
下圖為內(nèi)存變化占用情況

怎么優(yōu)化dump 耗時(shí)太長?
從上面的測試結(jié)果來看,一百萬 dump 大約需要 8 分鐘,如果 1 億文件就是 13 個小時(shí)之久,可見如果數(shù)據(jù)量太大,耗時(shí)就非常長。這么長的時(shí)間,生產(chǎn)上是不能被接受的。內(nèi)存不夠尚且可以通過鈔能力解決,但是太耗時(shí)的話,鈔能力也效果不大,所以根治還是要從內(nèi)部程序來優(yōu)化。我們先分析一下現(xiàn)在的耗費(fèi)最多的環(huán)節(jié)是什么。
一般耗時(shí)分兩個方面,大量的計(jì)算操作,大量的 io 操作,很明顯我們屬于大量的網(wǎng)絡(luò) IO 操作,dump 進(jìn)程每掃描到一個 entry就需要請求其元數(shù)據(jù)信息,每次請求耗時(shí)由 RTT(Round Trip Time)+命令計(jì)算時(shí)間組成,redis 基于內(nèi)存操作計(jì)算時(shí)間是非常快的,所以主要耗時(shí)是 RTT 上。N 個 entry 就是 N 個 RTT,耗時(shí)非常多。
如何減少RTT 的次數(shù)那?答案是使用 redis 的 pipline 技術(shù),pipline 的基本原理就是將N個命令一次性發(fā)送過去,redis計(jì)算完 N 個命令后將結(jié)果按照順序打包一次性返回給客戶端,所以 N 個命令的耗時(shí)為 1 個RTT 加 N 條命令計(jì)算時(shí)間。從實(shí)踐來看,pipline 的優(yōu)化是非??捎^的。順著這個思路,我們可以使用 pipline 將存在 redis 中的元數(shù)據(jù)全部拿到內(nèi)存中存起來,類似在內(nèi)存中做個 redis的快照,代碼上實(shí)現(xiàn)就是將其放入map 里面,原邏輯需要請求 redis 的現(xiàn)在直接從map中拿到。這樣即用了 pipline 批量拉取數(shù)據(jù)減少了 RTT,原本的邏輯又不需要改變太多,只需要把 redis 請求操作改為讀 map 即可。

性能
經(jīng)過“快照”方式優(yōu)化后的 dump 性能測試結(jié)果:耗時(shí) 35 秒,內(nèi)存占用 700M,耗時(shí)從 8 分鐘減少到 35 秒,提升高達(dá) 1270%,但是內(nèi)存占用卻因?yàn)槲覀冊趦?nèi)存中構(gòu)造了元數(shù)據(jù)緩存而增加到了 700M,從上面的測試可知這大約是一倍的元數(shù)據(jù)大小,這也符合預(yù)期。

低內(nèi)存與低耗時(shí)能否兼得?
在內(nèi)存中做 redis 的快照版本雖然速度快了很多,但是我們相當(dāng)于把 redis 的數(shù)據(jù)全部放到了內(nèi)存中,這樣內(nèi)存占用又回到到了一倍的元數(shù)據(jù)大小。當(dāng)元數(shù)據(jù)太大的時(shí)候,dump 占用內(nèi)存非常高。所以針對耗時(shí)的優(yōu)化是犧牲了內(nèi)存為代價(jià)的。一倍的內(nèi)存占用與耗時(shí)長對于生產(chǎn)都是不可接受的,所以我們需要一個魚和熊掌兼得的優(yōu)化方法。我們回想之前的兩次優(yōu)化,針對內(nèi)存占用高使用流式寫入解決,針對耗時(shí)長通過使用 redis pipline 減少 RTT 次數(shù)解決。這兩個優(yōu)化手段都是必須的,關(guān)鍵在于如何將兩者結(jié)合起來一起使用。
我們可以在針對優(yōu)化內(nèi)存占用過高做的流式寫入這版上思考如何加上 pipline。流式寫入版本其實(shí)可以看著是一個流水線處理,源端負(fù)責(zé)按照順序構(gòu)造 entry,接收端負(fù)責(zé)按照順序序列化 entry,entry 的順序就是 FSTree 的深度優(yōu)先遍歷的順序。要使用 pipline,就必須走批量處理,那么我們可以邏輯上將 entry 按照順序劃分為多個批次,每個批次長度 100,將流水線的處理邏輯單元變成一個批次,這樣流程變?yōu)椋?/p>
- 當(dāng)源端處理完 1個批次后通知接收端開始序列化這個批次
- 接收端序列化完這 1 個批次后再通知源端構(gòu)造下一個批次
- 以此反復(fù)到結(jié)束
每一個批次都通過 pipline 來加速獲取結(jié)果,這樣就做到了pipline 與流式寫入共存了。
關(guān)于內(nèi)存的優(yōu)化已經(jīng)結(jié)束了,那關(guān)于耗時(shí)還能再優(yōu)化嗎?我們分析現(xiàn)在的流水線的運(yùn)行情況,當(dāng)源端發(fā)送 pipline 請求元數(shù)據(jù)時(shí),此時(shí)接收端在做什么?在無事可做,因?yàn)闆]有數(shù)據(jù)可以序列化,那么當(dāng)接收端在序列化的時(shí)候源端在做什么,也是無事可做。所以其實(shí)流水線是走走停停的,這樣的是串行計(jì)算。如果將這兩者并行,提高 cpu 利用率,速度就可以進(jìn)一步提升。接下來我們思考怎么才能讓源端與序列化端并行?同一個批次數(shù)據(jù)產(chǎn)生與處理肯定是無法并行的,能并行的只能是未請求回來元數(shù)據(jù)的的批次與待序列化的批次。也就是說源端不用等等序列化端是否處理完畢了,源端只管開足馬力拿數(shù)據(jù)就好了,拿到的數(shù)據(jù)按照順序放入到流水線上,序列化端按照順序序列化,如果發(fā)現(xiàn)某個批次還沒拿到,就等源端告訴自己這個批次ready 了再處理。同時(shí)考慮到構(gòu)造批次的速度慢于序列化批次的時(shí)間,所以我們還可以給源端加上并發(fā)。源端同時(shí)序列化多個批次來減少序列化端的等待時(shí)間。
我們可以看著下圖,模擬一下流程,假設(shè)我們當(dāng)前源端并發(fā)度為 2,那么首先 1 號協(xié)程 2 號協(xié)程會同時(shí)分別構(gòu)建批次 1,批次 2,而序列化端與在等待批次 1 是否構(gòu)造完畢,一旦 1 號協(xié)程構(gòu)造完畢批次 1 就會通知序列化端端開始依次序列化批次 1。當(dāng)批次 1序列化完畢時(shí),序列化端會通知 1 號協(xié)程構(gòu)造批次 3(因?yàn)榕?2,批次 4 是該協(xié)程 2 處理的,每個協(xié)程按照一定規(guī)則分配批次序列化端才可以按照規(guī)則反過來推算出該通知哪個協(xié)程開始構(gòu)造下一個批次),通知完 1 協(xié)程后就會開始序列化批次 2(先檢查批次 2 是否 ready,如果沒 ready 就等協(xié)程 2 通知ready,一般來講此時(shí)批次 2 已經(jīng) ready 了),序列化完批次 2 就通知協(xié)程 2 開始構(gòu)造批次 4以此類推。這樣就做到了序列化端在序列化 entry 時(shí)源端在并行的處理 entry 以便跟上序列化的速度。

上面的邏輯步驟在樹形的文件系統(tǒng)上執(zhí)行的真實(shí)的過程如下圖所示

性能
經(jīng)過“魚和熊掌”兼得的優(yōu)化方式后測試性能,耗時(shí)為 19 秒,內(nèi)存占用 75M,都達(dá)到了各自優(yōu)化時(shí)的最佳效果。真正做到了“兩個都要”。

Load 優(yōu)化流程
load如何做
與 dump 相比,load 邏輯相對簡單,最直接的方法,我們將 json 文件內(nèi)容全部讀入內(nèi)存,然后反序列化到 FSTree 的對象上,深度優(yōu)先遍歷 FSTree 樹,然后把每個 entry 的各個維度的元數(shù)據(jù)分別插入到 redis 中。但是如果這么做就會存在一個問題,以上面的示例 json 文件內(nèi)容的文件樹為例,在 dump 這個文件系統(tǒng)的時(shí)候存在某種情況,此時(shí) file1 已經(jīng)掃描到,redis 返回 file1的 nlink 為 2(因?yàn)?hardLink 硬鏈接到了 file1),此時(shí)用戶刪除了 hardLink ,file1 的 nlink 在 redis 中被修改為了 1,但是因?yàn)槠湓?dump 中已經(jīng)被掃描過了,所以最終 dump 出來的 json 文件中 nlink 仍舊為 2,導(dǎo)致 nlink 錯誤,nlink 對于文件系統(tǒng)來說非常重要,其值的錯誤會導(dǎo)致刪不掉或者丟數(shù)據(jù)等問題,所以這種會導(dǎo)致 nlink 錯誤的方式不太行。
為了解決這個問題,我們需要在 load 的時(shí)候重新計(jì)算 nlink 值,這就需要我們再 load 前記錄下所有的inode 信息,所以我們在內(nèi)存中構(gòu)建了一個 map,key 為 inode,value 為 entry 的所有元數(shù)據(jù),在遍歷 entry 樹的時(shí)候?qū)⑺袙呙璧降奈募愋偷?entry 放入 map 中而不是直接插入 redis,每次放入 map 前判斷這個 inode 是否已經(jīng)存在,如果存在意味著是這是一個硬鏈接,需要將這個 inode 的 nlink++。同樣的情況也可能出現(xiàn)在子目錄上,所以需要在遍歷到子目錄的時(shí)候?qū)⒏改夸浀?nlink++。遍歷完 entry 后nlink 也就全部重新計(jì)算完畢了。此時(shí)遍歷 entry map,將所有的 entry 的元數(shù)據(jù)插入到 redis 中即可。當(dāng)然為了加快插入速度,我們需要使用 pipline 的方式插入。
性能
按照上面的思路的代碼測試結(jié)果如下,耗時(shí) 2 分 15 秒,內(nèi)存占用 2.18G。

優(yōu)化耗時(shí)
并不是用了 pipline 后,耗時(shí)就減少到了極致,我們?nèi)耘f可以通過其他方法進(jìn)一步減少時(shí)間。眾所周知 redis 是非??斓?,即使是使用了 pipline,命令的處理速度仍然遠(yuǎn)小于 RTT 時(shí)間,而 load 進(jìn)程構(gòu)造 pipline 也是一個內(nèi)存的操作,構(gòu)建 pipline 的時(shí)間也遠(yuǎn)小于 RTT 時(shí)間。我們可以通過一個舉一個極端的例子分析時(shí)間到底浪費(fèi)到了哪里:假設(shè)如果構(gòu)建 pipline與 redis 處理 pipline 的時(shí)間都是 10 ms,而 RTT 時(shí)間是 80ms,這樣就意味著 load 進(jìn)程每花費(fèi) 10ms 構(gòu)建一個 pipline 給 redis 都要等待 90ms 才能構(gòu)建下一個 pipline,所以其 cpu 利用率為10%,redis 也同樣如此,可見雙方的 cpu 利用率之低。所以我們可以通過并發(fā) pipline 插入,提高雙方 cpu 利用率來節(jié)省時(shí)間。
性能
經(jīng)過添加并發(fā)優(yōu)化后的測試結(jié)果,耗時(shí) 1 分鐘,內(nèi)存占用 2.17G,內(nèi)存基本持平,耗時(shí)優(yōu)化效果 125%

優(yōu)化內(nèi)存
經(jīng)過上面的測試應(yīng)該明白了內(nèi)存的優(yōu)化主要在序列化上下功夫,首先讀取整個 json 文件反序列化到結(jié)構(gòu)體上,這個就動作就需要大約 2 倍元數(shù)據(jù)的內(nèi)存,一倍的 json 字符串,一倍的結(jié)構(gòu)體??梢娬麄€讀入的代價(jià)太高了,所以我們要以流式讀取的方式來處理,每次讀取并反序列一個最小的 json 對象,這樣內(nèi)存占用就非常低了。load 的另一個問題是我們把所有的 entry 存到了內(nèi)存中來重新計(jì)算 nlink,這個也是導(dǎo)致內(nèi)存占用非常高的原因之一。解決方法也非常簡單,nlink 固然是需要重新計(jì)算的,不過把 entry 的所有屬性都記錄下其實(shí)是沒有必要的,我們回想重新計(jì)算的邏輯,每次將文件類型的 entry 放入 map 前根據(jù) inode 判斷 entry 是否存在,如果存在就意味著這是一個硬鏈接,將這個 inode 的 nlink++。所以將 map 的 value 類型改為 int64 即可,每次放入時(shí) value 值+1,這樣比較大的 map 也就不存在了,內(nèi)存占用進(jìn)一步減少。
性能
經(jīng)過了流式讀取優(yōu)化的測試結(jié)果如下,耗時(shí) 40s,內(nèi)存占用 518M。內(nèi)存優(yōu)化效果 330%

總結(jié)
當(dāng)前 1.0-rc2 版本與最初版優(yōu)化效果
- Dump 耗時(shí) 7 分 47 秒,內(nèi)存占用為 3.18G ,優(yōu)化為耗時(shí) 19 秒,內(nèi)存占用 75M,優(yōu)化效果分別為 2300%和 4200%
- Load 耗時(shí) 2 分 15 秒,內(nèi)存占用 2.18G, 優(yōu)化后為耗時(shí) 40 秒,內(nèi)存占用 518M。優(yōu)化效果分別為 230%和 330%


可以看到優(yōu)化效果是非常明顯的。
以上就是我們的優(yōu)化的思路與結(jié)果了,如果遇到類似的場景,希望這些實(shí)踐經(jīng)驗(yàn)也可以幫助大家拓展優(yōu)化的思路,提升系統(tǒng)的性能!
如有幫助的話歡迎關(guān)注我們項(xiàng)目 Juicedata/JuiceFS 喲! (0?0?)