分布式存儲在B站的應(yīng)用實踐
業(yè)務(wù)高速發(fā)展,B站的存儲系統(tǒng)如何演進以支撐指數(shù)增長的流量洪峰?隨著流量進一步暴增,如何設(shè)計一套穩(wěn)定可靠易拓展的系統(tǒng),來滿足未來進一步增長的業(yè)務(wù)訴求?同時,面對更高的可用性訴求,KV 是如何通過異地多活為應(yīng)用提供更高的可用性保障?文章的最后,會介紹一些典型業(yè)務(wù)在KV存儲的應(yīng)用實踐。
全文將圍繞下面4點展開:
- 存儲演進
- 設(shè)計實現(xiàn)
- 場景&問題
- 總結(jié)思考
01 存儲演進
首先介紹一下B站早期的存儲演進。
?
針對不同的場景,早期的KV存儲包括Redix/Memcache,Redis+MySQL,HBASE。
但是隨著B站數(shù)據(jù)量的高速增長,這種存儲選型會面臨一些問題:
- 首先,MySQL是單機存儲,一些場景數(shù)據(jù)量已經(jīng)超過 10 T,單機無法放下。當時也考慮了使用TiDB,TiDB是一種關(guān)系型數(shù)據(jù)庫,對于播放歷史這種沒有強關(guān)系的數(shù)據(jù)并不適合。
- 其次,是Redis Cluster的規(guī)模瓶頸,因為redis采用的是Gossip協(xié)議來通信傳遞信息,集群規(guī)模越大,節(jié)點間的通信開銷越大,并且節(jié)點之間狀態(tài)不一致的存留時間也會越長,很難再進行橫向擴展。
- 另外,HBase存在嚴重長尾和緩存內(nèi)存成本高的問題。
基于這些問題,我們對KV存儲提出了如下要求:
- 易拓展:100x橫向擴容;
- 高性能:低延時,高QPS;
- 高可用:長尾穩(wěn)定,故障自愈;
- 低成本:對比緩存;
- 高可靠:數(shù)據(jù)不丟。
02 設(shè)計實現(xiàn)
接下來介紹我們是如何基于上述要求進行具體實現(xiàn)的。
1. 總體架構(gòu)
總體架構(gòu)共分為三個部分Client,Node,Metaserver。Client是用戶接入端,確定了用戶的接入方式,用戶可以采用SDK的方式進行接入。Metaserver主要是存儲表的元數(shù)據(jù)信息,表分為了哪些分片,這些分片位于哪些node之上。用戶在讀寫操作的時候,只需要put、get方法,無需關(guān)注分布式實現(xiàn)的技術(shù)細節(jié)。Node的核心點就是Replica,每一張表會有多個分片,而每個分片會有多個Replica副本,通過Raft實現(xiàn)副本之間的同步復制,保證高可用。
2. 集群拓撲
?
Pool:資源池。根據(jù)不同的業(yè)務(wù)劃分,分為在線資源池和離線資源池。
Zone:可用區(qū)。主要用于故障隔離,保證每個切片的副本分布在不同的zone。
Node:存儲節(jié)點,可包含多個磁盤,存儲著Replica。
Shard:一張表數(shù)據(jù)量過大的時候可以拆分為多個Shard。拆分策略有Range,Hash。
3. Metaserver
資源管理:主要記錄集群的資源信息,包括有哪些資源池,可用區(qū),多少個節(jié)點。當創(chuàng)建表的時候,每個分片都會記錄這樣的映射關(guān)系。
元數(shù)據(jù)分布:記錄分片位于哪臺節(jié)點之上。
健康檢查:注冊所有的node信息,檢查當前node是否正常,是否有磁盤損壞?;谶@些信息可以做到故障自愈。
負載檢測:記錄磁盤使用率,CPU使用率,內(nèi)存使用率。
負載均衡:設(shè)置閾值,當達到閾值時會進行數(shù)據(jù)的重新分配。
分裂管理:數(shù)據(jù)量增大時,進行橫向擴展。
Raft選主:當有一個Metaserver掛掉的時候,可進行故障自愈。
Rocksdb:元數(shù)據(jù)信息持久化存儲。
4. Node
做為存儲模塊,主要包含后臺線程,RPC接入,抽象引擎層三個部分
?
① 后臺線程
Binlog管理,當用戶進行寫操作的時候,會記錄一條binlog日志,當發(fā)生故障的時候可以對數(shù)據(jù)進行恢復。因為本地的存儲空間有限,所以Binlog管理會將一些冷數(shù)據(jù)存放在S3,熱門的數(shù)據(jù)存放在本地。數(shù)據(jù)回收功能主要是用來防止誤刪數(shù)據(jù)。當用戶進行刪除操作,并不會真正的把數(shù)據(jù)刪除,通常是設(shè)置一個時間,比如一天,一天之后數(shù)據(jù)才會被回收。如果是誤刪數(shù)據(jù),就可以使用數(shù)據(jù)回收模塊對數(shù)據(jù)進行恢復。健康檢查會檢查節(jié)點的健康狀態(tài),比如磁盤信息,內(nèi)存是否異常,再上報給Metaserver。Compaction模塊主要是用來數(shù)據(jù)回收管理。存儲引擎Rocksdb,以LSM實現(xiàn),其特點在于寫入時是append only的形式。
RPC接入:
當集群達到一定規(guī)模后,如果沒有自動化運維,那么人工運維的成本是很高的。所以在RPC模塊加入了指標監(jiān)控,包括QPS、吞吐量、延時時間等,當出現(xiàn)問題時,會很方便排查。不同的業(yè)務(wù)的吞吐量是不同的,如何做到多用戶隔離?通過Quota管理,在業(yè)務(wù)接入的時候會申請配額,比如一張表申請了10K的QPS,當超過這個值得時候,會對用戶進行限流。不同的業(yè)務(wù)等級,會進行不同的Quota管理。
② 抽象引擎層
主要是為了應(yīng)對不同的業(yè)務(wù)場景。比如大value引擎,因為LSM存在寫放大的問題,如果數(shù)據(jù)的value特別大,頻繁的寫入會導致數(shù)據(jù)的有效寫入非常低。這些不同的引擎對于上層來說是透明的,在運行時通過選擇不同的參數(shù)就可以了。
5. 分裂-元數(shù)據(jù)更新
?
在KV存儲的時候,剛開始會根據(jù)業(yè)務(wù)規(guī)模劃分不同的分片,默認情況下單個分片是24G的大小。隨著業(yè)務(wù)數(shù)據(jù)量的增長,單個分片的數(shù)據(jù)放不下,就會對數(shù)據(jù)進行分裂。分裂的方式有兩種,rang和hash。這里我們以hash為例展開介紹:
假設(shè)一張表最開始設(shè)計了3個分片,當數(shù)據(jù)4到來,根據(jù)hash取余,應(yīng)該保存在分片1中。隨著數(shù)據(jù)的增長,3個分片放不下,則需要進行分裂,3個分片會分裂成6個分片。這個時候數(shù)據(jù)4來訪問,根據(jù)Hash會分配到分片4,如果分片4正處于分裂狀態(tài),Metaserver會對訪問進行重定向,還是訪問到原來的分片1。當分片完成,狀態(tài)變?yōu)閚ormal,就可以正常接收訪問,這一過程,用戶是無感知的。
6. 分裂-數(shù)據(jù)均衡回收
?
首先需要先將數(shù)據(jù)分裂,可以理解為本地做一個checkpoint,Rocksdb的checkpoint相當于是做了一個硬鏈接,通常1ms就可以完成數(shù)據(jù)的分裂。分裂完成后,Metaserver會同步更新元數(shù)據(jù)信息,比如0-100的數(shù)據(jù),分裂之后,分片1的50-100的數(shù)據(jù)其實是不需要的,就可以通過Compaction Filter對數(shù)據(jù)進行回收。最后將分裂后的數(shù)據(jù)分配到不同的節(jié)點上。因為整個過程都是對一批數(shù)據(jù)進行操作,而不是像redis那樣主從復制的時候一條一條復制,得益于這樣的實現(xiàn),整個分裂過程都在毫秒級別。
7. 多活容災(zāi)
?
前面提到的分裂和Metaserver來保證高可用,對某些場景仍不能滿足需求。比如整個機房的集群掛掉,這在業(yè)界多是采用多活來解決。我們KV存儲的多活也是基于Binlog來實現(xiàn),比如在云立方的機房寫入一條數(shù)據(jù),會通過Binlog同步到嘉定的機房。假如位于嘉定的機房的存儲部分掛了以后,proxy模塊會自動將流量切到云立方的機房進行讀寫操作。最極端的情況,整個機房掛掉了,就會將所有的用戶訪問集中到里一個機房,保證可用性。
03 場景&問題
接下來介紹KV在B站應(yīng)用的典型場景以及遇到的問題。
?
最典型的場景就是用戶畫像,比如推薦,就是通過用戶畫像來完成的。其他還有動態(tài)、追番、對象存儲、彈幕等都是通過KV來存儲。
1. 定制優(yōu)化
?
基于抽象實現(xiàn),可以很方便地支持不同的業(yè)務(wù)場景,并對一些特定的業(yè)務(wù)場景進行優(yōu)化。
Bulkload全量導入的場景主要是用于動態(tài)推薦以及用戶畫像。用戶畫像主要是T+1的數(shù)據(jù),在沒有使用Bulkload以前,主要是通過Hive來逐條寫入,數(shù)據(jù)鏈路很長,每天全量導入10億條數(shù)據(jù)大概需要6、7個小時。使用Bulkload之后,只需要在hive離線平臺把數(shù)據(jù)構(gòu)建成一個rocksdb引擎,hive離線平臺再把數(shù)據(jù)上傳到對象存儲。上傳完成之后通知KV來進行拉取,拉取完成后就可以進行本地的Bulkload,時間可以縮短到10分鐘以內(nèi)。
另一個場景就是定長list。大家可能發(fā)現(xiàn)你的播放歷史只有3000條,動態(tài)也只有3000條。因為歷史記錄是非常大的,不能無限存儲。最早是通過一個腳本,對歷史數(shù)據(jù)進行刪除,為了解決這個問題,我們開發(fā)了一個定制化引擎,保存一個定長的list,用戶只需要往里面寫入,當超過定長的長度時,引擎會自動刪除。
2. 面臨問題——存儲引擎
前面提到的compaction,在實際使用的過程中,也碰到了一些問題,主要是存儲引擎和raft方面的問題。存儲引擎方面主要是Rocksdb的問題。第一個就是數(shù)據(jù)淘汰,在數(shù)據(jù)寫入的時候,會通過不同的Compaction往下推。我們的播放歷史,會設(shè)置一個過期時間。超過了過期時間之后,假設(shè)數(shù)據(jù)現(xiàn)在位于L3層,在L3層沒滿的時候是不會觸發(fā)Compaction的,數(shù)據(jù)也不會被刪除。為了解決這個問題,我們就設(shè)置了一個定期的Compaction,在Compaction的時候回去檢查這個Key是否過期,過期的話就會把這條數(shù)據(jù)刪除。
另一個問題就是DEL導致SCAN慢查詢的問題。因為LSM進行delete的時候要一條一條地掃,有很多key。比如20-40之間的key被刪掉了,但是LSM刪除數(shù)據(jù)的時候不會真正地進行物理刪除,而是做一個delete的標識。刪除之后做SCAN,會讀到很多的臟數(shù)據(jù),要把這些臟數(shù)據(jù)過濾掉,當delete非常多的時候,會導致SCAN非常慢。為了解決這個問題,主要用了兩個方案。第一個就是設(shè)置刪除閾值,超過閾值的時候,會強制觸發(fā)Compaction,把這些delete標識的數(shù)據(jù)刪除掉。但是這樣也會產(chǎn)生寫放大的問題,比如有L1層的數(shù)據(jù)進行了刪除,刪除的時候會觸發(fā)一個Compaction,L1的文件會帶上一整層的L2文件進行Compaction,這樣會帶來非常大的寫放大的問題。為了解決寫放大,我們加入了一個延時刪除,在SCAN的時候,會統(tǒng)計一個指標,記錄當前刪除的數(shù)據(jù)占所有數(shù)據(jù)的比例,根據(jù)這個反饋值去觸發(fā)Compaction。
第三個是大Value寫入放大的問題,目前業(yè)內(nèi)的解決辦法都是通過KV存儲分離來實現(xiàn)的。我們也是這樣解決的。
3. 面臨問題——Raft
?
Raft層面的問題有兩個:
首先,我們的Raft是三副本,在一個副本掛掉的情況下,另外兩個副本可以提供服務(wù)。但是在極端情況下,超過半數(shù)的副本掛掉,雖然概率很低,但是我們還是做了一些操作,在故障發(fā)生的時候,縮短系統(tǒng)恢復的時間。我們采用的方法就是降副本,比如三個副本掛了兩個,會通過后臺的一個腳本將集群自動降為單副本模式,這樣依然可以正常提供服務(wù)。同時會在后臺啟動一個進程對副本進行恢復,恢復完成后重新設(shè)置為多副本模式,大大縮短了故障恢復時間。
另一個是日志刷盤問題。比如點贊、動態(tài)的場景,value其實非常小,但是吞吐量非常高,這種場景會帶來很嚴重的寫放大問題。我們用磁盤,默認都是4k寫盤,如果每次的value都是幾十個字節(jié),這樣會造成很大的磁盤浪費?;谶@樣的問題,我們會做一個聚合刷盤,首先會設(shè)置一個閾值,當寫入多少條,或者寫入量超過多少k,進行批量刷盤,這個批量刷盤可以使吞吐量提升2~3倍。
04 總結(jié)思考
?
1. 應(yīng)用
應(yīng)用方面,我們會做KV與緩存的融合。因為業(yè)務(wù)開發(fā)不太了解KV與緩存資源的情況,融合之后就不需要再去考慮是使用KV還是緩存。
另一個應(yīng)用方面的改進是支持Sentinel模式,進一步降低副本成本。
2. 運維
運維方面,一個問題就是慢節(jié)點檢測,我們可以檢測到故障節(jié)點,但是慢節(jié)點怎么檢測呢,目前在業(yè)界也是一個難題,也是我們今后要努力的方向。
另一個問題就是自動剔盤均衡,磁盤發(fā)生故障后,目前的方法是第二天看一些報警事項,再人工操作一下。我們希望做成一個自動化機制。
3. 系統(tǒng)
系統(tǒng)層面就是SPDK、DPDK方面的性能優(yōu)化,通過這些優(yōu)化,進一步提升KV進程的吞吐。