百億數據百萬查詢—關系鏈架構演進
一、關系鏈業務簡介
從主站業務角度來看,關系鏈指的是用戶A與用戶B的關注關系。以關注屬性細分,以關注(訂閱)為主,還涉及拉黑、悄悄關注、互相關注、特別關注等多種屬性或狀態。目前主站關系鏈量級較大,且還以較快速度持續增長。作為一個平臺型的業務,關系鏈服務對外提供一對多關系點查、全量關系列表、關系計數等基礎查詢,綜合查詢峰值QPS近百萬,被動態、評論等核心業務依賴。
在持續增長的數據量和查詢請求的趨勢下,保證數據的實時準確、保持服務高可用是關系鏈架構演進的核心目標。
(圖:關系鏈在空間頁的業務場景)
(圖:關系鏈狀態機)
二、事務瓶頸——存儲的演進
關系型數據庫
關注的寫事件對應的就是單純的狀態屬性流轉,所以使用關系型數據庫是非常適合的。在主站社區發展的早期,關系鏈量級較少,直接使用mysql有著天然的優勢:開發維護簡單、邏輯清晰,只需要直接維護一張關系表、一張計數表即可滿足線上使用。考慮到社區的發展速度,前人的設計分別采用了分500表(關系表)和50表(計數表)來分散壓力。
(圖:關系表的結構示例)
(圖:計數表的結構示例)
在這種存儲結構下,以一次mid新增關注fid請求為例,mysql需要以事務執行下述操作:
- 查詢mid的計數并加鎖,查詢fid的計數并加鎖;
- 查詢mid->fid的關系并加鎖,查詢fid->mid的關系并加鎖;
- 根據狀態機,在內存計算新關系后,修改mid->fid的關系,修改fid->mid的關系(若有,比如由單向關注變為互關);
- 修改mid的關注數,修改fid的粉絲數;
這種架構一直保持到2021年,隨著社區的不斷發展壯大,架構的缺點也日益顯現:一方面,即使做了分表,關系鏈數據整體規模仍然超出了建議的整體存儲容量(目前已經TB級);另一方面,繁重的事務導致mysql無法支撐很高的寫流量,在原始的同步寫架構下,表現就是關注失敗率變高;如果只是單純地升級到異步寫架構,表現就是消息積壓,當消息積壓持續時間超過臨時緩存有效期時,會引起客訴,治標不治本。
(圖:使用mysql作為核心存儲的“同步”寫關系流程圖)
KV存儲
*關于B站自研分布式KV存儲的介紹可以參閱:B站分布式KV存儲實踐
最終決定使用的升級方案是:數據存儲從mysql遷移到KV存儲,邏輯方面把”同步寫mysql“改為”異步寫KV“。未選擇”同步寫KV“的原因,一方面是一條關系對應著多條KV記錄,而KV不支持事務;另一方面是異步的架構可以扛住可能存在的瞬時大量關注請求。為了兼容訂閱了mysql binlog的業務,在“異步寫KV”之后還會”異步寫mysql“。
在新架構下,對于每一次用戶關注請求,投遞databus即視為請求成功,mysql binlog只提供給一些對實時性不太敏感的業務方(如數據平臺),所以對于異步寫mysql事件的偶爾的輕微積壓我們并不需要關心,而對實時性要求比較高的業務方,我們在處理完異步寫KV事件后,會投遞了databus供這些業務訂閱。
(圖:使用KV作為核心存儲、mysql冗余存儲的異步寫關系流程圖)
KV存儲最大的優勢在于,底層能提供計數(count)方法以替代冗余的mysql計數表,這樣的好處是,我們只需要維護一張單純保存關系的KV表即可。我們設計的存儲結構是:
- key為{attr|mid}fid,attr為關系拉鏈類型,mid和fid都表示用戶id,{attr|mid}表示拼接attr和mid作為hash,該hash下的多個fid將按照字典序存儲,結合KV服務提供的拉鏈遍歷方法(scan),可以獲取該hash下的所有的fid;
- value為結構體,包含了attribute(關系屬性)和mtime(修改時間);
attr和attribute容易混淆,兩者的區別如下:
- key中的attr為關系拉鏈類型,一共有5類(3類正向關系,2類反向關系):ATTR_WHISPER表示悄悄關注類(mid悄悄關注了fid),ATTR_FOLLOW表示關注類(mid關注了fid),ATTR_BLACK表示拉黑類(mid拉黑了fid),ATTR_WHISPERED表示被悄悄關注類(mid被fid悄悄關注了),ATTR_FOLLOWED表示被關注類(mid被fid關注了)。對用戶來說,各類列表和關系鏈類型的映射關系如下:
關注列表:根據產品需求的不同,大部分時候指關注類關系鏈(attr=ATTR_FOLLOW),有些場景也會加上悄悄關注類關系鏈(attr=ATTR_WHISPER);
粉絲列表:被悄悄關注類關系鏈(attr=ATTR_WHISPERED)和被關注類關系鏈(attr=ATTR_FOLLOWED)的合集;
黑名單列表:拉黑類關系鏈(attr=ATTR_BLACK)。
- value中的attribute表示當前的關系屬性,一共有4種:WHISPER表示悄悄關注、FOLLOW表示關注、FRIEND表示互相關注、BLACK表示拉黑。這里和前文的attr較易混淆,它們之間完整的映射關系如下:
attr=ATTR_WHISPER或ATTR_WHISPERED下可以有attribute=WHISPER;
attr=ATTR_FOLLOW或ATTR_FOLLOWED下可以有attribute=FOLLOW或者FRIEND;
attr=ATTR_BLACK下可以有attribute=BLACK。
midA的五種關系拉鏈如圖:
(圖:midA的五種關系拉鏈)
綜上所述,升級到KV存儲后,讀操作相對來說并沒有很復雜:
- 如果要正向查詢mid與fid的關系,只需要點查(get、batch_get)遍歷3種正向attr;
- 如果要查詢全量關注關系、黑名單,只需要找對應attr分別執行scan;
- 如果要查詢用戶計數,只需要count對應的attr即可;
稍微復雜一點的邏輯在于關系的寫入:
- mysql有事務來保證原子性,而kv存儲并不支持事務。而對于用戶的請求而言,投遞databus即算關注成功,那么在異步處理這條消息時,需要100%保障成功寫入,因此我們在處理異步消息時,對每個寫入操作都加上了失敗無限重試的邏輯。
- 極端情況下,還可能會遇到寫入沖突問題:比如某個時間點用戶A關注了用戶B,”同時“用戶B關注了用戶A,此時就可能會引發一些意想不到的數據錯誤(因為單向關注和互關是兩個不同的屬性,任一方的關注行為都會影響這個屬性)。為了避免這種情況出現,我們利用了消息隊列同一個key下數據的有序特性,通過保證同一對用戶分配到一個key,保證了同一對用戶的操作是有序執行的。
還是以mid新增關注fid為例,對于每一條關注事件:
- job需要先put一條正向關注關系,然后進行上限校驗,如果超過上限那么回滾退出;
- 然后批量put因本次關注動作所影響的所有其他反向attr,比如mid的被關注關系(attr=ATTR_WHISPERED)、fid的關注關系(attr=ATTR_FOLLOW,若有,比如由單向關注變為互關);
- 上述任何一個put操作失敗了,都需要重試;直到這些動作都完成了,那么認為此次關注事件成功;
- 投遞databus,告知訂閱方發生了關注事件;
- 投遞異步寫mysql事件,把關注事件同步mysql,產出binlog供訂閱方使用。
三、快速增長——緩存的迭代
存儲層緩存memcached
線上查詢請求中,有一定比例是查詢全量關注列表、全量黑名單。上一節中提到,為了不冗余存儲一份關系鏈計數,KV的存儲設計得比較特殊,一個用戶的正向關注關系分布在3個不同的attr(即3個不同的關系拉鏈)里。如果想從KV存儲拉取一個用戶的全量關系列表,那么同時需要分別對3種正向關系拉鏈都做循環scan(因為每次scan有數量上限),但由于scan方法性能相對較差,所以需要在KV存儲的上層加一套緩存,通過降低回源比例嚴格控制scan QPS。
鑒于memcached對大key有比較好的性能,前人在KV存儲的上層加了一個memcached緩存,用于存儲用戶的全量關系列表,具體業務流程如下:
(圖:全量關系列表的查詢業務流程)
從高峰時期的緩存回源數據來看,memcached為KV存儲抵擋住了97%-99%的請求,只有不到6K的QPS會miss緩存,效果比較明顯。
(圖:memcached的QPS和緩存回源率)
查詢層緩存hash
除了關注列表的請求外,很大一部分請求是一對多的點查關系(查詢用戶和其他一個或多個用戶的關系),如果每次都從memcached拉全量關系列表然后內存中取交集,網絡的開銷會非常大,因此對這種查詢場景,也需要設計一套適用于點查的緩存。
活躍用戶的關注數一般都在幾十到幾百的區間,用于點查的緩存不需要嚴格有序,但要支持指定hashkey的查詢,redis hash和其提供的hget、hmget、hset、hmset方法都是非常適合這一場景的。因此查詢層緩存設計如下:key為mid,hashkey為mid有關系的每一個用戶id,value為他們的關系數據,和前面midA在KV存儲的數據對應如下:
(圖:redis hash緩存中關注關系的存儲結構)
由于hash里保存的是midA的全部正向關注關系,當緩存miss需要回源時,要獲取全量關注關系,可以和前面的memcached配合使用,業務流程如下:
(圖:redis hash架構下的一對多關系的查詢業務流程)
基于這套緩存,點查一對一、一對多關注關系的接口耗時平均基本維持在1ms,且hash的命中率能達到70%-75%,因此目前能比較輕松地支持近百萬的QPS,并隨著redis集群的橫向擴展,可以支持更多的業務請求。
(圖:redis hash緩存的QPS和緩存回源率)
查詢層緩存kv(一次看似失敗的嘗試)
到2022年下半年,一方面產品提出“我關注的xx也關注了ta”需求,此類二度關系的查詢在hash架構下是非常吃力的:
- 由于hash只存儲了正向的關系查詢,需要先獲取”我“的關注列表,然后遍歷查詢關注列表中每個人和ta的關注關系;
- 由于”我“的關注列表中很多是非活躍用戶,因此很難命中hash、memcached緩存,也就意味著每次請求都會批量并發回源KV存儲。再加上推薦側能留給關系鏈服務計算的時間非常短,當這一次請求超時被cancel,屬于這次請求的回源KV存儲的scan操作就會被全部cancel,所在實例就會觸發rpc熔斷事件告警,帶來了大量的告警噪音(因為即使只是一個請求超時,rpc錯誤量是那一個請求下并發回源scan的個數)。
(圖:切換架構前后的KV存儲 scan操作RPC錯誤數情況)
另一方面,產品提出放開關注上限的想法,我們考慮在此類需求上線后,高關注量的用戶會越來越多,甚至部分用戶會在功能上線后迅速把關注拉滿,hash結構的缺陷和風險也會日漸顯現。其風險點在于:當同一個redis實例有多個高關注量的用戶miss緩存、觸發回源、hmset回填緩存時,持續性的高寫入QPS可能會讓redis cpu利用率打滿(比如每秒2個用戶需要回填緩存,且他們的關系列表5000個,實際寫入QPS是1萬)。
在上述大背景下,經團隊內部討論,我們先引入了redis kv結構緩存,希望能一步到位、通過簡單緩存直接替換hash,key為用戶A和用戶B的用戶id,value為用戶A與用戶B的關系,示例如下:
(圖:redis kv緩存中關注關系的存儲結構)
在這個緩存結構下,回源KV存儲就只需要點查了,因為KV存儲點查操作(get、batch_get)的性能遠遠好于scan操作,同時為了減少對memcached的依賴,因此當redis kv緩存miss時,我們直接回源KV存儲執行點查(get、batch_get),然后回填緩存,流程圖如下:
(圖:redis kv架構下的一對多關系的查詢業務流程)
我們灰度了2%的用戶,發現kv結構緩存的命中率逐漸收斂在60%,而且緩存內存的使用率和key的數量卻遠遠超出預期。這意味著有40%的請求會miss緩存并回源到kv,在百萬QPS的壓力下,這明顯是不能接受的。分析miss緩存的請求后,我們發現主要的業務來源是評論,其大部分請求的返回是“無關系”,即評論場景會查詢大量陌生人的關注關系,那么空哨兵會特別多且大部分不會被二次訪問到(對于一個用戶而言,空哨兵的數量可以認為是他看的評論用戶數),這也就能對單kv結構緩存的表現做出合理解釋了。
查詢層緩存bloom_filter+kv
對于大量空哨兵場景,在上面套一層布隆過濾器是一個公認比較合理的方案。我們決定對每一個用戶維護一個布隆過濾器,先把存量的所有關系鏈都添加到布隆過濾器,并消費新的寫關系事件并更新布隆過濾器,使其作為一個常駐緩存過濾器。命中布隆過濾器有三種可能:
- 現在有關系
- 曾經有過關系,但現在沒關系
- 一直沒有過關系,但哈希碰撞到前面兩種情況
命中布隆過濾器的才會走到下層的kv緩存,這樣就解決了絕大部分空哨兵的問題了,具體流程圖如下:
(圖:bloom filter + redis kv架構下的一對多關系的查詢業務流程)
目前關系鏈場景已經100%流量切到布隆的新架構下,布隆的命中率達到了80%+,hash的老架構正在灰度下線,這一技改不僅解決了關系鏈上限放開可能帶來的問題、二度關系告警噪音問題、難以支持類似”多對一“的反向查詢的問題,預計還能節省一部分緩存資源。
四、風險來臨——熱點的容災
關系鏈的主要場景還是查詢“用戶A”與其他用戶的關注關系。在同一時刻的請求里,當“用戶A”的請求分散時,對Redis的壓力會被均攤到集群的幾十臺實例上,此時系統所能承受的最大壓力等于集群中每一個實例之和;而在極端情況下,如果“用戶A”集中在少數幾個用戶上,那么壓力都會集中在Redis的少數幾臺實例上,木桶短板效應就會非常明顯。
回到去年的某次熱點場景,流量都集中在環球網等熱門up的動態詳情頁或稿件播放頁上,而這些頁面依賴實時查詢up主與各個評論人的關注關系,當同一時刻大量用戶加載評論時,即形成了該up主的查詢熱點。
當時關系鏈服務的架構對于熱點的處理是相對滯后的,當發現熱點up主(或已在事前知曉熱點up主)時,會手動將其配置到熱點名單中。對于熱點用戶,在請求Redis前會先查詢本地Localcache(Localcache中存儲的up主關系列表數據,隔十幾秒更新一次)。雖然在這十幾秒內可能會存在數據不一致的情況,但從實際業務角度看,引發熱點請求的都是大up主,這些up主的關系列表較少發生變動,因此幾乎不會對用戶體驗造成影響。
當天晚上熱點請求發生時,隨著用戶的增長,Redis集群幾個實例的CPU使用率逐步突破了70%,個別實例甚至突破了90%。
(圖:熱點事件當時的Redis單實例CPU使用率告警)
由于缺少熱點探測能力,運維人員看到告警后,需要人工抓取當前的熱點Key(在Redis實例CPU利用率已經幾乎被打滿的前提下,直連實例統計Key是一個高風險的操作),然后手動配置入庫,隨后Redis的壓力就直線下降。為了避免可能存在的風險,后續又逐步把其他官媒號臨時加到熱點用戶名單中,關系鏈服務算是有驚無險地度過此次流量高峰。
(圖:配置本地緩存前后單實例Redis QPS)
事后,業務架構提供了熱點檢測工具,接入后能基于配置的閾值,自動地統計熱點并臨時性地使用本地緩存;在今年年初,熱點檢測工具和本地緩存sdk融合在了一起(*另一個本地緩存例子可以看這篇文章:B站動態outbox本地緩存優化),熱點自動檢測與自動降級變得更加便捷,業務側只需要簡單修改本地緩存類型,即可低代碼擁有防熱點能力。經過英雄聯盟S12和拜年祭的驗證,關系鏈服務在上述活動期間各指標都比較平穩。
(圖:某天中午被自動感知到并緩存的熱key數量監控)
五、長遠規劃——關系的延伸
如何使用關系鏈能力賦能上層業務、如何讓關系鏈基礎服務更加靠譜,也是我們持續需要思考的問題,中期來看,還是有很多方向可以發力,這里僅列出幾個方向:
- 賦能業務:以多租戶的方式,通過關系鏈服務現有的一套代碼,可以提供基礎關系能力(關注/訂閱、取消關注/訂閱、關注列表、粉絲列表)給新的業務體系快速接入,避免二次開發。
- 賦能社區:如何讓關系鏈這個平臺服務更通用,可以嘗試把關系的對象泛化,比如在動態feed場景,整合用戶的泛訂閱關系場景(如up主、合集、漫畫、番劇、課堂等)。
- 穩定性提升:關系鏈服務接入業務方眾多,通過0信任、100%配置quota的方式,避免業務間互相干擾,尤其避免普通業務流量暴漲影響核心業務。
參考閱讀
- B站分布式KV存儲實踐
- B站動態outbox本地緩存優化
- TAO-Facebook的社交圖分布式數據存儲:https://www.usenix.org/system/files/conference/atc13/atc13-bronson.pdf
本期作者
劉鎏
嗶哩嗶哩高級開發工程師