NameNode鎖細(xì)粒度優(yōu)化在B站的實(shí)踐
1. 背景
隨著業(yè)務(wù)的高速發(fā)展,針對HDFS元數(shù)據(jù)的訪問請求量呈指數(shù)級上升。在之前的工作中,我們已經(jīng)通過引入HDFS Federation和Router機(jī)制實(shí)現(xiàn)NameNode的平行擴(kuò)容,在一定程度上滿足了元數(shù)據(jù)的擴(kuò)容需求;也通過引入Observer NameNode讀寫分離架構(gòu)提升單組NameSpace的讀寫能力,在一定程度上減緩了讀寫壓力。但隨著業(yè)務(wù)場景的發(fā)展變化,NameSpace數(shù)量也在上升至30+組后,Active+Standby+Observer NameNode 的架構(gòu)已經(jīng)無法滿足所有的元數(shù)據(jù)讀寫場景,我們必須考慮提升NameNode讀寫能力,來應(yīng)對不斷上升的元數(shù)據(jù)讀寫要求。
如圖1-1 所展示的B站離線存儲整體架構(gòu)所示,隨著業(yè)務(wù)的不斷增量發(fā)展,通過引入HDFS Router機(jī)制實(shí)現(xiàn)NameNode的平行擴(kuò)容,目前NameSpace的數(shù)量已經(jīng)超過30+組,總存儲量EB級,每日請求訪問量超過200億次。各個NameSpace之間的讀寫請求更是分布非常不均衡,在一些特殊場景下,部分NameSpace的整體負(fù)載更高。如Flink任務(wù)的CheckPoint 場景,Spark和MR任務(wù)的log日志上傳場景,這兩類場景的數(shù)據(jù)寫入要求要遠(yuǎn)遠(yuǎn)高于普通場景。此外還有部分?jǐn)?shù)據(jù)回刷場景,存在短時間寫入請求增加300%以上的情況,極易觸發(fā)NameNode的寫入性能瓶頸,影響其他任務(wù)的正常訪問。為了應(yīng)對這個問題,我們針對性的提出了NameNode的讀寫性能提升方案。
圖1-1 B站HDFS整體架構(gòu)圖
2. HDFS 細(xì)粒度鎖優(yōu)化整體方案
2.1 面臨的問題
NameNode是整個HDFS的核心組件,集中管理HDFS集群的所有元數(shù)據(jù),主要包括文件系統(tǒng)的目錄樹、數(shù)據(jù)塊集合和分布以及整個集群的拓?fù)浣Y(jié)構(gòu)。HDFS在對NameNode的實(shí)現(xiàn)上做了大膽取舍,如圖2-1所示,鎖機(jī)制上使用全局鎖來統(tǒng)一來控制并發(fā)讀寫。這樣處理的優(yōu)勢非常明顯,全局鎖進(jìn)一步簡化鎖模型,不需要額外考慮鎖依賴關(guān)系,同時降低復(fù)雜度,減少工程量。但是問題比優(yōu)勢更加突出,核心問題就是全局唯一鎖制約性能提升。
圖片
在多年的HDFS實(shí)踐工作中,我們發(fā)現(xiàn)NameNode全局唯一的讀寫鎖已經(jīng)成為NameNode讀寫性能最大瓶頸之一,社區(qū)已經(jīng)做了很多的工作來優(yōu)化相關(guān)性能,如將一些日志操作異步化,移動日志操作到鎖外,針對DU請求采用分段鎖,大刪除異步化等一系列優(yōu)化措施,但對于我們這種數(shù)據(jù)量的HDFS集群來說,仍然難以滿足部分生產(chǎn)場景。為了進(jìn)一步提升HDFS讀寫性能,滿足業(yè)務(wù)場景,我們計劃對全局鎖進(jìn)行細(xì)粒度拆分,為此我們也面臨著許多困難。
首先是問題復(fù)雜度高,Hadoop發(fā)展到今天已經(jīng)超過十年,其中HDFS經(jīng)過多次迭代演進(jìn),架構(gòu)已經(jīng)非常復(fù)雜。針對NameNode組件來說,架構(gòu)上模塊劃分不夠清晰,內(nèi)部核心數(shù)據(jù)結(jié)構(gòu)和工作線程之間耦合非常嚴(yán)重,實(shí)現(xiàn)細(xì)節(jié)上,還存在大量相互依賴,不一而足。
其次是社區(qū)的動力不足,在全局唯一的讀寫鎖的擴(kuò)展性問題上,社區(qū)做過多次嘗試,主要就有 HDFS-8966:Separate the lock used in namespace and block management layer 和 HDFS-5453:Support fine grain locking in FSNamesystem 等方面的嘗試,但是并沒有產(chǎn)出可以進(jìn)行生產(chǎn)化部署的成果。具體原因還是動力不足,因為NameNode性能針對小規(guī)模部署的集群來說大體上已經(jīng)足夠,也有通過Federation和Router機(jī)制進(jìn)行擴(kuò)展,滿足一定的需求。
為了解決這個難題,我們參考了業(yè)界的拆鎖方案和Alluxio的LockPool實(shí)現(xiàn)機(jī)制,計劃實(shí)現(xiàn)針對NameNode全局唯一鎖的細(xì)粒度拆分。
2.2 設(shè)計選型
為了更好地理解使用全局鎖存在的問題,首先梳理全局鎖管理的主要數(shù)據(jù)結(jié)構(gòu),大致分成三類:
- NameSpace目錄樹:文件系統(tǒng)的全局目錄視圖。獲取目錄樹上任一節(jié)點(diǎn)的信息必須先拿到全局讀鎖;目錄樹上任一節(jié)點(diǎn)新增、刪除、修改都必須先拿到全局寫鎖。
- BlockPool層數(shù)據(jù)塊集合:文件系統(tǒng)的全量數(shù)據(jù)信息。獲取其中任一數(shù)據(jù)塊信息必須先拿到全局讀鎖;新增、刪除,修改都必須先拿到全局寫鎖。
- 集群信息:HDFS集群節(jié)點(diǎn)信息的集合。獲取節(jié)點(diǎn)信息等必須先拿到全局讀鎖;注冊,下線或者變更節(jié)點(diǎn)信息請求處理時必須先拿到全局寫鎖。
具體實(shí)現(xiàn)上,NameNode使用了JDK提供的可重入讀寫鎖(ReentrantReadWriteLock),ReentrantReadWriteLock對并行請求有嚴(yán)格限制,支持讀請求并行處理,寫請求具有排他性。針對不同RPC請求的處理邏輯,按照需要獲取鎖粒度,我們可以把所有請求抽象為全局讀鎖和全局寫鎖兩類。全局讀鎖包括客戶端請求(getListing/getBlockLocations/getFileInfo)、服務(wù)管理接口(monitorHealth/getServiceStatus)等;全局寫鎖則包括客戶端寫請求(create/mkdir/rename/append/truncate/complete/recoverLease)、服務(wù)管理接口(transitionToActive/transitionToStandby/setSafeMode)和主從節(jié)點(diǎn)之間請求(rollEditLog)等。在一次RPC處理過程中,如果不能及時獲取到鎖,這次RPC將處于排隊等待狀態(tài),直到成功獲得鎖,鎖等待時間直接影響請求響應(yīng)性能,極端場景下如果長時間不能獲得鎖,將造成IPC隊列堆積,TCP連接隊列被打滿,客戶端出現(xiàn)請求卡住,新建連接超時失敗等各種異常問題。從全局來看,寫鎖因為排它對性能影響更加明顯。如果當(dāng)前有寫請求正在被處理,其他所有請求都必須排隊等待,直到寫請求被處理完成釋放鎖后再競爭全局鎖。因此我們希望對全局鎖進(jìn)行細(xì)粒度劃分,最終實(shí)現(xiàn)NameNode服務(wù)的大部分的RPC請求都能并行處理。
我們計劃通過3步實(shí)現(xiàn) NameNode 鎖的細(xì)粒度劃分,如圖2-2所示。
第一步,將NameNode 全局鎖拆分為 Namespace層讀寫鎖和 BlockPool層讀寫鎖;
第二部,將NameSpace讀寫鎖拆成顆粒度更細(xì)的Inode層的讀寫鎖;
第三步,將BlockPool層讀寫鎖也拆成更細(xì)粒度的讀寫鎖;
目前我們已經(jīng)基本完成第一部分和第二部分的工作。
圖2-2 NameNode 鎖優(yōu)化過程
3. HDFS 細(xì)粒度鎖優(yōu)化實(shí)現(xiàn)
3.1 NameNode全局唯一鎖拆成
NameSpace層鎖和BlockPool層鎖
在實(shí)踐中發(fā)現(xiàn),客戶端請求訪問NameNode過程中,部分請求需要同時訪Namespace層和BlockPool層,有些請求只需要訪問 Namespace層,同時服務(wù)端請求如DataNode的IBR/BlockReport等請求實(shí)際上也只需要訪問 BlockPool層,這兩層的鎖調(diào)用可以拆分,實(shí)現(xiàn)對兩層數(shù)據(jù)的并行訪問。因此拆鎖的第一步, 就是將NameNode 全局鎖拆分為 Namespace層讀寫鎖和 BlockPool層讀寫鎖,如圖2-3所示,通過這種拆分實(shí)現(xiàn)訪問的這兩層數(shù)據(jù)的RPC請求能夠并?處理。在實(shí)踐過程中,我們引入了BlockManagerLock,單獨(dú)處理BlockPool層鎖事件。
圖2-3 NameNode全局唯一鎖拆成NameSpace層鎖和BlockPool層鎖
在實(shí)際的拆鎖過程中,我們發(fā)現(xiàn)NameSpace層和BlockPool層之間有非常多的耦合,這里我們參考了社區(qū)的一部分工作HDFS-8966:Separate the lock used in namespace and block management layer, 已經(jīng)幫助我們解除了部分的依賴,除了社區(qū)列出來的這部分依賴之外我們還發(fā)現(xiàn)一些BlockPool層對NameSpace層的反相依賴,主要是Block的副本信息和storagePolicy屬性信息,這塊我們將這部分信息在BlockPool層進(jìn)行冗余存儲,同時確保發(fā)生變更時NameSpace層的信息及時同步至BlockPool層。在解除了BlockPool層對NameSpace層的反相依賴后,開始針對不同類型的請求獲取何種類型的鎖進(jìn)行區(qū)分,如圖2-4所示。
- NameSpace層請求(getListing/getFileInfo等請求),只需要獲取NameSpace層鎖;
- BlockPool層請求(BlockReport/IncrementalBlockReport等請求),只需要獲取BlockPool層鎖,這塊我們發(fā)現(xiàn)有塊上報過程中,有一段更新Quota的邏輯需要獲取NameSpace層鎖,我們無法做到完美的適配,考慮到我們的Quota采用的外置計算的方式,所以做了相應(yīng)的取舍,只獲取了BlockPool層的鎖;
- 同時訪問NameSpace層和BlockPool層的請求(setReplication/getBlockLocation),需要同時獲取NameSpace層的鎖和BlockPool層的鎖。
通過對不同請求按不同類型鎖要求劃分后,我們基本可以做到訪問部分不同層數(shù)據(jù)的請求的并行執(zhí)行,但仍然有2個問題需要解決。首先是死鎖問題,為此我們確保所有請求的加鎖順序的一致性,所有需要同時獲取NameSpace層鎖和BlockPool層的請求都是NameSpace層鎖在前,BlockPool層的鎖在后;其次是一致性問題,NameNode內(nèi)部本身是寫一致性,并發(fā)讀取場景,針對同時訪問NameSpace層和BlockPool層的請求,需要確保NameSpace層加鎖范圍完全包含BlockPool層加鎖范圍,防止讀取到中間狀態(tài)。
圖2-4 不同類型的請求加鎖場景
通過上述這種方式,我們基本實(shí)現(xiàn)了BlockPool層和NameSpace層的鎖拆分,當(dāng)前這部分優(yōu)化策略已經(jīng)在生產(chǎn)環(huán)境運(yùn)行了一段時間,NameNode整體性能大約提升了50%左右。
3.2 NameSpace層鎖拆分成INode粒度鎖
在實(shí)現(xiàn)了FSN層和BP層鎖拆分之后,NameNode性能已經(jīng)有了一定的提升,生產(chǎn)環(huán)境中對HDFS的NameNode元數(shù)據(jù)請求的rpc processtime和queuetime也有明顯的下降,但仍然有一些場景無法滿足,因此我們繼續(xù)優(yōu)化,對NameSpace層的鎖進(jìn)行更細(xì)粒度的拆分如圖2-5所示,將鎖細(xì)粒度到INode層,希望能進(jìn)一步提升NameSpace層RPC并發(fā)能力,提升NameNode整體寫入能力。
圖2-5 NameSpace層鎖細(xì)粒度拆分
要將NameSpace層鎖拆分到INode層級粒度,必然要為對應(yīng)的INode分配鎖對象,在這里我們面臨了許多問題。
首先是內(nèi)存限制,我們目前單組Namespace元數(shù)據(jù)容量閾值基本在10億左右,如果每個INode分配一個INode鎖,單是INode鎖的內(nèi)存幾乎就需要120GB左右的內(nèi)存,再加上本身NameNode就非常耗費(fèi)內(nèi)存,當(dāng)前的服務(wù)器類型很難滿足。為了解決這個問題,我們參考了 Alluxio 的LockPool 的概念,也就是有一個鎖資源池,每個INode需要Lock加鎖的時候,就去資源池里申請鎖,同時引用計數(shù)會增加,用完之后unlock掉的時候,引用計數(shù)會減少,同時配置不同的高低水位,定期清理掉引用計數(shù)為0的鎖,確??傮w內(nèi)存可控。
其次是鎖對象的管理,這方面我們引入了INodeLockManager 用于管理INode和鎖對象的之間的映射,我們通過INodeLockManager新增了INode鎖的LockPool 和 Edge鎖的LockPool,如圖2-6所示,管理整個NameSpace層的INode層級的細(xì)粒度鎖。
圖2-6 NameSpace層的INode層級的細(xì)粒度鎖管理
完成了鎖對象的管理后,Namespace層鎖細(xì)粒度拆分剩下的問題都是如何預(yù)防死鎖和數(shù)據(jù)錯亂,因此我們對加鎖行為進(jìn)行規(guī)劃,總體遵循如下原則。
- 普通Client端的RPC請求采用自上而下的加鎖方式,對特殊操作如Rename等操作進(jìn)行特殊處理;
- Client端的RPC請求進(jìn)行全鏈路加鎖,部分請求考慮最后的INode和Edge加寫鎖;
如圖2-7所示,我們配置了3種類型的鎖,分別時Read鎖,Write_Inode 鎖和 WRITE_EDGE鎖分別應(yīng)對不同類型的客戶端RPC請求。針對讀請求,我們正向遍歷INodeTree從ROOT節(jié)點(diǎn)開始依次加鎖 對對整個路徑上的INode和Edge都加讀鎖;針對addBlock ,setReplication 這類不影響INodeTree的請求,我們也是正向遍歷INodeTree從ROOT節(jié)點(diǎn)開始依次加讀鎖,但是對最后一個INode加寫鎖;針對create ,mkdir請求,我們正向遍歷INodeTree ,對最后INode節(jié)點(diǎn)和最后的Edge都加寫鎖,如果最后INode不存在,對最后的Edge也需要加寫鎖。
圖2-7 INodeLock 加鎖方式
除了訪問單個路徑的請求,還有rename等訪問多個路徑的rpc請求,如圖2-8所示,從 /a/b/c rename 成 /a/b/e,我們對這種場景做了特殊處理。我們首先路徑/a/b/c和/a/b/e按字典序確定先后,再自上而下加鎖,如圖2-8所示,路徑/a/b/c排序在前,我們先對/a/b/c 路徑加鎖,正向遍歷INodeTree從ROOT節(jié)點(diǎn)開始依次加鎖,邊b-c,INode c都加上寫鎖,路徑/a/b/e排序在后,我們在對路徑/a/b/c加鎖完成后,對路徑/a/b/e加鎖,同樣遍歷INodeTree,Edge b→e加上寫鎖,INode e 由于還未存在,則放棄加鎖;
圖2-8 Rename RPC操作加鎖方式
在上述的工作中,我們完成了不同請求的加鎖方式,針對部份加鎖場景中存在的INode缺失場景(如文件不存在等場景),如下圖2-9所示針對相對典型的是create請求列舉了不同RPC類型的加鎖邏輯。
- create 路徑/a/b/c文件,如果當(dāng)前已經(jīng)存在存在/a/b 路徑,則最終會在Edge b->c 加寫鎖;
- create 路徑/a/b/c文件,如果已經(jīng)存在/a/b/c路徑,則最終會在Edge b→c 和INode c上加寫鎖;
- create 路徑/a/b/c文件,如果只存在 /a 路徑,則會在Edge a->b 這條邊上加上寫鎖。
圖 2-9 不同RPC類型的加鎖邏輯舉例
通過實(shí)現(xiàn)上述2步拆鎖過程,NameNode性能已經(jīng)有了很大提升,如圖2-10展示了我們在測試環(huán)境中的性能對比,經(jīng)過Namespace層讀寫鎖和BlockPool層讀寫鎖拆分后,相比于社區(qū)版本,單NameSpace的寫性能大約提升了50% ,經(jīng)過Namespace層細(xì)粒度鎖拆分后,寫性能相比于社區(qū)版本有3倍左右的提升,此時NameNode性能瓶頸已經(jīng)集中在Edits和審計日志同步以及BlockPool層的本身的鎖競爭上。在實(shí)際生產(chǎn)過程中,我們把之前需要2組NameSpace支持的任務(wù)日志采集路徑收歸為一組NamesSpace支持,在寫入QPS上升3倍的場景下,整體rpc queue time 下降了90%,整體性能有很大的提升。
圖2-10 鎖優(yōu)化性能對比
四. 總結(jié)與展望
NameNode的性能優(yōu)化已經(jīng)告一段落了,第一步和第二部的拆鎖已經(jīng)在我們的生產(chǎn)集群上穩(wěn)定運(yùn)行了一段時間,整體性能提升明顯,整體RPC Queue Time相比于拆鎖之前有數(shù)量級的下降,當(dāng)前已經(jīng)可以支持絕大多數(shù)應(yīng)用場景,包括之前的描述的任務(wù)日志聚合和Flink CheckPoint 路徑等場景,在接下來計劃中,我們也正在考慮是否將BlockPool層鎖做進(jìn)一步細(xì)粒度拆分,進(jìn)一步提升NameNode的性能。
同時考慮到NameNode元數(shù)據(jù)都存儲在內(nèi)存中,限制了NameNode元數(shù)據(jù)總量的擴(kuò)展,特別是小文件場景,我們也將在未來規(guī)劃引入Ozone或者將NameNode的元數(shù)據(jù)信息持久化至RocksDB或者KV中,進(jìn)一步提升單組Namespace的承載量徹底解決小文件問題。