如何設(shè)計(jì)一個(gè)億級(jí)消息量的 IM 系統(tǒng)
來(lái)源:https://xie.infoq.cn/article/19e95a78e2f5389588debfb1c
IM核心概念
用戶(hù) :系統(tǒng)的使用者
消息 :是指用戶(hù)之間的溝通內(nèi)容。通常在IM系統(tǒng)中,消息會(huì)有以下幾類(lèi):文本消息、表情消息、圖片消息、視頻消息、文件消息等等
會(huì)話(huà) :通常指兩個(gè)用戶(hù)之間因聊天而建立起的關(guān)聯(lián)
群 :通常指多個(gè)用戶(hù)之間因聊天而建立起的關(guān)聯(lián)
終端 :指用戶(hù)使用IM系統(tǒng)的機(jī)器。通常有Android端、iOS端、Web端等等
未讀數(shù) :指用戶(hù)還沒(méi)讀的消息數(shù)量
用戶(hù)狀態(tài) :指用戶(hù)當(dāng)前是在線(xiàn)、離線(xiàn)還是掛起等狀態(tài)
關(guān)系鏈 :是指用戶(hù)與用戶(hù)之間的關(guān)系,通常有單向的好友關(guān)系、雙向的好友關(guān)系、關(guān)注關(guān)系等等。這里需要注意與會(huì)話(huà)的區(qū)別,用戶(hù)只有在發(fā)起聊天時(shí)才產(chǎn)生會(huì)話(huà),但關(guān)系并不需要聊天才能建立。對(duì)于關(guān)系鏈的存儲(chǔ),可以使用圖數(shù)據(jù)庫(kù)(Neo4j等等),可以很自然地表達(dá)現(xiàn)實(shí)世界中的關(guān)系,易于建模
單聊 :一對(duì)一聊天
群聊 :多人聊天
客服 :在電商領(lǐng)域,通常需要對(duì)用戶(hù)提供售前咨詢(xún)、售后咨詢(xún)等服務(wù)。這時(shí),就需要引入客服來(lái)處理用戶(hù)的咨詢(xún)
消息分流 :在電商領(lǐng)域,一個(gè)店鋪通常會(huì)有多個(gè)客服,此時(shí)決定用戶(hù)的咨詢(xún)由哪個(gè)客服來(lái)處理就是消息分流。通常消息分流會(huì)根據(jù)一系列規(guī)則來(lái)確定消息會(huì)分流給哪個(gè)客服,例如客服是否在線(xiàn)(客服不在線(xiàn)的話(huà)需要重新分流給另一個(gè)客服)、該消息是售前咨詢(xún)還是售后咨詢(xún)、當(dāng)前客服的繁忙程度等等
信箱 :本文的信箱我們指一個(gè)Timeline、一個(gè)收發(fā)消息的隊(duì)列
讀擴(kuò)散 vs 寫(xiě)擴(kuò)散

讀擴(kuò)散
我們先來(lái)看看讀擴(kuò)散。如上圖所示,A與每個(gè)聊天的人跟群都有一個(gè)信箱(有些博文會(huì)叫Timeline),A在查看聊天信息的時(shí)候需要讀取所有有新消息的信箱。這里的讀擴(kuò)散需要注意與Feeds系統(tǒng)的區(qū)別,在Feeds系統(tǒng)中,每個(gè)人都有一個(gè)寫(xiě)信箱,寫(xiě)只需要往自己的寫(xiě)信箱里寫(xiě)一次就好了,讀需要從所有關(guān)注的人的寫(xiě)信箱里讀。但I(xiàn)M系統(tǒng)里的讀擴(kuò)散通常是每?jī)蓚€(gè)相關(guān)聯(lián)的人就有一個(gè)信箱,或者每個(gè)群一個(gè)信箱。
讀擴(kuò)散的優(yōu)點(diǎn):
寫(xiě)操作(發(fā)消息)很輕量,不管是單聊還是群聊,只需要往相應(yīng)的信箱寫(xiě)一次就好了 每一個(gè)信箱天然就是兩個(gè)人的聊天記錄,可以方便查看聊天記錄跟進(jìn)行聊天記錄的搜索
讀擴(kuò)散的缺點(diǎn):
讀操作(讀消息)很重
寫(xiě)擴(kuò)散
接下來(lái)看看寫(xiě)擴(kuò)散。

在寫(xiě)擴(kuò)散中,每個(gè)人都只從自己的信箱里讀取消息,但寫(xiě)(發(fā)消息)的時(shí)候,對(duì)于單聊跟群聊處理如下:
單聊:往自己的信箱跟對(duì)方的信箱都寫(xiě)一份消息,同時(shí),如果需要查看兩個(gè)人的聊天歷史記錄的話(huà)還需要再寫(xiě)一份(當(dāng)然,如果從個(gè)人信箱也能回溯出兩個(gè)人的所有聊天記錄,但這樣效率會(huì)很低)。 群聊:需要往所有的群成員的信箱都寫(xiě)一份消息,同時(shí),如果需要查看群的聊天歷史記錄的話(huà)還需要再寫(xiě)一份。可以看出,寫(xiě)擴(kuò)散對(duì)于群聊來(lái)說(shuō)大大地放大了寫(xiě)操作。
寫(xiě)擴(kuò)散優(yōu)點(diǎn):
讀操作很輕量 可以很方便地做消息的多終端同步
寫(xiě)擴(kuò)散缺點(diǎn):
寫(xiě)操作很重,尤其是對(duì)于群聊來(lái)說(shuō)
注意,在Feeds系統(tǒng)中:
寫(xiě)擴(kuò)散也叫:Push、Fan-out或者Write-fanout 讀擴(kuò)散也叫:Pull、Fan-in或者Read-fanout
唯一ID設(shè)計(jì)
通常情況下,ID的設(shè)計(jì)主要有以下幾大類(lèi):
UUID 基于Snowflake的ID生成方式 基于申請(qǐng)DB步長(zhǎng)的生成方式 基于Redis或者DB的自增ID生成方式 特殊的規(guī)則生成唯一ID
具體的實(shí)現(xiàn)方法跟優(yōu)缺點(diǎn)可以參考之前的一篇博文:分布式唯一 ID 解析
在IM系統(tǒng)中需要唯一Id的地方主要是:
會(huì)話(huà)ID 消息ID
消息ID
我們來(lái)看看在設(shè)計(jì)消息ID時(shí)需要考慮的三個(gè)問(wèn)題。
消息ID不遞增可以嗎
我們先看看不遞增的話(huà)會(huì)怎樣:
使用字符串,浪費(fèi)存儲(chǔ)空間,而且不能利用存儲(chǔ)引擎的特性讓相鄰的消息存儲(chǔ)在一起,降低消息的寫(xiě)入跟讀取性能 使用數(shù)字,但數(shù)字隨機(jī),也不能利用存儲(chǔ)引擎的特性讓相鄰的消息存儲(chǔ)在一起,會(huì)加大隨機(jī)IO,降低性能;而且隨機(jī)的ID不好保證ID的唯一性
因此,消息ID最好是遞增的。
全局遞增 vs 用戶(hù)級(jí)別遞增 vs 會(huì)話(huà)級(jí)別遞增
全局遞增:指消息ID在整個(gè)IM系統(tǒng)隨著時(shí)間的推移是遞增的。全局遞增的話(huà)一般可以使用Snowflake(當(dāng)然,Snowflake也只是worker級(jí)別的遞增)。此時(shí),如果你的系統(tǒng)是讀擴(kuò)散的話(huà)為了防止消息丟失,那每一條消息就只能帶上上一條消息的ID,前端根據(jù)上一條消息判斷是否有丟失消息,有消息丟失的話(huà)需要重新拉一次。
用戶(hù)級(jí)別遞增:指消息ID只保證在單個(gè)用戶(hù)中是遞增的,不同用戶(hù)之間不影響并且可能重復(fù)。典型代表:微信。如果是寫(xiě)擴(kuò)散系統(tǒng)的話(huà)信箱時(shí)間線(xiàn)ID跟消息ID需要分開(kāi)設(shè)計(jì),信箱時(shí)間線(xiàn)ID用戶(hù)級(jí)別遞增,消息ID全局遞增。如果是讀擴(kuò)散系統(tǒng)的話(huà)感覺(jué)使用用戶(hù)級(jí)別遞增必要性不是很大。
會(huì)話(huà)級(jí)別遞增:指消息ID只保證在單個(gè)會(huì)話(huà)中是遞增的,不同會(huì)話(huà)之間不影響并且可能重復(fù)。典型代表:QQ。
連續(xù)遞增 vs 單調(diào)遞增
連續(xù)遞增是指ID按 1,2,3...n 的方式生成;而單調(diào)遞增是指只要保證后面生成的ID比前面生成的ID大就可以了,不需要連續(xù)。
據(jù)我所知,QQ的消息ID就是在會(huì)話(huà)級(jí)別使用的連續(xù)遞增,這樣的好處是,如果丟失了消息,當(dāng)下一條消息來(lái)的時(shí)候發(fā)現(xiàn)ID不連續(xù)就會(huì)去請(qǐng)求服務(wù)器,避免丟失消息。此時(shí),可能有人會(huì)想,我不能用定時(shí)拉的方式看有沒(méi)有消息丟失嗎?當(dāng)然不能,因?yàn)橄D只在會(huì)話(huà)級(jí)別連續(xù)遞增的話(huà)那如果一個(gè)人有上千個(gè)會(huì)話(huà),那得拉多少次啊,服務(wù)器肯定是抗不住的。
對(duì)于讀擴(kuò)散來(lái)說(shuō),消息ID使用連續(xù)遞增就是一種不錯(cuò)的方式了。如果使用單調(diào)遞增的話(huà)當(dāng)前消息需要帶上前一條消息的ID(即聊天消息組成一個(gè)鏈表),這樣,才能判斷消息是否丟失。
總結(jié)一下就是:
寫(xiě)擴(kuò)散:信箱時(shí)間線(xiàn)ID使用用戶(hù)級(jí)別遞增,消息ID全局遞增,此時(shí)只要保證單調(diào)遞增就可以了 讀擴(kuò)散:消息ID可以使用會(huì)話(huà)級(jí)別遞增并且最好是連續(xù)遞增
會(huì)話(huà)ID
我們來(lái)看看設(shè)計(jì)會(huì)話(huà)ID需要注意的問(wèn)題:
其中,會(huì)話(huà)ID有種比較簡(jiǎn)單的生成方式(特殊的規(guī)則生成唯一ID):拼接 from_user_id 跟 to_user_id:
如果 from_user_id跟to_user_id都是32位整形數(shù)據(jù)的話(huà)可以很方便地用位運(yùn)算拼接成一個(gè)64位的會(huì)話(huà)ID,即:conversation_id = ${from_user_id} << 32 | ${to_user_id}(在拼接前需要確保值比較小的用戶(hù)ID是from_user_id,這樣任意兩個(gè)用戶(hù)發(fā)起會(huì)話(huà)可以很方便地知道會(huì)話(huà)ID)如果 from_user_id跟to_user_id都是64位整形數(shù)據(jù)的話(huà)那就只能拼接成一個(gè)字符串了,拼接成字符串的話(huà)就比較傷了,浪費(fèi)存儲(chǔ)空間性能又不好。
前東家就是使用的上面第1種方式,第1種方式有個(gè)硬傷:隨著業(yè)務(wù)在全球的擴(kuò)展,32位的用戶(hù)ID如果不夠用需要擴(kuò)展到64位的話(huà)那就需要大刀闊斧地改了。32位整形ID看起來(lái)能夠容納21億個(gè)用戶(hù),但通常我們?yōu)榱朔乐箘e人知道真實(shí)的用戶(hù)數(shù)據(jù),使用的ID通常不是連續(xù)的,這時(shí),32位的用戶(hù)ID就完全不夠用了。因此,該設(shè)計(jì)完全依賴(lài)于用戶(hù)ID,不是一種可取的設(shè)計(jì)方式。
因此,會(huì)話(huà)ID的設(shè)計(jì)可以使用全局遞增的方式,加一個(gè)映射表,保存from_user_id、to_user_id跟conversation_id的關(guān)系。
推模式 vs 拉模式 vs 推拉結(jié)合模式
在IM系統(tǒng)中,新消息的獲取通常會(huì)有三種可能的做法:
推模式:有新消息時(shí)服務(wù)器主動(dòng)推給所有端(iOS、Android、PC等) 拉模式:由前端主動(dòng)發(fā)起拉取消息的請(qǐng)求,為了保證消息的實(shí)時(shí)性,一般采用推模式,拉模式一般用于獲取歷史消息 推拉結(jié)合模式:有新消息時(shí)服務(wù)器會(huì)先推一個(gè)有新消息的通知給前端,前端接收到通知后就向服務(wù)器拉取消息
推模式簡(jiǎn)化圖如下:

如上圖所示,正常情況下,用戶(hù)發(fā)的消息經(jīng)過(guò)服務(wù)器存儲(chǔ)等操作后會(huì)推給接收方的所有端。但推是有可能會(huì)丟失的,最常見(jiàn)的情況就是用戶(hù)可能會(huì)偽在線(xiàn)(是指如果推送服務(wù)基于長(zhǎng)連接,而長(zhǎng)連接可能已經(jīng)斷開(kāi),即用戶(hù)已經(jīng)掉線(xiàn),但一般需要經(jīng)過(guò)一個(gè)心跳周期后服務(wù)器才能感知到,這時(shí)服務(wù)器會(huì)錯(cuò)誤地以為用戶(hù)還在線(xiàn);偽在線(xiàn)是本人自己想的一個(gè)概念,沒(méi)想到合適的詞來(lái)解釋?zhuān)R虼巳绻麊渭兪褂猛颇J降脑?huà),是有可能會(huì)丟失消息的。
推拉結(jié)合模式簡(jiǎn)化圖如下:

可以使用推拉結(jié)合模式解決推模式可能會(huì)丟消息的問(wèn)題。在用戶(hù)發(fā)新消息時(shí)服務(wù)器推送一個(gè)通知,然后前端請(qǐng)求最新消息列表,為了防止有消息丟失,可以再每隔一段時(shí)間主動(dòng)請(qǐng)求一次。可以看出,使用推拉結(jié)合模式最好是用寫(xiě)擴(kuò)散,因?yàn)閷?xiě)擴(kuò)散只需要拉一條時(shí)間線(xiàn)的個(gè)人信箱就好了,而讀擴(kuò)散有N條時(shí)間線(xiàn)(每個(gè)信箱一條),如果也定時(shí)拉取的話(huà)性能會(huì)很差。
業(yè)界解決方案
前面了解了IM系統(tǒng)的常見(jiàn)設(shè)計(jì)問(wèn)題,接下來(lái)我們?cè)倏纯礃I(yè)界是怎么設(shè)計(jì)IM系統(tǒng)的。研究業(yè)界的主流方案有助于我們深入理解IM系統(tǒng)的設(shè)計(jì)。以下研究都是基于網(wǎng)上已經(jīng)公開(kāi)的資料,不一定正確,大家僅作參考就好了。
微信
雖然微信很多基礎(chǔ)框架都是自研,但這并不妨礙我們理解微信的架構(gòu)設(shè)計(jì)。從微信公開(kāi)的《[從0到1:微信后臺(tái)系統(tǒng)的演進(jìn)之路](#》這篇文章可以看出,微信采用的主要是:寫(xiě)擴(kuò)散 + 推拉結(jié)合。由于群聊使用的也是寫(xiě)擴(kuò)散,而寫(xiě)擴(kuò)散很消耗資源,因此微信群有人數(shù)上限(目前是500)。所以這也是寫(xiě)擴(kuò)散的一個(gè)明顯缺點(diǎn),如果需要萬(wàn)人群就比較難了。
從文中還可以看出,微信采用了多數(shù)據(jù)中心架構(gòu):

微信每個(gè)數(shù)據(jù)中心都是自治的,每個(gè)數(shù)據(jù)中心都有全量的數(shù)據(jù),數(shù)據(jù)中心間通過(guò)自研的消息隊(duì)列來(lái)同步數(shù)據(jù)。為了保證數(shù)據(jù)的一致性,每個(gè)用戶(hù)都只屬于一個(gè)數(shù)據(jù)中心,只能在自己所屬的數(shù)據(jù)中心進(jìn)行數(shù)據(jù)讀寫(xiě),如果用戶(hù)連了其它數(shù)據(jù)中心則會(huì)自動(dòng)引導(dǎo)用戶(hù)接入所屬的數(shù)據(jù)中心。而如果需要訪(fǎng)問(wèn)其它用戶(hù)的數(shù)據(jù)那只需要訪(fǎng)問(wèn)自己所屬的數(shù)據(jù)中心就可以了。同時(shí),微信使用了三園區(qū)容災(zāi)的架構(gòu),使用Paxos來(lái)保證數(shù)據(jù)的一致性。
從微信公開(kāi)的《萬(wàn)億級(jí)調(diào)用系統(tǒng):微信序列號(hào)生成器架構(gòu)設(shè)計(jì)及演變》這篇文章可以看出,微信的ID設(shè)計(jì)采用的是:基于申請(qǐng)DB步長(zhǎng)的生成方式 + 用戶(hù)級(jí)別遞增。如下圖所示:

微信的序列號(hào)生成器由仲裁服務(wù)生成路由表(路由表保存了uid號(hào)段到AllocSvr的全映射),路由表會(huì)同步到AllocSvr跟Client。如果AllocSvr宕機(jī)的話(huà)會(huì)由仲裁服務(wù)重新調(diào)度uid號(hào)段到其它AllocSvr。
釘釘
釘釘公開(kāi)的資料不多,從《阿里釘釘技術(shù)分享:企業(yè)級(jí)IM王者——釘釘在后端架構(gòu)上的過(guò)人之處》這篇文章我們只能知道,釘釘最開(kāi)始使用的是寫(xiě)擴(kuò)散模型,為了支持萬(wàn)人群,后來(lái)貌似優(yōu)化成了讀擴(kuò)散。
但聊到阿里的IM系統(tǒng),不得不提的是阿里自研的Tablestore。一般情況下,IM系統(tǒng)都會(huì)有一個(gè)自增ID生成系統(tǒng),但Tablestore創(chuàng)造性地引入了主鍵列自增,即把ID的生成整合到了DB層,支持了用戶(hù)級(jí)別遞增(傳統(tǒng)MySQL等DB只能支持表級(jí)自增,即全局自增)。具體可以參考:《如何優(yōu)化高并發(fā)IM系統(tǒng)架構(gòu)》
什么?Twitter不是Feeds系統(tǒng)嗎?這篇文章不是討論IM的嗎?是的,Twitter是Feeds系統(tǒng),但Feeds系統(tǒng)跟IM系統(tǒng)其實(shí)有很多設(shè)計(jì)上的共性,研究下Feeds系統(tǒng)有助于我們?cè)谠O(shè)計(jì)IM系統(tǒng)時(shí)進(jìn)行參考。再說(shuō)了,研究下Feeds系統(tǒng)也沒(méi)有壞處,擴(kuò)展下技術(shù)視野嘛。
Twitter的自增ID設(shè)計(jì)估計(jì)大家都耳熟能詳了,即大名鼎鼎的Snowflake,因此ID是全局遞增的。

從這個(gè)視頻分享《How We Learned to Stop Worrying and Love Fan-In at Twitter》可以看出,Twitter一開(kāi)始使用的是寫(xiě)擴(kuò)散模型,F(xiàn)anout Service負(fù)責(zé)擴(kuò)散寫(xiě)到Timelines Cache(使用了Redis),Timeline Service負(fù)責(zé)讀取Timeline數(shù)據(jù),然后由API Services返回給用戶(hù)。
但由于寫(xiě)擴(kuò)散對(duì)于大V來(lái)說(shuō)寫(xiě)的消耗太大,因此后面Twitter又使用了寫(xiě)擴(kuò)散跟讀擴(kuò)散結(jié)合的方式。如下圖所示:

對(duì)于粉絲數(shù)不多的用戶(hù)如果發(fā)Twitter使用的還是寫(xiě)擴(kuò)散模型,由Timeline Mixer服務(wù)將用戶(hù)的Timeline、大V的寫(xiě)Timeline跟系統(tǒng)推薦等內(nèi)容整合起來(lái),最后再由API Services返回給用戶(hù)
IM需要解決的問(wèn)題
如何保證消息的實(shí)時(shí)性
在通信協(xié)議的選擇上,我們主要有以下幾個(gè)選擇:
使用TCP Socket通信,自己設(shè)計(jì)協(xié)議:58到家等等 使用UDP Socket通信:QQ等等 使用HTTP長(zhǎng)輪循:微信網(wǎng)頁(yè)版等等
不管使用哪種方式,我們都能夠做到消息的實(shí)時(shí)通知。但影響我們消息實(shí)時(shí)性的可能會(huì)在我們處理消息的方式上。例如:假如我們推送的時(shí)候使用MQ去處理并推送一個(gè)萬(wàn)人群的消息,推送一個(gè)人需要2ms,那么推完一萬(wàn)人需要20s,那么后面的消息就阻塞了20s。如果我們需要在10ms內(nèi)推完,那么我們推送的并發(fā)度應(yīng)該是:人數(shù):10000 / (推送總時(shí)長(zhǎng):10 / 單個(gè)人推送時(shí)長(zhǎng):2) = 2000
因此,我們?cè)谶x擇具體的實(shí)現(xiàn)方案的時(shí)候一定要評(píng)估好我們系統(tǒng)的吞吐量,系統(tǒng)的每一個(gè)環(huán)節(jié)都要進(jìn)行評(píng)估壓測(cè)。只有把每一個(gè)環(huán)節(jié)的吞吐量評(píng)估好了,才能保證消息推送的實(shí)時(shí)性。
如何保證消息時(shí)序
以下情況下消息可能會(huì)亂序:
發(fā)送消息如果使用的不是長(zhǎng)連接,而是使用HTTP的話(huà)可能會(huì)出現(xiàn)亂序。因?yàn)楹蠖艘话闶羌翰渴穑褂肏TTP的話(huà)請(qǐng)求可能會(huì)打到不同的服務(wù)器,由于網(wǎng)絡(luò)延遲或者服務(wù)器處理速度的不同,后發(fā)的消息可能會(huì)先完成,此時(shí)就產(chǎn)生了消息亂序。解決方案: 前端依次對(duì)消息進(jìn)行處理,發(fā)送完一個(gè)消息再發(fā)送下一個(gè)消息。這種方式會(huì)降低用戶(hù)體驗(yàn),一般情況下不建議使用。 帶上一個(gè)前端生成的順序ID,讓接收方根據(jù)該ID進(jìn)行排序。這種方式前端處理會(huì)比較麻煩一點(diǎn),而且聊天的過(guò)程中接收方的歷史消息列表中可能會(huì)在中間插入一條消息,這樣會(huì)很奇怪,而且用戶(hù)可能會(huì)漏讀消息。但這種情況可以通過(guò)在用戶(hù)切換窗口的時(shí)候再進(jìn)行重排來(lái)解決,接收方每次收到消息都先往最后面追加。 通常為了優(yōu)化體驗(yàn),有的IM系統(tǒng)可能會(huì)采取異步發(fā)送確認(rèn)機(jī)制(例如:QQ)。即消息只要到達(dá)服務(wù)器,然后服務(wù)器發(fā)送到MQ就算發(fā)送成功。如果由于權(quán)限等問(wèn)題發(fā)送失敗的話(huà)后端再推一個(gè)通知下去。這種情況下MQ就要選擇合適的Sharding策略了: 按 to_user_id進(jìn)行Sharding:使用該策略如果需要做多端同步的話(huà)發(fā)送方多個(gè)端進(jìn)行同步可能會(huì)亂序,因?yàn)椴煌?duì)列的處理速度可能會(huì)不一樣。例如發(fā)送方先發(fā)送m1然后發(fā)送m2,但服務(wù)器可能會(huì)先處理完m2再處理m1,這里其它端會(huì)先收到m2然后是m1,此時(shí)其它端的會(huì)話(huà)列表就亂了。按 conversation_id進(jìn)行Sharding:使用該策略同樣會(huì)導(dǎo)致多端同步會(huì)亂序。按 from_user_id進(jìn)行Sharding:這種情況下使用該策略是比較好的選擇通常為了優(yōu)化性能,推送前可能會(huì)先往MQ推,這種情況下使用 to_user_id才是比較好的選擇。
用戶(hù)在線(xiàn)狀態(tài)如何做
很多IM系統(tǒng)都需要展示用戶(hù)的狀態(tài):是否在線(xiàn),是否忙碌等。主要可以使用Redis或者分布式一致性哈希來(lái)實(shí)現(xiàn)用戶(hù)在線(xiàn)狀態(tài)的存儲(chǔ)。
Redis存儲(chǔ)用戶(hù)在線(xiàn)狀態(tài)

看上面的圖可能會(huì)有人疑惑,為什么每次心跳都需要更新Redis?如果我使用的是TCP長(zhǎng)連接那是不是就不用每次心跳都更新了?確實(shí),正常情況下服務(wù)器只需要在新建連接或者斷開(kāi)連接的時(shí)候更新一下Redis就好了。但由于服務(wù)器可能會(huì)出現(xiàn)異常,或者服務(wù)器跟Redis之間的網(wǎng)絡(luò)會(huì)出現(xiàn)問(wèn)題,此時(shí)基于事件的更新就會(huì)出現(xiàn)問(wèn)題,導(dǎo)致用戶(hù)狀態(tài)不正確。因此,如果需要用戶(hù)在線(xiàn)狀態(tài)準(zhǔn)確的話(huà)最好通過(guò)心跳來(lái)更新在線(xiàn)狀態(tài)。
由于Redis是單機(jī)存儲(chǔ)的,因此,為了提高可靠性跟性能,我們可以使用Redis Cluster或者Codis。
分布式一致性哈希存儲(chǔ)用戶(hù)在線(xiàn)狀態(tài)

使用分布式一致性哈希需要注意在對(duì)Status Server Cluster進(jìn)行擴(kuò)容或者縮容的時(shí)候要先對(duì)用戶(hù)狀態(tài)進(jìn)行遷移,不然在剛操作時(shí)會(huì)出現(xiàn)用戶(hù)狀態(tài)不一致的情況。同時(shí)還需要使用虛擬節(jié)點(diǎn)避免數(shù)據(jù)傾斜的問(wèn)題。
多端同步怎么做
讀擴(kuò)散
前面也提到過(guò),對(duì)于讀擴(kuò)散,消息的同步主要是以推模式為主,單個(gè)會(huì)話(huà)的消息ID順序遞增,前端收到推的消息如果發(fā)現(xiàn)消息ID不連續(xù)就請(qǐng)求后端重新獲取消息。但這樣仍然可能丟失會(huì)話(huà)的最后一條消息,為了加大消息的可靠性,可以在歷史會(huì)話(huà)列表的會(huì)話(huà)里再帶上最后一條消息的ID,前端在收到新消息的時(shí)候會(huì)先拉取最新的會(huì)話(huà)列表,然后判斷會(huì)話(huà)的最后一條消息是否存在,如果不存在,消息就可能丟失了,前端需要再拉一次會(huì)話(huà)的消息列表;如果會(huì)話(huà)的最后一條消息ID跟消息列表里的最后一條消息ID一樣,前端就不再處理。這種做法的性能瓶頸會(huì)在拉取歷史會(huì)話(huà)列表那里,因?yàn)槊看涡孪⒍夹枰『蠖艘淮危绻次⑿诺牧考?jí)來(lái)看,單是消息就可能會(huì)有20萬(wàn)的QPS,如果歷史會(huì)話(huà)列表放到MySQL等傳統(tǒng)DB的話(huà)肯定抗不住。因此,最好將歷史會(huì)話(huà)列表存到開(kāi)了AOF(用RDB的話(huà)可能會(huì)丟數(shù)據(jù))的Redis集群。這里只能感慨性能跟簡(jiǎn)單性不能兼得。
寫(xiě)擴(kuò)散

對(duì)于寫(xiě)擴(kuò)散來(lái)說(shuō),多端同步就簡(jiǎn)單些了。前端只需要記錄最后同步的位點(diǎn),同步的時(shí)候帶上同步位點(diǎn),然后服務(wù)器就將該位點(diǎn)后面的數(shù)據(jù)全部返回給前端,前端更新同步位點(diǎn)就可以了。
如何處理未讀數(shù)
在IM系統(tǒng)中,未讀數(shù)的處理非常重要。未讀數(shù)一般分為會(huì)話(huà)未讀數(shù)跟總未讀數(shù),如果處理不當(dāng),會(huì)話(huà)未讀數(shù)跟總未讀數(shù)可能會(huì)不一致,嚴(yán)重降低用戶(hù)體驗(yàn)。
讀擴(kuò)散
對(duì)于讀擴(kuò)散來(lái)說(shuō),我們可以將會(huì)話(huà)未讀數(shù)跟總未讀數(shù)都存在后端,但后端需要保證兩個(gè)未讀數(shù)更新的原子性跟一致性,一般可以通過(guò)以下兩種方法來(lái)實(shí)現(xiàn):
使用Redis的multi事務(wù)功能,事務(wù)更新失敗可以重試。但要注意如果你使用Codis集群的話(huà)并不支持事務(wù)功能。 使用Lua嵌入腳本的方式。使用這種方式需要保證會(huì)話(huà)未讀數(shù)跟總未讀數(shù)都在同一個(gè)Redis節(jié)點(diǎn)(Codis的話(huà)可以使用Hashtag)。這種方式會(huì)導(dǎo)致實(shí)現(xiàn)邏輯分散,加大維護(hù)成本。
寫(xiě)擴(kuò)散
對(duì)于寫(xiě)擴(kuò)散來(lái)說(shuō),服務(wù)端通常會(huì)弱化會(huì)話(huà)的概念,即服務(wù)端不存儲(chǔ)歷史會(huì)話(huà)列表。未讀數(shù)的計(jì)算可由前端來(lái)負(fù)責(zé),標(biāo)記已讀跟標(biāo)記未讀可以只記錄一個(gè)事件到信箱里,各個(gè)端通過(guò)重放該事件的形式來(lái)處理會(huì)話(huà)未讀數(shù)。使用這種方式可能會(huì)造成各個(gè)端的未讀數(shù)不一致,至少微信就會(huì)有這個(gè)問(wèn)題。
如果寫(xiě)擴(kuò)散也通過(guò)歷史會(huì)話(huà)列表來(lái)存儲(chǔ)未讀數(shù)的話(huà)那用戶(hù)時(shí)間線(xiàn)服務(wù)跟會(huì)話(huà)服務(wù)緊耦合,這個(gè)時(shí)候需要保證原子性跟一致性的話(huà)那就只能使用分布式事務(wù)了,會(huì)大大降低系統(tǒng)的性能。
如何存儲(chǔ)歷史消息
讀擴(kuò)散
對(duì)于讀擴(kuò)散,只需要按會(huì)話(huà)ID進(jìn)行Sharding存儲(chǔ)一份就可以了。
寫(xiě)擴(kuò)散
對(duì)于寫(xiě)擴(kuò)散,需要存儲(chǔ)兩份:一份是以用戶(hù)為T(mén)imeline的消息列表,一份是以會(huì)話(huà)為T(mén)imeline的消息列表。以用戶(hù)為T(mén)imeline的消息列表可以用用戶(hù)ID來(lái)做Sharding,以會(huì)話(huà)為T(mén)imeline的消息列表可以用會(huì)話(huà)ID來(lái)做Sharding。
數(shù)據(jù)冷熱分離
對(duì)于IM來(lái)說(shuō),歷史消息的存儲(chǔ)有很強(qiáng)的時(shí)間序列特性,時(shí)間越久,消息被訪(fǎng)問(wèn)的概率也越低,價(jià)值也越低。

如果我們需要存儲(chǔ)幾年甚至是永久的歷史消息的話(huà)(電商IM中比較常見(jiàn)),那么做歷史消息的冷熱分離就非常有必要了。數(shù)據(jù)的冷熱分離一般是HWC(Hot-Warm-Cold)架構(gòu)。對(duì)于剛發(fā)送的消息可以放到Hot存儲(chǔ)系統(tǒng)(可以用Redis)跟Warm存儲(chǔ)系統(tǒng),然后由Store Scheduler根據(jù)一定的規(guī)則定時(shí)將冷數(shù)據(jù)遷移到Cold存儲(chǔ)系統(tǒng)。獲取消息的時(shí)候需要依次訪(fǎng)問(wèn)Hot、Warm跟Cold存儲(chǔ)系統(tǒng),由Store Service整合數(shù)據(jù)返回給IM Service。
接入層怎么做
實(shí)現(xiàn)接入層的負(fù)載均衡主要有以下幾個(gè)方法:
硬件負(fù)載均衡:例如F5、A10等等。硬件負(fù)載均衡性能強(qiáng)大,穩(wěn)定性高,但價(jià)格非常貴,不是土豪公司不建議使用。 使用DNS實(shí)現(xiàn)負(fù)載均衡:使用DNS實(shí)現(xiàn)負(fù)載均衡比較簡(jiǎn)單,但使用DNS實(shí)現(xiàn)負(fù)載均衡如果需要切換或者擴(kuò)容那生效會(huì)很慢,而且使用DNS實(shí)現(xiàn)負(fù)載均衡支持的IP個(gè)數(shù)有限制、支持的負(fù)載均衡策略也比較簡(jiǎn)單。 DNS + 4層負(fù)載均衡 + 7層負(fù)載均衡架構(gòu):例如 DNS + DPVS + Nginx 或者 DNS + LVS + Nginx。有人可能會(huì)疑惑為什么要加入4層負(fù)載均衡呢?這是因?yàn)?層負(fù)載均衡很耗CPU,并且經(jīng)常需要擴(kuò)容或者縮容,對(duì)于大型網(wǎng)站來(lái)說(shuō)可能需要很多7層負(fù)載均衡服務(wù)器,但只需要少量的4層負(fù)載均衡服務(wù)器即可。因此,該架構(gòu)對(duì)于HTTP等短連接大型應(yīng)用很有用。當(dāng)然,如果流量不大的話(huà)只使用DNS + 7層負(fù)載均衡即可。但對(duì)于長(zhǎng)連接來(lái)說(shuō),加入7層負(fù)載均衡Nginx就不大好了。因?yàn)镹ginx經(jīng)常需要改配置并且reload配置,reload的時(shí)候TCP連接會(huì)斷開(kāi),造成大量掉線(xiàn)。 DNS + 4層負(fù)載均衡:4層負(fù)載均衡一般比較穩(wěn)定,很少改動(dòng),比較適合于長(zhǎng)連接。
對(duì)于長(zhǎng)連接的接入層,如果我們需要更加靈活的負(fù)載均衡策略或者需要做灰度的話(huà),那我們可以引入一個(gè)調(diào)度服務(wù),如下圖所示:

Access Schedule Service可以實(shí)現(xiàn)根據(jù)各種策略來(lái)分配Access Service,例如:
根據(jù)灰度策略來(lái)分配 根據(jù)就近原則來(lái)分配 根據(jù)最少連接數(shù)來(lái)分配
架構(gòu)心得
最后,分享一下做大型應(yīng)用的架構(gòu)心得:
灰度!灰度!灰度! 監(jiān)控!監(jiān)控!監(jiān)控! 告警!告警!告警! 緩存!緩存!緩存! 限流!熔斷!降級(jí)! 低耦合,高內(nèi)聚! 避免單點(diǎn),擁抱無(wú)狀態(tài)! 評(píng)估!評(píng)估!評(píng)估! 壓測(cè)!壓測(cè)!壓測(cè)!

Redis 大數(shù)據(jù)量(百億級(jí))Key存儲(chǔ)需求及解決方案

程序員過(guò)關(guān)斬將--錯(cuò)誤的IOC和DI
