UFS (UCloud File System) 是一款 UCloud 自主研發的分散式檔案儲存產品,此前已推出容量型 UFS 版本。UFS 以其彈性線上擴容、穩定可靠的特點,為眾多公有云、物理雲、託管雲使用者提供共享儲存方案,單檔案系統儲存容量可達百 PB 級。
為了應對 IO 效能要求很高的資料分析、AI 訓練、高效能站點等場景,UFS 團隊又推出了一款基於 NVMe SSD 介質的效能型 UFS,以滿足高 IO 場景下業務對共享儲存的需求。效能型 UFS 的 4K 隨機寫的延遲能保持在 10ms 以下,4K 隨機讀延遲在 5ms 以下。
效能的提升不僅僅是因為儲存介質的升級,更有架構層面的改進,本文將從協議、索引、儲存設計等幾方面來詳細介紹效能型 UFS 升級改造的技術細節。
協議改進
此前容量型 UFS 設計時支援的協議為 NFSv3,其設計理念是介面無狀態,故障恢復的邏輯簡單。此外 NFSv3 在 Linux 和 Windows 上被廣泛支援,更易於跨平臺使用。但是 NFSv3 的設計缺點導致的高延遲在高 IO 場景下是不可接受的,所以在效能型 UFS 中,我們選擇僅支援效能更好、設計更先進的 NFSv4 協議。
NFSv4 與 NFSv3 相比,更先進的特性包括:支援有狀態的 lock 語義、多協議間的 compound 機制等。特別是 compound 機制,可以讓多次 NFS 協議互動在一個 RTT 中完成,很好地解決了 NFSv3 效能低效的問題。一次典型的 open for write 操作,在 NFSv3 和 NFSv4 上分別是這樣的:
可以看到,在關鍵的 IO 部分,NFSv4 比 NFSv3 節省一半的互動次數,可以顯著降低 IO 延遲。除了協議以外,效能型 UFS 的核心由業務索引和底層儲存兩部分組成,由於底層 IO 效能的提升,這兩部分都需要進行深度改造以適應這種結構性的改變。下面我們將分別介紹這兩部分的改造細節。
業務索引
索引服務是分散式檔案系統的核心功能之一。相比物件儲存等其它儲存服務,檔案儲存的索引需要提供更為複雜的語義,所以會對效能產生更大影響。
索引服務的功能模組設計是基於單機檔案系統設計思路的一種『仿生』,分為兩大部分:
• 目錄索引: 實現樹狀層級目錄,記錄各個目錄下的檔案和子目錄項
• 檔案索引: 記錄檔案後設資料,包含資料塊儲存資訊和訪問許可權等
索引服務各模組的功能是明確的,主要解決兩個問題:
• 業務特性: 除了實現符合檔案系統語義的各類操作外,還要保證索引資料的外部一致性,在各類併發場景下不對索引資料產生靜態修改從而產生資料丟失或損壞
• 分散式系統特性: 包括系統擴充性、可靠性等問題,使系統能夠應對各類節點和資料故障,保證系統對外的高可用性和系統彈性等
雖然功能有區別,目錄索引和檔案索引在架構上是類似的,所以我們下面只介紹檔案索引 (FileIdx) 架構。在以上的目標指導下,最終 FileIdx 採用無狀態設計,依靠各索引節點和 master 之間的租約(Lease)機制來做節點管理,實現其容災和彈性架構。
租約機制和悲觀鎖
master 模組負責維護一張路由表,路由表可以理解成一個由虛節點組成的一致性雜湊環,每個 FileIdx 例項負責其中的部分虛節點,master 通過心跳和各個例項節點進行存活性探測,並用租約機制告知 FileIdx 例項和各個 NFSServer 具體的虛節點由誰負責處理。如果某個 FileIdx 例項發生故障,master 只需要在當前租約失效後將該節點負責的虛節點分配給其他例項處理即可。
當 NFSServer 需要向檔案服務請求具體操作 (比如請求分配 IO 塊) 時,會對請求涉及的檔案控制程式碼做雜湊操作確認負責該檔案的虛節點由哪個 FileIdx 處理,將請求發至該節點。每個節點上為每個檔案控制程式碼維持一個處理佇列,佇列按照 FIFO 方式進行執行。本質上這構成了一個悲觀鎖,當一個檔案的操作遇到較多併發時,我們保證在特定節點和特定佇列上的排隊,使得併發修改導致的衝突降到最低。
更新保護
儘管租約機制一定程度上保證了檔案索引操作的併發安全性,但是在極端情況下租約也不能保持併發操作的絕對互斥及有序。所以我們在索引資料庫上基於 CAS 和 MVCC 技術對索引進行更新保護,確保索引資料不會因為併發更新而喪失外部一致性。
IO 塊分配優化
在效能型 UFS 中,底層儲存的 IO 延遲大幅降低帶來了更高的 IOPS 和吞吐,也對索引模組特別是 IO 塊的分配效能提出了挑戰。頻繁地申請 IO 塊導致索引在整個 IO 鏈路上貢獻的延遲比例更高,對效能帶來了損害。一方面我們對索引進行了讀寫分離改造,引入快取和批量更新機制,提升單次 IO 塊分配的效能。
同時,我們增大了 IO 塊的大小,更大的 IO 資料塊降低了分配和獲取資料塊的頻率,將分配開銷進行均攤。後續我們還將對索引關鍵操作進行非同步化改造,讓 IO 塊的分配從 IO 關鍵路徑上移除,最大程度降低索引操作對 IO 效能的影響。
底層儲存
設計理念
儲存功能是一個儲存系統的重中之重,它的設計實現關係到系統最終的效能、穩定性等。通過對 UFS 在資料儲存、資料操作等方面的需求分析,我們認為底層儲存 (命名為 nebula) 應該滿足如下的要求:・簡單:簡單可理解的系統有利於後期維護・可靠:必須保證高可用性、高可靠性等分散式要求・擴充方便:包括處理叢集擴容、資料均衡等操作・支援隨機 IO・充分利用高效能儲存介質
Nebula: append-only 和中心化索引
基於以上目標,我們將底層儲存系統 nebula 設計為基於 append-only 的儲存系統 (immutable storage)。面向追加寫的方式使得儲存邏輯會更簡單,在多副本資料的同步上可以有效降低資料一致性的容錯複雜度。更關鍵的是,由於追加寫本質上是一個 log-based 的記錄方式,整個 IO 的歷史記錄都被儲存,在此之上實現資料快照和資料回滾會很方便,在出現資料故障時,更容易做資料恢復操作。
在現有的儲存系統設計中,按照資料定址的方式可以分為去中心化和中心化索引兩種,這兩者的典型代表系統是 Ceph 和 Google File System。去中心化的設計消除了系統在索引側的故障風險點,並且降低了資料定址的開銷。但是增加了資料遷移、資料分佈管理等功能的複雜度。出於系統簡單可靠的設計目標,我們最終選擇了中心化索引的設計方式,中心化索引使叢集擴容等擴充性操作變得更容易。
資料塊管理:extent-based 理念
中心化索引面臨的效能瓶頸主要在資料塊的分配上,我們可以類比一下單機檔案系統在這方面的設計思路。早期檔案系統的 inode 對資料塊的管理是 block-based,每次 IO 都會申請 block 進行寫入,典型的 block 大小為 4KB,這就導致兩個問題:1、4KB 的資料塊比較小,對於大片的寫入需要頻繁進行資料塊申請操作,不利於發揮順序 IO 的優勢。2、inode 在基於 block 的方式下表示大檔案時需要更大的後設資料空間,能表示的檔案大小也受到限制。
在 Ext4/XFS 等更先進的檔案系統設計中,inode 被設計成使用 extent-based 的方式來實現,每個 extent 不再被固定的 block 大小限制,相反它可以用來表示一段不定長的磁碟空間,如下圖所示:
![](user-gold-cdn.xitu.io/2019/9/4/16… jpeg&s=34026) 顯然地,在這種方式下,IO 能夠得到更大更連續的磁碟空間,有助於發揮磁碟的順序寫能力,並且有效降低了分配 block 的開銷,IO 的效能也得到了提升,更關鍵的是,它可以和追加寫儲存系統非常好地結合起來。我們看到,不僅僅在單機檔案系統中,在 Google File System、Windows Azure Storage 等分散式系統中也可以看到 extent-based 的設計思想。我們的 nebula 也基於這一理念進行了模型設計。
儲存架構 Stream 資料流
在 nebula 系統中儲存的資料按照 stream 為單位進行組織,每個 stream 稱為一個資料流,它由一個或多個 extent 組成,每次針對該 stream 的寫入操作以 block 為單位在最後一個 extent 上進行追加寫,並且只有最後一個 extent 允許寫入,每個 block 的長度不定,可由上層業務結合場景決定。而每個 extent 在邏輯上構成一個副本組,副本組在物理上按照冗餘策略在各儲存節點維持多副本,stream 的 IO 模型如下:
streamsvr 和 extentsvr
基於這個模型,儲存系統被分為兩大主要模組:・streamsvr:負責維護各個 stream 和 extent 之間的對映關係以及 extent 的副本位置等後設資料,並且對資料排程、均衡等做管控・extentsvr:每塊磁碟對應一個 extentsvr 服務程式,負責儲存實際的 extent 資料儲存,處理前端過來的 IO 請求,執行 extent 資料的多副本操作和修復等
在儲存叢集中,所有磁碟通過 extentsvr 表現為一個大的儲存池,當一個 extent 被請求建立時,streamsvr 根據它對叢集管理的全域性視角,從負載和資料均衡等多個角度選取其多副本所在的 extentsvr,之後 IO 請求由客戶端直接和 extentsvr 節點進行互動完成。在某個儲存節點發生故障時,客戶端只需要 seal 掉當前在寫入的 extent,建立一個新的 extent 進行寫入即可,節點容災在一次 streamsvr 的 rpc 呼叫的延遲級別即可完成,這也是基於追加寫方式實現帶來的系統簡潔性的體現。
由此,儲存層各模組的架構圖如下:
至此,資料已經可以通過各模組的協作寫入到 extentsvr 節點,至於資料在具體磁碟上的儲存佈局,這是單盤儲存引擎的工作。
單盤儲存引擎
前面的儲存架構講述了整個 IO 在儲存層的功能分工,為了保證效能型 UFS 的高效能,我們在單盤儲存引擎上做了一些優化。
執行緒模型優化
儲存介質效能的大幅提升對儲存引擎的設計帶來了全新的需求。在容量型 UFS 的 SATA 介質上,磁碟的吞吐較低延遲較高,一臺儲存機器的整體吞吐受限於磁碟的吞吐,一個單執行緒 / 單程式的服務就可以讓磁碟吞吐打滿。隨著儲存介質處理能力的提升,IO 的系統瓶頸逐漸從磁碟往處理器和網路頻寬方面轉移。
在 NVMe SSD 介質上由於其多佇列的並行設計,單執行緒模型已經無法發揮磁碟效能優勢,系統中斷、網路卡中斷將成為 CPU 新的瓶頸點,我們需要將服務模型轉換到多執行緒方式,以此充分發揮底層介質多佇列的並行處理能力。為此我們重寫了程式設計框架,新框架採用 one loop per thread 的執行緒模型,並通過 Lock-free 等設計來最大化挖掘磁碟效能。
block 定址
讓我們思考一個問題,當客戶端寫入了一片資料 block 之後,讀取時如何找到 block 資料位置?一種方式是這樣的,給每個 block 分配一個唯一的 blockid,通過兩級索引轉換進行定址:
・第一級:查詢 streamsvr 定位到 blockid 和 extent 的關係
・第二級:找到 extent 所在的副本,查詢 blockid 在 extent 內的偏移,然後讀取資料
這種實現方式面臨兩個問題,(1)第一級的轉換需求導致 streamsvr 需要記錄的索引量很大,而且查詢互動會導致 IO 延遲升高降低效能。(2)第二級轉換以 Facebook Haystack 系統為典型代表,每個 extent 在檔案系統上用一個獨立檔案表示,extentsvr 記錄每個 block 在 extent 檔案中的偏移,並在啟動時將全部索引資訊載入在記憶體裡,以提升查詢開銷,查詢這個索引在多執行緒框架下必然因為互斥機制導致查詢延遲,因此在高效能場景下也是不可取的。而且基於檔案系統的操作讓整個儲存棧的 IO 路徑過長,效能調優不可控,也不利於 SPDK 技術的引入。
為避免上述不利因素,我們的儲存引擎是基於裸盤設計的,一塊物理磁碟將被分為幾個核心部分:
**•superblock: ** 超級塊,記錄了 segment 大小,segment 起始位置以及其他索引塊位置等
**•segment: ** 資料分配單位,整個磁碟除了超級塊以外,其他區域全部都是 segment 區域,每個 segment 是定長的 (預設為 128MB),每個 extent 都由一個或多個 segment 組成
**•extent index / segment meta region: extent/segment ** 索引區域,記錄了每個 extent 對應的 segment 列表,以及 segment 的狀態 (是否可用) 等資訊
基於這個設計,我們可以將 block 的定址優化為無須查詢的純計算方式。當寫完一個 block 之後,將返回該 block 在整個 stream 中的偏移。客戶端請求該 block 時只需要將此偏移傳遞給 extentsvr,由於 segment 是定長的,extentsvr 很容易就計算出該偏移在磁碟上的位置,從而定位到資料進行讀取,這樣就消除了資料定址時的查詢開銷。
隨機 IO 支援:FileLayer 中間層
我們之前出於簡單可靠的理念將儲存系統設計為 append-only,但是又由於檔案儲存的業務特性,需要支援覆蓋寫這類隨機 IO 場景。
因此我們引入了一箇中間層 FileLayer 來支援隨機 IO,在一個追加寫的引擎上實現隨機寫,該思路借鑑於 Log-Structured File System 的實現。LevelDB 使用的 LSM-Tree 和 SSD 控制器裡的 FTL 都有類似的實現,被覆蓋的資料只在索引層面進行間接修改,而不是直接對資料做覆蓋寫或者是 COW (copy-on-write),這樣既可以用較小的代價實現覆蓋寫,又可以保留底層追加寫的簡單性。
FileLayer 中發生 IO 操作的單元稱為 dataunit,每次讀寫操作涉及的 block 都在某個 dataunit 上進行處理,dataunit 的邏輯組成由如下幾個部分:
dataunit 由多個 segment 組成 (注意這和底層儲存的 segment 不是一個概念),因為基於 LSM-Tree 的設計最終需要做 compaction, 多 segment 的劃分類似於 LevelDB 中的多層 sst 概念,最下層的 segment 是隻讀的,只有最上層的 segment 允許寫入,這使得 compaction 操作可以更簡單可靠地進行甚至回滾,而由於每次 compaction 涉及的資料域是確定的,也便於我們檢驗 compaction 操作的 invariant:回收前後資料域內的有效資料必須是一樣的。
每個 segment 則由一個索引流和一個資料流組成,它們都儲存在底層儲存系統 nebula 上,每次寫入 IO 需要做一次資料流的同步寫,而為了提升 IO 效能,索引流的寫入是非同步的,並且維護一份純記憶體索引提升查詢操作效能。為了做到這一點,每次寫入到資料流中的資料是自包含的,這意味著如果索引流缺失部分資料甚至損壞,我們可以從資料流中完整構建整個索引。
客戶端以檔案為粒度寫入到 dataunit 中,dataunit 會給每個檔案分配一個全域性唯一的 fid,fid 作為資料控制程式碼儲存到業務索引中 (FileIdx 的 block 控制程式碼)。
dataunit 本身則由 fileserver 服務程式負責,每個 fileserver 可以有多個 dataunit,coordinator 根據各節點的負載在例項間進行 dataunit 的排程和容災。整個 FileLayer 的架構如下:
至此,儲存系統已經按照設計要求滿足了我們檔案儲存的需求,下面我們來看一看各個模組是如何一起協作來完成一次檔案 IO 的。
The Big Picture:一次檔案寫 IO 的全流程
從整體來說,一次檔案寫 IO 的大致流程是這樣的:
①使用者在主機上發起 IO 操作會在核心層被 nfs-client 在 VFS 層截獲 (僅以 Linux 系統下為例),通過被隔離的 VPC 網路發往 UFS 服務的接入層。
②接入層通過對 NFS 協議的解析和轉義,將這個操作分解為索引和資料操作。
③經過索引模組將這個操作在檔案內涉及的 IO 範圍轉化為由多個 file system block (固定大小,預設 4MB) 表示的 IO 範圍。
④NFSServer 拿到需要操作的 block 的控制程式碼 (bid) 後去請求 FileLayer 進行 IO 操作 (每個 bid 在 FileLayer 中代表一個檔案)。
請求會被 NFSServer 發往負責處理該 bid 對應的檔案的 fileserver 上,fileserver 獲取該檔案所在的 dataunit 編號 (此編號被編碼在 bid 中) 後,直接往該 dataunit 當前的資料流 (stream) 中進行追加寫,完成後更新索引,將新寫入的資料的位置記錄下來,本次 IO 即告完成,可以向 NFSServer 返回回應了。類似地,當 fileserver 產生的追加寫 IO 抵達其所屬的 extentsvr 的時候,extentsvr 確定出該 stream 對應的最後一個 extent 在磁碟上的位置,並執行一次追加寫落地資料,在完成多副本同步後返回。
至此,一次檔案寫 IO 就完成了。
效能資料
經過前述的設計和優化,效能型 UFS 的實際效能資料如下:
總結
本文從 UFS 效能型產品的需求出發,詳細介紹了基於高效能儲存介質構建分散式檔案系統時,在協議、業務架構、儲存引擎等多方面的設計考慮和優化,並最終將這些優化落實到產品中去。效能型 UFS 的上線豐富了產品種類,各類對 IO 延遲要求更高的大資料分析、AI 訓練等業務場景將得到更好的助力。
後續我們將在多方面繼續提升 UFS 的使用體驗,產品上會支援 SMB 協議,提升 Windows 主機使用檔案儲存的效能;底層儲存會引入 SPDK、RDMA 等技術,並結合其它更高效能的儲存介質;在冷存資料場景下引入 Erasure Coding 等技術;使使用者能夠享受到更先進的技術帶來的效能和價格紅利。
產品最新優惠:效能型 UFS 原價 1.0 元 /GB/ 月,現在福建可用區優惠價 0.6 元 /GB/ 月,國內其他可用區 0.8 元 /GB/ 月,歡迎聯絡客戶經理申請體驗!
如您有本篇文章相關問題,歡迎新增作者微信諮詢。WeChat ID:cheneydeng