首頁>科技>

前言

本文不會給出一套通用的IM方案,也不會評判某種架構的好壞,而是討論設計IM系統的常見難題跟業界的解決方案。因為也沒有所謂的通用方案,不同的解決方案都有其優缺點,只有最滿足業務需要的系統才是一個好的系統。而且,在有限的人力、物力跟時間資源下,通常需要做出很多權衡,此時,一個能夠快速迭代、方便擴充套件的系統才是一個好的系統。

IM核心概念

使用者:系統的使用者

訊息:是指使用者之間的溝通內容。通常在IM系統中,訊息會有以下幾類:文字訊息、表情訊息、圖片訊息、影片訊息、檔案訊息等等

會話:通常指兩個使用者之間因聊天而建立起的關聯

:通常指多個使用者之間因聊天而建立起的關聯

終端:指使用者使用IM系統的機器。通常有Android端、iOS端、Web端等等

未讀數:指使用者還沒讀的訊息數量

使用者狀態:指使用者當前是線上、離線還是掛起等狀態

關係鏈:是指使用者與使用者之間的關係,通常有單向的好友關係、雙向的好友關係、關注關係等等。這裡需要注意與會話的區別,使用者只有在發起聊天時才產生會話,但關係並不需要聊天才能建立。對於關係鏈的儲存,可以使用圖資料庫(Neo4j等等),可以很自然地表達現實世界中的關係,易於建模

單聊:一對一聊天

群聊:多人聊天

客服:在電商領域,通常需要對使用者提供售前諮詢、售後諮詢等服務。這時,就需要引入客服來處理使用者的諮詢

訊息分流:在電商領域,一個店鋪通常會有多個客服,此時決定使用者的諮詢由哪個客服來處理就是訊息分流。通常訊息分流會根據一系列規則來確定訊息會分流給哪個客服,例如客服是否線上(客服不線上的話需要重新分流給另一個客服)、該訊息是售前諮詢還是售後諮詢、當前客服的繁忙程度等等

信箱:本文的信箱我們指一個Timeline、一個收發訊息的佇列

讀擴散 vs 寫擴散

讀擴散

我們先來看看讀擴散。如上圖所示,A與每個聊天的人跟群都有一個信箱(有些博文會叫Timeline),A在檢視聊天資訊的時候需要讀取所有有新訊息的信箱。這裡的讀擴散需要注意與Feeds系統的區別,在Feeds系統中,每個人都有一個寫信箱,寫只需要往自己的寫信箱裡寫一次就好了,讀需要從所有關注的人的寫信箱裡讀。但IM系統裡的讀擴散通常是每兩個相關聯的人就有一個信箱,或者每個群一個信箱。

讀擴散的優點:

寫操作(發訊息)很輕量,不管是單聊還是群聊,只需要往相應的信箱寫一次就好了每一個信箱天然就是兩個人的聊天記錄,可以方便檢視聊天記錄跟進行聊天記錄的搜尋

讀擴散的缺點:

讀操作(讀訊息)很重

寫擴散

接下來看看寫擴散。

在寫擴散中,每個人都只從自己的信箱裡讀取訊息,但寫(發訊息)的時候,對於單聊跟群聊處理如下:

單聊:往自己的信箱跟對方的信箱都寫一份訊息,同時,如果需要檢視兩個人的聊天曆史記錄的話還需要再寫一份(當然,如果從個人信箱也能回溯出兩個人的所有聊天記錄,但這樣效率會很低)。群聊:需要往所有的群成員的信箱都寫一份訊息,同時,如果需要檢視群的聊天曆史記錄的話還需要再寫一份。可以看出,寫擴散對於群聊來說大大地放大了寫操作。

寫擴散優點:

讀操作很輕量可以很方便地做訊息的多終端同步

寫擴散缺點:

寫操作很重,尤其是對於群聊來說

注意,在Feeds系統中:

寫擴散也叫:Push、Fan-out或者Write-fanout讀擴散也叫:Pull、Fan-in或者Read-fanout唯一ID設計

通常情況下,ID的設計主要有以下幾大類:

UUID基於Snowflake的ID生成方式基於申請DB步長的生成方式基於Redis或者DB的自增ID生成方式特殊的規則生成唯一ID

具體的實現方法跟優缺點可以參考之前的一篇博文:分散式唯一 ID 解析

在IM系統中需要唯一Id的地方主要是:

會話ID訊息ID訊息ID

我們來看看在設計訊息ID時需要考慮的三個問題。

訊息ID不遞增可以嗎

我們先看看不遞增的話會怎樣:

使用字串,浪費儲存空間,而且不能利用儲存引擎的特性讓相鄰的訊息儲存在一起,降低訊息的寫入跟讀取效能使用數字,但數字隨機,也不能利用儲存引擎的特性讓相鄰的訊息儲存在一起,會加大隨機IO,降低效能;而且隨機的ID不好保證ID的唯一性

因此,訊息ID最好是遞增的。

全域性遞增 vs 使用者級別遞增 vs 會話級別遞增

全域性遞增:指訊息ID在整個IM系統隨著時間的推移是遞增的。全域性遞增的話一般可以使用Snowflake(當然,Snowflake也只是worker級別的遞增)。此時,如果你的系統是讀擴散的話為了防止訊息丟失,那每一條訊息就只能帶上上一條訊息的ID,前端根據上一條訊息判斷是否有丟失訊息,有訊息丟失的話需要重新拉一次。

會話級別遞增:指訊息ID只保證在單個會話中是遞增的,不同會話之間不影響並且可能重複。典型代表:QQ。

連續遞增 vs 單調遞增

連續遞增是指ID按 1,2,3...n 的方式生成;而單調遞增是指只要保證後面生成的ID比前面生成的ID大就可以了,不需要連續。

據我所知,QQ的訊息ID就是在會話級別使用的連續遞增,這樣的好處是,如果丟失了訊息,當下一條訊息來的時候發現ID不連續就會去請求伺服器,避免丟失訊息。此時,可能有人會想,我不能用定時拉的方式看有沒有訊息丟失嗎?當然不能,因為訊息ID只在會話級別連續遞增的話那如果一個人有上千個會話,那得拉多少次啊,伺服器肯定是抗不住的。

對於讀擴散來說,訊息ID使用連續遞增就是一種不錯的方式了。如果使用單調遞增的話當前訊息需要帶上前一條訊息的ID(即聊天訊息組成一個連結串列),這樣,才能判斷訊息是否丟失。

總結一下就是:

寫擴散:信箱時間線ID使用使用者級別遞增,訊息ID全域性遞增,此時只要保證單調遞增就可以了讀擴散:訊息ID可以使用會話級別遞增並且最好是連續遞增會話ID

我們來看看設計會話ID需要注意的問題:

其中,會話ID有種比較簡單的生成方式(特殊的規則生成唯一ID):拼接 from_user_id 跟 to_user_id:

如果 from_user_id 跟 to_user_id 都是32位整形資料的話可以很方便地用位運算拼接成一個64位的會話ID,即: conversation_id = ${from_user_id} << 32 | ${to_user_id} (在拼接前需要確保值比較小的使用者ID是 from_user_id,這樣任意兩個使用者發起會話可以很方便地知道會話ID)如果from_user_id 跟 to_user_id 都是64位整形資料的話那就只能拼接成一個字串了,拼接成字串的話就比較傷了,浪費儲存空間效能又不好。

前東家就是使用的上面第1種方式,第1種方式有個硬傷:隨著業務在全球的擴充套件,32位的使用者ID如果不夠用需要擴充套件到64位的話那就需要大刀闊斧地改了。32位整形ID看起來能夠容納21億個使用者,但通常我們為了防止別人知道真實的使用者資料,使用的ID通常不是連續的,這時,32位的使用者ID就完全不夠用了。因此,該設計完全依賴於使用者ID,不是一種可取的設計方式。

因此,會話ID的設計可以使用全域性遞增的方式,加一個對映表,儲存from_user_id、to_user_id跟conversation_id的關係。

推模式 vs 拉模式 vs 推拉結合模式

在IM系統中,新訊息的獲取通常會有三種可能的做法:

推模式:有新訊息時伺服器主動推給所有端(iOS、Android、PC等)拉模式:由前端主動發起拉取訊息的請求,為了保證訊息的實時性,一般採用推模式,拉模式一般用於獲取歷史訊息推拉結合模式:有新訊息時伺服器會先推一個有新訊息的通知給前端,前端接收到通知後就向伺服器拉取訊息

推模式簡化圖如下:

如上圖所示,正常情況下,使用者發的訊息經過伺服器儲存等操作後會推給接收方的所有端。但推是有可能會丟失的,最常見的情況就是使用者可能會偽線上(是指如果推送服務基於長連線,而長連線可能已經斷開,即使用者已經掉線,但一般需要經過一個心跳週期後伺服器才能感知到,這時伺服器會錯誤地以為使用者還線上;偽線上是本人自己想的一個概念,沒想到合適的詞來解釋)。因此如果單純使用推模式的話,是有可能會丟失訊息的。

推拉結合模式簡化圖如下:

可以使用推拉結合模式解決推模式可能會丟訊息的問題。在使用者發新訊息時伺服器推送一個通知,然後前端請求最新訊息列表,為了防止有訊息丟失,可以再每隔一段時間主動請求一次。可以看出,使用推拉結合模式最好是用寫擴散,因為寫擴散只需要拉一條時間線的個人信箱就好了,而讀擴散有N條時間線(每個信箱一條),如果也定時拉取的話效能會很差。

業界解決方案

前面瞭解了IM系統的常見設計問題,接下來我們再看看業界是怎麼設計IM系統的。研究業界的主流方案有助於我們深入理解IM系統的設計。以下研究都是基於網上已經公開的資料,不一定正確,大家僅作參考就好了。

微信

雖然微信很多基礎框架都是自研,但這並不妨礙我們理解微信的架構設計。從微信公開的《從0到1:微信後臺系統的演進之路》這篇文章可以看出,微信採用的主要是:寫擴散 + 推拉結合。由於群聊使用的也是寫擴散,而寫擴散很消耗資源,因此微信群有人數上限(目前是500)。所以這也是寫擴散的一個明顯缺點,如果需要萬人群就比較難了。

微信每個資料中心都是自治的,每個資料中心都有全量的資料,資料中心間透過自研的訊息佇列來同步資料。為了保證資料的一致性,每個使用者都只屬於一個數據中心,只能在自己所屬的資料中心進行資料讀寫,如果使用者連了其它資料中心則會自動引導使用者接入所屬的資料中心。而如果需要訪問其它使用者的資料那隻需要訪問自己所屬的資料中心就可以了。同時,微信使用了三園區容災的架構,使用Paxos來保證資料的一致性。

釘釘

釘釘公開的資料不多,從《阿里釘釘技術分享:企業級IM王者——釘釘在後端架構上的過人之處》這篇文章我們只能知道,釘釘最開始使用的是寫擴散模型,為了支援萬人群,後來貌似最佳化成了讀擴散。

但聊到阿里的IM系統,不得不提的是阿里自研的Tablestore。一般情況下,IM系統都會有一個自增ID生成系統,但Tablestore創造性地引入了主鍵列自增,即把ID的生成整合到了DB層,支援了使用者級別遞增(傳統MySQL等DB只能支援表級自增,即全域性自增)。具體可以參考:《如何最佳化高併發IM系統架構》

Twitter

什麼?Twitter不是Feeds系統嗎?這篇文章不是討論IM的嗎?是的,Twitter是Feeds系統,但Feeds系統跟IM系統其實有很多設計上的共性,研究下Feeds系統有助於我們在設計IM系統時進行參考。再說了,研究下Feeds系統也沒有壞處,擴充套件下技術視野嘛。

Twitter的自增ID設計估計大家都耳熟能詳了,即大名鼎鼎的Snowflake,因此ID是全域性遞增的。

從這個影片分享《How We Learned to Stop Worrying and Love Fan-In at Twitter》可以看出,Twitter一開始使用的是寫擴散模型,Fanout Service負責擴散寫到Timelines Cache(使用了Redis),Timeline Service負責讀取Timeline資料,然後由API Services返回給使用者。

但由於寫擴散對於大V來說寫的消耗太大,因此後面Twitter又使用了寫擴散跟讀擴散結合的方式。如下圖所示:

對於粉絲數不多的使用者如果發Twitter使用的還是寫擴散模型,由Timeline Mixer服務將使用者的Timeline、大V的寫Timeline跟系統推薦等內容整合起來,最後再由API Services返回給使用者。

58到家

58到家實現了一個通用的實時訊息平臺:

可以看出,msg-server儲存了應用跟MQ主題之間的對應關係,msg-server根據這個配置將訊息推到不同的MQ佇列,具體的應用來消費就可以了。因此,新增一個應用只需要修改配置就可以了。

看到這裡,估計大家已經明白了,設計一個IM系統很有挑戰性。我們還是繼續來看設計一個IM系統需要考慮的問題吧。

IM需要解決的問題如何保證訊息的實時性

在通訊協議的選擇上,我們主要有以下幾個選擇:

使用TCP Socket通訊,自己設計協議:58到家等等使用UDP Socket通訊:QQ等等使用HTTP長輪循:微信網頁版等等

不管使用哪種方式,我們都能夠做到訊息的實時通知。但影響我們訊息實時性的可能會在我們處理訊息的方式上。例如:假如我們推送的時候使用MQ去處理並推送一個萬人群的訊息,推送一個人需要2ms,那麼推完一萬人需要20s,那麼後面的訊息就阻塞了20s。如果我們需要在10ms內推完,那麼我們推送的併發度應該是:人數:10000 / (推送總時長:10 / 單個人推送時長:2) = 2000

因此,我們在選擇具體的實現方案的時候一定要評估好我們系統的吞吐量,系統的每一個環節都要進行評估壓測。只有把每一個環節的吞吐量評估好了,才能保證訊息推送的實時性。

如何保證訊息時序

以下情況下訊息可能會亂序:

傳送訊息如果使用的不是長連線,而是使用HTTP的話可能會出現亂序。因為後端一般是叢集部署,使用HTTP的話請求可能會打到不同的伺服器,由於網路延遲或者伺服器處理速度的不同,後發的訊息可能會先完成,此時就產生了訊息亂序。解決方案:前端依次對訊息進行處理,傳送完一個訊息再發送下一個訊息。這種方式會降低使用者體驗,一般情況下不建議使用。帶上一個前端生成的順序ID,讓接收方根據該ID進行排序。這種方式前端處理會比較麻煩一點,而且聊天的過程中接收方的歷史訊息列表中可能會在中間插入一條訊息,這樣會很奇怪,而且使用者可能會漏讀訊息。但這種情況可以透過在使用者切換視窗的時候再進行重排來解決,接收方每次收到訊息都先往最後面追加。通常為了最佳化體驗,有的IM系統可能會採取非同步傳送確認機制(例如:QQ)。即訊息只要到達伺服器,然後伺服器傳送到MQ就算髮送成功。如果由於許可權等問題傳送失敗的話後端再推一個通知下去。這種情況下MQ就要選擇合適的Sharding策略了:按to_user_id進行Sharding:使用該策略如果需要做多端同步的話傳送方多個端進行同步可能會亂序,因為不同佇列的處理速度可能會不一樣。例如傳送方先發送m1然後傳送m2,但伺服器可能會先處理完m2再處理m1,這裡其它端會先收到m2然後是m1,此時其它端的會話列表就亂了。按conversation_id進行Sharding:使用該策略同樣會導致多端同步會亂序。按from_user_id進行Sharding:這種情況下使用該策略是比較好的選擇通常為了最佳化效能,推送前可能會先往MQ推,這種情況下使用to_user_id才是比較好的選擇。使用者線上狀態如何做

很多IM系統都需要展示使用者的狀態:是否線上,是否忙碌等。主要可以使用Redis或者分散式一致性雜湊來實現使用者線上狀態的儲存。

Redis儲存使用者線上狀態

看上面的圖可能會有人疑惑,為什麼每次心跳都需要更新Redis?如果我使用的是TCP長連線那是不是就不用每次心跳都更新了?確實,正常情況下伺服器只需要在新建連線或者斷開連線的時候更新一下Redis就好了。但由於伺服器可能會出現異常,或者伺服器跟Redis之間的網路會出現問題,此時基於事件的更新就會出現問題,導致使用者狀態不正確。因此,如果需要使用者線上狀態準確的話最好透過心跳來更新線上狀態。

由於Redis是單機儲存的,因此,為了提高可靠性跟效能,我們可以使用Redis Cluster或者Codis。

分散式一致性雜湊儲存使用者線上狀態

使用分散式一致性雜湊需要注意在對Status Server Cluster進行擴容或者縮容的時候要先對使用者狀態進行遷移,不然在剛操作時會出現使用者狀態不一致的情況。同時還需要使用虛擬節點避免資料傾斜的問題。

多端同步怎麼做

讀擴散

前面也提到過,對於讀擴散,訊息的同步主要是以推模式為主,單個會話的訊息ID順序遞增,前端收到推的訊息如果發現訊息ID不連續就請求後端重新獲取訊息。但這樣仍然可能丟失會話的最後一條訊息,為了加大訊息的可靠性,可以在歷史會話列表的會話裡再帶上最後一條訊息的ID,前端在收到新訊息的時候會先拉取最新的會話列表,然後判斷會話的最後一條訊息是否存在,如果不存在,訊息就可能丟失了,前端需要再拉一次會話的訊息列表;如果會話的最後一條訊息ID跟訊息列表裡的最後一條訊息ID一樣,前端就不再處理。這種做法的效能瓶頸會在拉取歷史會話列表那裡,因為每次新訊息都需要拉取後端一次,如果按微信的量級來看,單是訊息就可能會有20萬的QPS,如果歷史會話列表放到MySQL等傳統DB的話肯定抗不住。因此,最好將歷史會話列表存到開了AOF(用RDB的話可能會丟資料)的Redis叢集。這裡只能感慨效能跟簡單性不能兼得。

對於寫擴散來說,多端同步就簡單些了。前端只需要記錄最後同步的位點,同步的時候帶上同步位點,然後伺服器就將該位點後面的資料全部返回給前端,前端更新同步位點就可以了。

如何處理未讀數

在IM系統中,未讀數的處理非常重要。未讀數一般分為會話未讀數跟總未讀數,如果處理不當,會話未讀數跟總未讀數可能會不一致,嚴重降低使用者體驗。

讀擴散

對於讀擴散來說,我們可以將會話未讀數跟總未讀數都存在後端,但後端需要保證兩個未讀數更新的原子性跟一致性,一般可以透過以下兩種方法來實現:

使用Redis的multi事務功能,事務更新失敗可以重試。但要注意如果你使用Codis叢集的話並不支援事務功能。使用Lua嵌入指令碼的方式。使用這種方式需要保證會話未讀數跟總未讀數都在同一個Redis節點(Codis的話可以使用Hashtag)。這種方式會導致實現邏輯分散,加大維護成本。

寫擴散

如果寫擴散也通過歷史會話列表來儲存未讀數的話那使用者時間線服務跟會話服務緊耦合,這個時候需要保證原子性跟一致性的話那就只能使用分散式事務了,會大大降低系統的效能。

如何儲存歷史訊息

讀擴散

對於讀擴散,只需要按會話ID進行Sharding儲存一份就可以了。

寫擴散

對於寫擴散,需要儲存兩份:一份是以使用者為Timeline的訊息列表,一份是以會話為Timeline的訊息列表。以使用者為Timeline的訊息列表可以用使用者ID來做Sharding,以會話為Timeline的訊息列表可以用會話ID來做Sharding。

資料冷熱分離

對於IM來說,歷史訊息的儲存有很強的時間序列特性,時間越久,訊息被訪問的機率也越低,價值也越低。

如果我們需要儲存幾年甚至是永久的歷史訊息的話(電商IM中比較常見),那麼做歷史訊息的冷熱分離就非常有必要了。資料的冷熱分離一般是HWC(Hot-Warm-Cold)架構。對於剛傳送的訊息可以放到Hot儲存系統(可以用Redis)跟Warm儲存系統,然後由Store Scheduler根據一定的規則定時將冷資料遷移到Cold儲存系統。獲取訊息的時候需要依次訪問Hot、Warm跟Cold儲存系統,由Store Service整合資料返回給IM Service。

接入層怎麼做

實現接入層的負載均衡主要有以下幾個方法:

硬體負載均衡:例如F5、A10等等。硬體負載均衡效能強大,穩定性高,但價格非常貴,不是土豪公司不建議使用。使用DNS實現負載均衡:使用DNS實現負載均衡比較簡單,但使用DNS實現負載均衡如果需要切換或者擴容那生效會很慢,而且使用DNS實現負載均衡支援的IP個數有限制、支援的負載均衡策略也比較簡單。DNS + 4層負載均衡 + 7層負載均衡架構:例如 DNS + DPVS + Nginx 或者 DNS + LVS + Nginx。有人可能會疑惑為什麼要加入4層負載均衡呢?這是因為7層負載均衡很耗CPU,並且經常需要擴容或者縮容,對於大型網站來說可能需要很多7層負載均衡伺服器,但只需要少量的4層負載均衡伺服器即可。因此,該架構對於HTTP等短連線大型應用很有用。當然,如果流量不大的話只使用DNS + 7層負載均衡即可。但對於長連線來說,加入7層負載均衡Nginx就不大好了。因為Nginx經常需要改配置並且reload配置,reload的時候TCP連線會斷開,造成大量掉線。DNS + 4層負載均衡:4層負載均衡一般比較穩定,很少改動,比較適合於長連線。

對於長連線的接入層,如果我們需要更加靈活的負載均衡策略或者需要做灰度的話,那我們可以引入一個排程服務,如下圖所示:

Access Schedule Service可以實現根據各種策略來分配Access Service,例如:

根據灰度策略來分配根據就近原則來分配根據最少連線數來分配架構心得

最後,分享一下做大型應用的架構心得:

灰度!灰度!灰度!監控!監控!監控!告警!告警!告警!快取!快取!快取!限流!熔斷!降級!低耦合,高內聚!避免單點,擁抱無狀態!評估!評估!評估!壓測!壓測!壓測!

原文連結:https://xie.infoq.cn/article/19e95a78e2f5389588debfb1c

5
  • 整治雙十一購物亂象,國家再次出手!該跟這些套路說再見了
  • 打通VoNR是什麼意思?