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