題目
設(shè)計(jì)實(shí)現(xiàn)關(guān)注评甜,粉絲列表,且有分頁(yè)功能
每天1億的日活量用戶
100萬(wàn)的關(guān)注和取關(guān)修改量
簡(jiǎn)介
關(guān)系鏈指的是用戶A與用戶B的關(guān)注關(guān)系。以關(guān)注屬性細(xì)分饲常,以關(guān)注(訂閱)為主,還涉及拉黑狼讨、悄悄關(guān)注贝淤、互相關(guān)注、特別關(guān)注等多種屬性或狀態(tài)
關(guān)系鏈狀態(tài)機(jī)
A和B的狀態(tài)就5種
數(shù)據(jù)量少的時(shí)候
一張關(guān)系表政供、一張計(jì)數(shù)表即可滿足線上使用
關(guān)系表的結(jié)構(gòu)示例
計(jì)數(shù)表的結(jié)構(gòu)示例
一次mid新增關(guān)注fid請(qǐng)求為例
查詢mid的計(jì)數(shù)并加鎖播聪,查詢fid的計(jì)數(shù)并加鎖;
查詢mid->fid的關(guān)系并加鎖鲫骗,查詢fid->mid的關(guān)系并加鎖犬耻;
根據(jù)狀態(tài)機(jī),在內(nèi)存計(jì)算新關(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)的缺點(diǎn)
中規(guī)模可以先分表
一方面沦寂,即使做了分表学密,關(guān)系鏈數(shù)據(jù)整體規(guī)模仍然超出了建議的整體存儲(chǔ)容量(TB級(jí));
另一方面传藏,繁重的事務(wù)導(dǎo)致mysql無(wú)法支撐很高的寫(xiě)流量腻暮,在原始的同步寫(xiě)架構(gòu)下,表現(xiàn)就是關(guān)注失敗率變高毯侦;如果只是單純地升級(jí)到異步寫(xiě)架構(gòu)哭靖,表現(xiàn)就是消息積壓,當(dāng)消息積壓持續(xù)時(shí)間超過(guò)臨時(shí)緩存有效期時(shí)侈离,會(huì)引起客訴试幽,治標(biāo)不治本。
使用mysql作為核心存儲(chǔ)的“同步”寫(xiě)關(guān)系流程圖
升級(jí)架構(gòu)
數(shù)據(jù)存儲(chǔ)從mysql遷移到KV存儲(chǔ)卦碾,邏輯方面把”同步寫(xiě)mysql“改為”異步寫(xiě)KV“铺坞。未選擇”同步寫(xiě)KV“的原因,一方面是一條關(guān)系對(duì)應(yīng)著多條KV記錄洲胖,而KV不支持事務(wù)济榨;另一方面是異步的架構(gòu)可以扛住可能存在的瞬時(shí)大量關(guān)注請(qǐng)求。為了兼容訂閱了mysql binlog的業(yè)務(wù)宾濒,在“異步寫(xiě)KV”之后還會(huì)”異步寫(xiě)mysql“腿短。
在新架構(gòu)下,對(duì)于每一次用戶關(guān)注請(qǐng)求绘梦,投遞databus(異步消息)即視為請(qǐng)求成功,mysql binlog只提供給一些對(duì)實(shí)時(shí)性不太敏感的業(yè)務(wù)方(如數(shù)據(jù)平臺(tái))赴魁,所以對(duì)于異步寫(xiě)mysql事件的偶爾的輕微積壓我們并不需要關(guān)心卸奉,而對(duì)實(shí)時(shí)性要求比較高的業(yè)務(wù)方,我們?cè)谔幚硗戤惒綄?xiě)KV事件后颖御,會(huì)投遞了databus供這些業(yè)務(wù)訂閱榄棵。
KV存儲(chǔ)最大的優(yōu)勢(shì)在于,底層能提供計(jì)數(shù)(count)方法以替代冗余的mysql計(jì)數(shù)表潘拱,這樣的好處是疹鳄,我們只需要維護(hù)一張單純保存關(guān)系的KV表即可。我們?cè)O(shè)計(jì)的存儲(chǔ)結(jié)構(gòu)是:
key為{attr|mid}fid芦岂,attr為關(guān)系拉鏈類型瘪弓,mid和fid都表示用戶id,{attr|mid}表示拼接attr和mid作為hash禽最,該hash下的多個(gè)fid將按照字典序存儲(chǔ)腺怯,結(jié)合KV服務(wù)提供的拉鏈遍歷方法(scan)以什么開(kāi)頭的
比如{ATTR_FOLLOW|11}hash出來(lái)是AAAA
則查11的關(guān)注列表就是遍歷AAAA% 袱饭,可以獲取該hash下的所有的fid;
value為結(jié)構(gòu)體呛占,包含了attribute(關(guān)系屬性)和mtime(修改時(shí)間)虑乖;
key的解釋 (attr和attribute容易混淆)
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)注了)。
對(duì)用戶來(lái)說(shuō)坠狡,各類列表和關(guān)系鏈類型的映射關(guān)系如下:
關(guān)注列表:根據(jù)產(chǎn)品需求的不同继找,大部分時(shí)候指關(guān)注類關(guān)系鏈(attr=ATTR_FOLLOW),有些場(chǎng)景也會(huì)加上悄悄關(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)系拉鏈如圖:
升級(jí)到KV存儲(chǔ)后讀操作
如果要正向查詢mid與fid的關(guān)系啃沪,只需要點(diǎn)查(get粘拾、batch_get)遍歷3種正向attr;
如果要查詢?nèi)筷P(guān)注關(guān)系创千、黑名單缰雇,只需要找對(duì)應(yīng)attr分別執(zhí)行scan;
如果要查詢用戶計(jì)數(shù)追驴,只需要count對(duì)應(yīng)的attr即可械哟;
升級(jí)到KV存儲(chǔ)后寫(xiě)操作
mysql有事務(wù)來(lái)保證原子性,而kv存儲(chǔ)并不支持事務(wù)殿雪。而對(duì)于用戶的請(qǐng)求而言暇咆,投遞databus即算關(guān)注成功,那么在異步處理這條消息時(shí)冠摄,需要100%保障成功寫(xiě)入糯崎,因此我們?cè)谔幚懋惒较r(shí)几缭,對(duì)每個(gè)寫(xiě)入操作都加上了失敗無(wú)限重試的邏輯。
極端情況下沃呢,還可能會(huì)遇到寫(xiě)入沖突問(wèn)題:比如某個(gè)時(shí)間點(diǎn)用戶A關(guān)注了用戶B年栓,”同時(shí)“用戶B關(guān)注了用戶A,此時(shí)就可能會(huì)引發(fā)一些意想不到的數(shù)據(jù)錯(cuò)誤(因?yàn)閱蜗蜿P(guān)注和互關(guān)是兩個(gè)不同的屬性薄霜,任一方的關(guān)注行為都會(huì)影響這個(gè)屬性)某抓。為了避免這種情況出現(xiàn),我們利用了消息隊(duì)列同一個(gè)key下數(shù)據(jù)的有序特性惰瓜,通過(guò)保證同一對(duì)用戶分配到一個(gè)key否副,保證了同一對(duì)用戶的操作是有序執(zhí)行的。
還是以mid新增關(guān)注fid為例崎坊,對(duì)于每一條關(guān)注事件:
job需要先put一條正向關(guān)注關(guān)系备禀,然后進(jìn)行上限校驗(yàn),如果超過(guò)上限那么回滾退出奈揍;
然后批量put因本次關(guān)注動(dòng)作所影響的所有其他反向attr曲尸,比如mid的被關(guān)注關(guān)系(attr=ATTR_WHISPERED)、fid的關(guān)注關(guān)系(attr=ATTR_FOLLOW男翰,若有另患,比如由單向關(guān)注變?yōu)榛リP(guān));
上述任何一個(gè)put操作失敗了蛾绎,都需要重試昆箕;直到這些動(dòng)作都完成了,那么認(rèn)為此次關(guān)注事件成功租冠;
投遞databus鹏倘,告知訂閱方發(fā)生了關(guān)注事件;
投遞異步寫(xiě)mysql事件肺稀,把關(guān)注事件同步mysql第股,產(chǎn)出binlog供訂閱方使用。
全量關(guān)注列表话原、全量黑名單
線上查詢請(qǐng)求中,有一定比例是查詢?nèi)筷P(guān)注列表诲锹、全量黑名單繁仁。上一節(jié)中提到,為了不冗余存儲(chǔ)一份關(guān)系鏈計(jì)數(shù)归园,KV的存儲(chǔ)設(shè)計(jì)得比較特殊黄虱,一個(gè)用戶的正向關(guān)注關(guān)系分布在3個(gè)不同的attr(即3個(gè)不同的關(guān)系拉鏈)里。如果想從KV存儲(chǔ)拉取一個(gè)用戶的全量關(guān)系列表庸诱,那么同時(shí)需要分別對(duì)3種正向關(guān)系拉鏈都做循環(huán)scan(因?yàn)槊看蝧can有數(shù)量上限)捻浦,但由于scan方法性能相對(duì)較差晤揣,所以需要在KV存儲(chǔ)的上層加一套緩存,通過(guò)降低回源比例嚴(yán)格控制scan QPS朱灿。
鑒于memcached對(duì)大key有比較好的性能昧识,前人在KV存儲(chǔ)的上層加了一個(gè)memcached緩存,用于存儲(chǔ)用戶的全量關(guān)系列表盗扒,具體業(yè)務(wù)流程如下
Memcached是一個(gè)開(kāi)源的高性能分布式內(nèi)存對(duì)象緩存系統(tǒng)跪楞。它可以幫助應(yīng)用程序加快訪問(wèn)數(shù)據(jù)庫(kù)、API調(diào)用或其他耗時(shí)操作的速度侣灶,通過(guò)將數(shù)據(jù)存儲(chǔ)在內(nèi)存中甸祭,避免了頻繁的磁盤(pán)讀寫(xiě)操作。
Memcached使用簡(jiǎn)單的鍵值對(duì)存儲(chǔ)模型褥影,可以將數(shù)據(jù)以鍵值對(duì)的形式存儲(chǔ)在內(nèi)存中池户,并提供快速的讀寫(xiě)訪問(wèn)。它常用于緩存常用的數(shù)據(jù)庫(kù)查詢結(jié)果凡怎、網(wǎng)頁(yè)內(nèi)容校焦、API響應(yīng)等。
Memcached是一個(gè)分布式系統(tǒng)栅贴,它可以在多臺(tái)服務(wù)器上運(yùn)行斟湃,并通過(guò)哈希算法將數(shù)據(jù)分散存儲(chǔ)在不同的節(jié)點(diǎn)上。這樣可以提高系統(tǒng)的可擴(kuò)展性和容錯(cuò)性檐薯,確保即使某個(gè)節(jié)點(diǎn)失效凝赛,數(shù)據(jù)仍然可用。
Memcached支持多種編程語(yǔ)言坛缕,并且有許多客戶端庫(kù)可供開(kāi)發(fā)人員使用墓猎。它被廣泛應(yīng)用于Web開(kāi)發(fā)、緩存加速赚楚、數(shù)據(jù)分析等領(lǐng)域毙沾,為應(yīng)用程序提供了高速的數(shù)據(jù)訪問(wèn)能力。
查詢用戶和其他一個(gè)或多個(gè)用戶的關(guān)系
如果每次都從memcached拉全量關(guān)系列表然后內(nèi)存中取交集宠页,網(wǎng)絡(luò)的開(kāi)銷會(huì)非常大左胞,因此對(duì)這種查詢場(chǎng)景,也需要設(shè)計(jì)一套適用于點(diǎn)查的緩存举户。
活躍用戶的關(guān)注數(shù)一般都在幾十到幾百的區(qū)間烤宙,用于點(diǎn)查的緩存不需要嚴(yán)格有序,但要支持指定hashkey的查詢俭嘁,redis hash和其提供的hget躺枕、hmget、hset、hmset方法都是非常適合這一場(chǎng)景的拐云。因此查詢層緩存設(shè)計(jì)如下:key為mid罢猪,hashkey為mid有關(guān)系的每一個(gè)用戶id,value為他們的關(guān)系數(shù)據(jù)叉瘩,和前面midA在KV存儲(chǔ)的數(shù)據(jù)對(duì)應(yīng)如下:
Redis中的Hash是一種用于存儲(chǔ)鍵值對(duì)的數(shù)據(jù)結(jié)構(gòu)膳帕,其中鍵是唯一的,值可以是字符串房揭、數(shù)字等备闲。Hash是一種適用于存儲(chǔ)對(duì)象的數(shù)據(jù)結(jié)構(gòu),可以實(shí)現(xiàn)對(duì)對(duì)象的部分修改和讀取捅暴。
Redis提供了一系列操作Hash的命令恬砂,包括hget、hmget蓬痒、hset泻骤、hmset等。
- hget命令用于獲取Hash中指定字段的值:
Jedis jedis = new Jedis("localhost");
String value = jedis.hget("hashKey", "field");
其中梧奢,"hashKey"是Hash的鍵狱掂,"field"是要獲取的字段名,返回的是字段對(duì)應(yīng)的值亲轨。- hmget命令用于獲取Hash中多個(gè)字段的值:
Jedis jedis = new Jedis("localhost");
List<String> values = jedis.hmget("hashKey", "field1", "field2");
其中趋惨,"hashKey"是Hash的鍵,"field1"惦蚊、"field2"是要獲取的字段名器虾,返回的是一個(gè)列表,包含了字段對(duì)應(yīng)的值蹦锋。- hset命令用于設(shè)置Hash中指定字段的值:
Jedis jedis = new Jedis("localhost");
Long result = jedis.hset("hashKey", "field", "value");
其中兆沙,"hashKey"是Hash的鍵,"field"是要設(shè)置的字段名莉掂,"value"是要設(shè)置的字段值葛圃,返回的是設(shè)置操作的結(jié)果(1表示成功,0表示字段已存在)憎妙。- hmset命令用于設(shè)置Hash中多個(gè)字段的值:
Jedis jedis = new Jedis("localhost");
String result = jedis.hmset("hashKey", Map.of("field1", "value1", "field2", "value2"));
其中库正,"hashKey"是Hash的鍵,Map中的鍵值對(duì)表示要設(shè)置的字段和對(duì)應(yīng)的值厘唾,返回的是設(shè)置操作的結(jié)果诀诊。
需要注意的是,以上示例中使用的是Jedis客戶端進(jìn)行操作阅嘶,你可以根據(jù)自己的需求選擇其他的Redis客戶端庫(kù)。
redis hash緩存中關(guān)注關(guān)系的存儲(chǔ)結(jié)構(gòu)
由于hash里保存的是midA的全部正向關(guān)注關(guān)系,當(dāng)緩存miss需要回源時(shí)讯柔,要獲取全量關(guān)注關(guān)系抡蛙,可以和前面的memcached配合使用,業(yè)務(wù)流程如下:
熱點(diǎn)場(chǎng)景
問(wèn)題根因:集中在Redis的少數(shù)幾臺(tái)實(shí)例上魂迄,木桶短板效應(yīng)就會(huì)非常明顯粗截。
對(duì)于熱點(diǎn)用戶,在請(qǐng)求Redis前會(huì)先查詢本地Localcache(Localcache中存儲(chǔ)的up主關(guān)系列表數(shù)據(jù)捣炬,隔十幾秒更新一次)熊昌。雖然在這十幾秒內(nèi)可能會(huì)存在數(shù)據(jù)不一致的情況,但從實(shí)際業(yè)務(wù)角度看湿酸,引發(fā)熱點(diǎn)請(qǐng)求的都是大up主婿屹,這些up主的關(guān)系列表較少發(fā)生變動(dòng),因此幾乎不會(huì)對(duì)用戶體驗(yàn)造成影響推溃。