海量小文件存儲(簡稱LOSF,lots of small files)出現后,就一直是業界的難題,眾多博文(如 [1] )對此問題進行了闡述與分析,許多互聯網公司也針對自己的具體場景研發了自己的存儲方案(如taobao開源的 TFS ,facebook自主研發的 Haystack ),還有一些公司在現有開源項目(如hbase,fastdfs,mfs等)基礎上做針對性改造優化以滿足業務存儲需求;
一. 海量小文件存儲側重于解決的問題
通過對若干分布式存儲系統的調研、測試與使用,與其它分布式系統相比,海量小文件存儲更側重于解決兩個問題:
1. 海量小文件的元數據信息組織與管理: 對于百億量級的數據,每個文件元信息按100B計算,元信息總數據量為1TB,遠超過目前單機服務器內存大小;若使用本地持久化設備存儲,須高效滿足每次 文件存取請求的元數據查詢尋址(對于上層有cdn的業務場景,可能不存在明顯的數據熱點),為了避免單點,還要有備用元數據節點;同時,單組元數據服務器 也成為整個集群規模擴展的瓶頸;或者使用獨立的存儲集群存儲管理元數據信息,當數據存儲節點的狀態發生變更時,應該及時通知相應元數據信息進行變更;
對此問題,tfs/fastdfs設計時,就在文件名中包含了部分元數據信息,減小了元數據規模,元數據節點只負責管理粒度更大的分片結構信息; 商用分布式文件系統龍存,通過升級優化硬件,使用分布式元數據架構——多組(每組2臺)高性能ssd服務器——存儲集群的元數據信息,滿足單次io元數據 查詢的同時,也實現了元數據存儲的擴展性;Haystack Directory模塊提供了圖片邏輯卷到物理卷軸的映射存儲與查詢功能,使用Replicated Database存儲,并通過cache集群來降低延時提高并發,其對外提供的讀qps在百萬量級;
2. 本地磁盤文件的存儲與管理(本地存儲引擎):對于常見的linux文件系統,讀取一個文件通常需要三次磁盤IO(讀取目錄元數據到內存,把文件的 inode節點裝載到內存,最后讀取實際的文件內容);按目前主流2TB~4TB的sata盤,可存儲2kw~4kw個100KB大小的文件,由于文件數 太多,無法將所有目錄及文件的inode信息緩存到內存,很難實現每個圖片讀取只需要一次磁盤IO的理想狀態,而長尾現象使得熱點緩存無明顯效果;當請求 尋址到具體的一塊磁盤,如何減少文件存取的io次數,高效地響應請求(尤其是讀)已成為需要解決的另一問題;
對此問題,有些系統(如tfs,Haystack)采用了小文件合并存儲+索引文件的優化方案,此方案有若干益處:a.合并后的合并大文件通常在 64MB,甚至更大,單盤所存存儲的合并大文件數量遠小于原小文件的數量,其inode等信息可以全部被cache到內存,減少了一次不必要的磁盤 IO;b.索引文件通常數據量(通常只存儲小文件所在的合并文件,及offset和size等關鍵信息)很小,可以全部加載到內存中,讀取時先訪問內存索 引數據,再根據合并文件、offset和size訪問實際文件數據,實現了一次磁盤IO的目的;c.單個小文件獨立存儲時,文件系統存儲了其guid、屬 主、大小、創建日期、訪問日期、訪問權限及其它結構信息,有些信息可能不是業務所必需的,在合并存儲時,可根據實際需要對文件元數據信息裁剪后在做合并, 減少空間占用。除了合并方法外,還可以使用性能更好的SSD等設備,來實現高效響應本地io請求的目標。
當然,在合并存儲優化方案中,刪除或修改文件操作可能無法立即回收存儲空間,對于存在大量刪除修改的業務場景,需要再做相應的考量。
二. 海量小文件存儲與Ceph實踐
Ceph 是近年越來越被廣泛使用的分布式存儲系統,其重要的創新之處是基于 CRUSH 算法的計算尋址,真正的分布式架構、無中心查詢節點,理論上無擴展上限(更詳細ceph介紹見網上相關文章);Ceph的基礎組件RADOS本身是對象存 儲系統,將其用于海量小文件存儲時,CRUSH算法直接解決了上面提到的第一個問題;不過Ceph OSD目前的存儲引擎( Filestore , KeyValuestore )對于上面描述的海量小文件第二個問題尚不能很好地解決;ceph社區曾對此問題做過描述并提出了基于rgw的一種 方案 (實際上,在實現本文所述方案過程中,發現了社區上的方案),不過在最新代碼中,一直未能找到方案的實現;
我們在Filestore存儲引擎基礎上對小文件存儲設計了優化方案并進行實現,方案主要思路如下:將若干小文件合并存儲在RADOS系統的一個 對象(object)中,<小文件的名字、小文件在對象中的offset及小文件size>組成kv對,作為相應對象的擴展屬性(或者 omap,本文以擴展屬性表述,ceph都使用kv數據庫實現,如leveldb)進行存儲,如下圖所示,對象的擴展屬性數據與對象數據存儲在同一塊盤上;
使用本結構存儲后,write小文件file_a操作分解為: 1)對某個object調用append小文件file_a;2)將小文件file_a在相應object的offset和size,及小文件名字 file_a作為object的擴展屬性存儲kv數據庫。read小文件file_a操作分解為:1)讀取相應object的file_a對應的擴展屬性 值(及offset,size);2)讀取object的offset偏移開始的size長度的數據。對于刪除操作,直接將相應object的 file_a對應的擴展屬性鍵值刪除即可,file_a所占用的存儲空間延遲回收,回收以后討論。另外,Ceph本身是強一致存儲系統,其內在機制可以保 證object及其擴展屬性數據的可靠一致;
由于對象的擴展屬性數據與對象數據存儲在同一塊盤上,小文件的讀寫操作全部在本機本OSD進程內完成,避免了網絡交互機制潛在的問題。另一方面, 對于寫操作,一次小文件寫操作對應兩次本地磁盤隨機io(邏輯層面),且不能更少,某些kv數據庫(如leveldb)還存在write amplification問題,對于寫壓力大的業務場景,此方案不能很好地滿足;不過對于讀操作,我們可以通過配置參數,盡量將kv數據保留在內存中, 實現讀取操作一次磁盤io的預期目標;
如何選擇若干小文件進行合并,及合并存儲到哪個對象中呢?最簡單地方案是通過計算小文件key的hash值,將具有相同hash值的小文件合并存 儲到id為對應hash值的object中,這樣每次存取時,先根據key計算出hash值,再對id為hash值的object進行相應的操作;關于 hash函數的選擇,(1)可使用最簡單的hash取模,這種方法需要事先確定模數,即當前業務合并操作使用的object個數,且確定后不能改變,在業 務數據增長過程中,小文件被平均分散到各個object中,寫壓力被均勻分散到所有object(即所有物理磁盤,假設object均勻分布) 上;object文件大小在一直增長,但不能無限增長,上限與單塊磁盤容量及存儲的object數量有關,所以在部署前,應規劃好集群的容量和hash模 數。(2)對于某些帶目錄層次信息的數據,如/a/b/c/d/efghi.jpg,可以將文件的目錄信息作為相應object的id,及/a/b/c /d,這樣一個子目錄下的所有文件存儲在了一個object中,可以通過rados的listxattr命令查看一個目錄下的所有文件,方便運維使用;另 外,隨著業務數據的增加,可以動態增加object數量,并將之前的object設為只讀狀態(方便以后的其它處理操作),來避免object的無限增 長;此方法需要根據業務寫操作量及集群磁盤數來合理規劃當前可寫的object數量,在滿足寫壓力的前提下將object大小控制在一定范圍內。
本方案是為小文件(1MB及以下)設計的,對于稍大的文件存儲(幾十MB甚至更大),如何使用本方案存儲呢?我們將大文件 large_file_a切片分成若干大小一樣(如2MB,可配置,最后一塊大小可能不足2MB)的若干小塊文 件:large_file_a_0, large_file_a_1 ... large_file_a_N,并將每個小塊文件作為一個獨立的小文件使用上述方案存儲,分片信息(如總片數,當前第幾片,大文件大小,時間等) 附加在每個分片數據開頭一并進行存儲,以便在讀取時進行解析并根據操作類型做相應操作。
根據業務的需求,我們提供如下操作接口供業務使用(c++描述):
int WriteFullObj(const std::string& oid, bufferlist& bl, int create_time = GetCurrentTime());
int Write(const std::string& oid, bufferlist& bl, uint64_t off, int create_time = GetCurrentTime());
int WriteFinish(const std::string& oid, uint64_t total_size, int create_time = GetCurrentTime());
int Read(const std::string& oid, bufferlist& bl, size_t len, uint64_t off);
int ReadFullObj(const std::string& oid, bufferlist& bl, int* create_time = NULL);
int Stat(const std::string& oid, uint64_t *psize, time_t *pmtime, MetaInfo* meta = NULL);
int Remove(const std::string& oid);
int BatchWriteFullObj(const String2BufferlistHMap& oid2data, int create_time = GetCurrentTime());
對于寫小文件可直接使用WriteFullObj;對于寫大文件可使用帶offset的Write,寫完所有數據后,調用 WriteFinish;對于讀取整個文件可直接使用ReadFullObj;對于隨機讀取部分文件可使用帶offset的Read;Stat用于查看文 件狀態信息;Remove用于刪除文件;當使用第二種hash規則時,可使用BatchWriteFullObj提高寫操作的吞吐量。