百萬級羣聊的設計實踐

作者:來自 vivo 互聯網服務器團隊 - Cai Linfeng

本文介紹了服務端在搭建 Web 版的百萬人級別的羣聊系統時,遇到的技術挑戰和解決思路,內容包括:通信方案選型、消息存儲、消息有序性、消息可靠性、未讀數統計。

一、引言

現在 IM 羣聊產品多種多樣,有國民級的微信、QQ,企業級的釘釘、飛書,還有許多公司內部的 IM 工具,這些都是以客戶端爲主要載體,而且羣聊人數通常都是有限制,微信正常羣人數上限是 500,QQ2000 人,收費能達到 3000 人,這裏固然有產品考量,但技術成本、資源成本也是很大的因素。而筆者業務場景上需要一個迭代更新快、輕量級(不依賴客戶端)、單羣百萬羣成員的純 H5 的 IM 產品,本文將回顧實現一個百萬人量級的羣聊,服務器側需要考慮的設計要點,希望可以給到讀者一些啓發。

二、背景介紹

不同的羣聊產品,採用的技術方案是不同的,爲了理解接下來的技術選型,需要先了解下這羣聊產品的特性。

  1. 單羣成員需要支撐百萬人,同時在線百萬級。

  2. 功能、體驗要接近純客戶端實現方案。

  3. 用戶端完全用 H5 承載。

三、通信技術

即時通信常見的通信技術有短輪詢、長輪詢、Server-Sent Events(SSE)、Websocket。短輪詢和長輪詢適用於實時性要求不高的場景,比如論壇的消息提醒。SSE 適用於服務器向客戶端單向推送的場景,如實時新聞、股票行情。Websocket 適用於實時雙向通信的場景,實時性好,且服務端、前端都有比較成熟的三方包,如 socket.io,所以這塊在方案選擇中是比較 easy 的,前後端使用 Websocket 來實現實時通信。

彩蛋提醒

我們爲大家準備了抽獎福利,請繼續閱讀下去。

四、消息存儲

羣聊消息的保存方式,主流有 2 種方式:讀擴散、寫擴散。圖 1 展示了它們的區別,區別就在於消息是寫一次還是寫 N 次,以及如何讀取。

讀擴散就是所有羣成員共用一個羣信箱,當一個羣產生一條消息時,只需要寫入這個羣的信箱即可,所有羣成員從這一個信箱裏讀取羣消息。

優點是寫入邏輯簡單,存儲成本低,寫入效率高。缺點是讀取邏輯相對複雜,要通過消息表與其他業務表數據聚合;消息定製化處理複雜,需要額外的業務表;可能還有 IO 熱點問題。

舉個例子:

很常見的場景,展示用戶對消息的已讀未讀狀態,這個時候公共羣信箱就無法滿足要求,必須增加消息已讀未讀表來記錄相關狀態。還有用戶對某條消息的刪除狀態,用戶可以選擇刪除一條消息,但是其他人仍然可以看到它,此時也不適合在公共羣信箱裏拓展,也需要用到另一張關係表,總而言之針對消息做用戶特定功能時就會比寫擴散複雜。

寫擴散就是每個羣成員擁有獨立的信箱,每產生一條消息,需要寫入所有羣成員信箱,羣成員各自從自己的信箱內讀取羣消息。
優點是讀取邏輯簡單,適合消息定製化處理,不存在 IO 熱點問題。缺點是寫入效率低,且隨着羣成員數增加,效率降低;存儲成本大。

所以當單羣成員在萬級以上時,用寫擴散就明顯不太合適了,寫入效率太低,而且可能存在很多無效寫入,不活躍的羣成員也必須得有信箱,存儲成本是非常大的,因此採用讀擴散是比較合適的。

據瞭解,微信是採用寫擴散模式,微信羣設定是 500 人上限,寫擴散的缺點影響就比較小。

五、架構設計

5.1 整體架構

先來看看羣聊的架構設計圖,如圖 2 所示:

從用戶登錄到發送消息,再到羣用戶收到這條消息的系統流程如圖 3 所示:

  1. 用戶登錄,通過負載均衡,與連接服務建立 Websocket 長連接。

  2. 連接服務管理會話,管理羣與用戶的映射關係,在本地內存裏使用哈希表存儲,key 爲 groupId,value 爲 List,同一個羣的用戶可能會在不同的集羣服務器上。

  3. 連接服務向羣組服務上報羣組路由,上報它的內網 IP 和它所管理的 groupIdList 的關係,這裏需要 2 種同步策略並行保證羣組路由信息的準確性:a. 在用戶建立、斷開長連接時即刻上報;b. 定時上報。

  4. 羣組服務管理羣組路由,使用遠程中心緩存 Redis 管理 groupId 和連接服務器 IP 的關係,key 爲 groupId,value 爲 List,該 IP 爲連接服務的內網 IP 地址,這裏會做上報的心跳判斷,超過 3 個心跳週期不上報,則認爲已斷線。

  5. 用戶在羣裏發佈一條消息,消息通過 Websokcet 送達連接服務,然後經過連接服務——> 消息隊列——> 羣組服務,消息在羣組服務裏經過頻控、安全檢查、格式轉換等一系列流程後入庫,持久化。

  6. 羣組服務通過羣組路由管理獲取這條消息所屬羣的路由信息,即一組連接服務的 IP 地址,然後通過 HTTP 回調對應的連接服務,通知它們有新消息產生,這裏只簡單傳遞消息 ID。

  7. 連接服務收到 HTTP 請求後,根據會話管理查詢該羣所有用戶,給用戶發送新消息提醒。

  8. 用戶收到新消息提醒,通過 Websocket 來連接服務拉取該新消息具體詳情,然後根據消息協議展示在信息流裏。

5.2 路由策略

用戶應該連接到哪一臺連接服務呢?這個過程重點考慮如下 2 個問題:

  1. 儘量保證各個節點的連接均衡;

  2. 增刪節點是否要做 Rebalance。

保證均衡有如下幾個算法:

  1. 輪詢:挨個將各個節點分配給客戶端,但會出現新增節點分配不均勻的情況;

  2. 取模:類似於 HashMap,但也會出現輪詢的問題。當然也可以像 HashMap 那樣做一次 Rebalance,讓所有的客戶端重新連接。不過這樣會導致所有的連接出現中斷重連,代價有點大。由於 Hash 取模方式的問題帶來了一致性 Hash 算法,但依然會有一部分的客戶端需要 Rebalance;

  3. 權重:可以手動調整各個節點的負載情況,甚至可以做成自動的,基於監控當某些節點負載較高就自動調低權重,負載較低的可以提高權重;
    筆者是採用輪詢 + 權重模式,儘量保證負載均衡。

5.3 重連機制

當應用在擴縮容或重啓升級時,在該節點上的客戶端怎麼處理?
由於設計有心跳機制,當心跳不通或監聽連接斷開時,就認爲該節點有問題了,就嘗試重新連接;如果客戶端正在發送消息,那麼就需要將消息臨時保存住,等待重新連接上後再次發送。

5.4 線程策略

將連接服務裏的 IO 線程與業務線程隔離,提升整體性能,原因如下:

  1. 充分利用多核的並行處理能力:IO 線程和業務線程隔離,雙方都可以並行處理網絡 IO 和業務邏輯,充分利用計算機多核並行計算能力,提升性能;

  2. 故障隔離:業務線程處理多種業務消息,有 IO 密集型,也有 CPU 密集型,有些是純內存計算,不同的業務處理時延和故障率是不同的。如果把業務線程和 IO 線程合併,就會有如下問題:某類業務處理較慢,阻塞 IO 線程,導致其他處理較快的業務消息響應不及時;

  3. 可維護性:IO 線程和業務線程隔離之後,職責單一,有利於維護和定位問題。

5.5 有狀態鏈接

在這樣的場景中不像 HTTP 那樣是無狀態的,需要明確知道各個客戶端和連接的關係。比如需要向客戶端廣播羣消息時,首先得知道客戶端的連接會話保存在哪個連接服務節點上,自然這裏需要引入第三方中間件來存儲這個關係。通過由連接服務主動上報給羣組服務來實現,上報時機是客戶端接入和斷開連接服務以及週期性的定時任務。

5.6 羣組路由

設想這樣一個場景:需要給羣所有成員推送一條消息怎麼做?
通過羣編號去前面的路由 Redis 獲取對應羣的連接服務組,再通過 HTTP 方式調用連接服務,通過連接服務上的長連接會話進行真正的消息下發。

5.7 消息流轉

連接服務直接接收用戶的上行消息,考慮到消息量可能非常大,在連接服務裏做業務顯然不合適,這裏完全可以選擇 Kafka 來解耦,將所有的上行消息直接丟到 Kafka 就不管了,消息由羣組服務來處理。

六、消息順序

6.1 亂序現象

爲什麼要講消息順序,來看一個場景。假設羣裏有用戶 A、用戶 B、用戶 C、用戶 D,下面以 ABCD 代替,假設 A 發送了 3 條消息,順序分別是 msg1、msg2、msg3,但 B、C、D 看到的消息順序不一致,如圖 4 所示:

這時 B、C、D 肯定會覺得 A 在胡言亂語了,這樣的產品用戶必定是不喜歡的,因此必須要保證所有接收方看到的消息展示順序是一致的。

6.2 原因分析

所以先了解下消息發送的宏觀過程:

  1. 發送方發送消息。

  2. 服務端接收消息。

  3. 服務端返回 ACK 消息。

  4. 服務端推送新消息或客戶端拉取新消息。

在上面的過程中,都可能產生順序問題,簡要分析幾點原因:

  1. 時鐘不一致:多個客戶端、服務端集羣、DB 集羣,時鐘不能保證完全一致,因此不能用本地時間來決定消息順序。

  2. 網絡傳輸:發送消息環節,先發後至,到達服務器的順序可能是 msg2、msg1、msg3。

  3. 多線程:服務器考慮性能、吞吐量,往往會在多處環節採用線程池、異步去提升整體速度,因此也會產生順序問題。

6.3 解決方案

6.3.1 單用戶保持有序

通過上面的分析可以知道,其實無法保證或是無法衡量不同用戶之間的消息順序,那麼只需保證同一個用戶的消息是有序的,保證上下文語義,所以可以得出一個比較樸素的實現方式:以服務端數據庫的唯一自增 ID 爲標尺來衡量消息的時序,然後讓同一個用戶的消息處理串行化。那麼就可以通過以下幾個技術手段配合來解決:

  1. 發送消息使用 Websocket 發送,並且多次發送保持同一個會話,那麼 tcp 協議就保證了應用層收到的消息必定是有序的。

  2. 在應用程序內部處理時,涉及相關多線程的模塊,根據 uid 進行 hash,匹配一個單線程的線程池,即同一個 uid 的消息永遠用同一個線程去處理,不同用戶之間仍是並行處理。

  3. 在跨應用程序時,一般有 2 種處理方式:一是用 rpc 同步調用;二是利用消息中間件的全局有序。

  4. 用戶端上做消息發送頻率限制,2 次發送必須間隔 1 秒,能大大降低亂序的可能性了。

6.3.2 推拉結合

到這裏基本解決了同一個用戶的消息可以按照他自己發出的順序入庫的問題,即解決了消息發送流程裏第一、二步。

第三、四步存在的問題是這樣的:

A 發送了 msg1、msg2、msg3,B 發送了 msg4、msg5、msg6,最終服務端的入庫順序是 msg1、msg2、msg4、msg3、msg5、msg6,那除了 A 和 B 其他人的消息順序需要按照入庫順序來展示,而這裏的問題是服務端考量推送吞吐量,在推送環節是併發的,即可能 msg4 比 msg1 先推送到用戶端上,如果按照推送順序追加來展示,那麼就與預期不符了,每個人看到的消息順序都可能不一致,如果用戶端按照消息的 id 大小進行比較插入的話,用戶體驗將會比較奇怪,突然會在 2 個消息中間出現一條消息。所以這裏採用推拉結合方式來解決這個問題,具體步驟如下:

  1. 用戶端發出消息,服務端將消息以羣維度按照消息的入庫順序緩存在 Redis 有序 SET。

  2. 服務端推送給用戶端新消息提醒,內容是該新消息的 id。

  3. 用戶端拉取消息,攜帶 2 個消息 id,startId 和 endId,startId:本地最新的完整消息 id;endId:服務端推送得到的新消息 id。

  4. 服務端返回 2 個消息 id 區間內的消息列表。

舉例,圖 5 表示服務端的消息順序,圖 6 表示用戶端拉取消息時本地消息隊列和提醒隊列的變化邏輯。

  1. t1 時刻用戶本地最新的完整消息是 msg1,即這條消息已經完整展示給用戶。

  2. t2 時刻收到服務端推送的 msg3 新消息提醒,放到提醒隊列,此時用戶看不到這條消息。

  3. t3 時刻向服務端拉取消息詳情,請求參數爲 startId:msg1,endId:msg3,服務端會按順序一起返回 2 個消息區間內的所有消息的詳情即 msg2、msg4、msg3,將消息詳情同步寫入到消息隊列,此時用戶可以看到刷新出 3 條消息。

  4. t4 時刻用戶還會收到 msg2、msg4 的新消息提醒,用戶端校驗消息隊列已經存在 msg2、msg4 的詳情,忽略該新消息提醒。

通過推拉結合的方式可以保證所有用戶收到的消息展示順序一致。細心的讀者可能會有疑問,如果聊天信息流裏有自己發送的消息,那麼可能與其他的人看到的不一致,這是因爲自己的消息展示不依賴拉取,需要即時展示,給用戶立刻發送成功的體驗,同時其他人也可能也在發送,最終可能比他先入庫,爲了不出現信息流中間插入消息的用戶體驗,只能將他人的新消息追加在自己的消息後面。所以如果作爲發送者,消息順序可能不一致,但是作爲純接收者,大家的消息順序都是一樣的。

七、消息可靠性

在 IM 系統中,消息的可靠性同樣非常重要,它主要體現在:

  1. 消息不丟失:對發送人來說,必須保證消息能入庫;對接收者來說,不管是在線還是離線,都能保證收到。但是這裏的不丟失,只是說以最大努力去保證,並不是說完全不丟失。

  2. 消息不重複:這很容易理解,同一條消息不能重複出現。

7.1 消息不丟失設計

  1. 傳輸協議保障:首先 TCP 是可靠的協議,能較大程度上保證消息不丟失。

  2. 增加 ACK 機制:服務端在執行完消息處理的所有流程後,給發送者發送 ACK;假如發送者在超時時間內沒有收到 ACK 消息,則進行一定次數的重試,重新發送;當重發次數超過預設次數,就不再重發,消息發送失敗。

  3. 最終一致性:這是對接收者而言,如果某條新消息提醒因網絡等其他原因丟失,用戶沒有收到這條消息提醒,那麼用戶就不會去拉消息詳情,在用戶視角就是沒有看到這條消息。但是當後續的新消息提醒送達時,可以依賴前面提到的拉取機制拿到一個區間內的消息列表,這裏就包含了丟失的消息,因此能達到最終一致性。

7.2 消息不重複設計

  1. 增加 UUID:每條消息增加 UUID,由客戶端創建消息時生成,同一個用戶的消息 UUID 唯一。

  2. 服務端:用戶 ID+UUID 在數據庫做聯合唯一索引,保證數據層面消息不重複。

  3. 用戶端:進行兜底,構造一個 map 來維護已接收消息的 id,當收到 id 重複的消息時直接丟棄。

八、未讀數統計

爲了提醒用戶有新消息,需要給用戶展示新消息提醒標識,產品設計上一般有小紅點、具體的數值 2 種方式。具體數值比小紅點要複雜,這裏分析下具體數值的處理方式,還需要分爲初始打開羣和已打開羣 2 個場景。

已打開羣:可以完全依賴用戶端本地統計,用戶端獲取到新消息後,就將未讀數累計加 1,等點進去查看後,清空未讀數統計,這個比較簡單。

初始打開羣:由於用戶端採用 H5 開發,用戶端沒有緩存,沒有能力緩存最近的已讀消息遊標,因此這裏完全需要服務端來統計,在打開羣時下發最新的聊天信息流和未讀數,下面具體講下這個場景下該怎麼設計。

既然由服務端統計未讀數,那麼少不了要保存用戶在某個羣裏已經讀到哪個消息,類似一個遊標,用戶已讀消息,遊標往前走。用戶已讀消息存儲表設計如圖 7 所示:

遊標 offset 採用定時更新策略,連接服務會記錄用戶最近一次拉取到的消息 ID,定時異步上報批量用戶到羣組服務更新 offset。

該表第一行表示用戶 1 在 id=89 的羣裏,最新的已讀消息是 id=1022 消息,那麼可以通過下面的 SQL 來統計他在這個羣裏的未讀數:
select count(1) from msg_info where groupId = 89 and id > 1022。但是事情並沒這麼簡單,一個用戶有很多羣,每個羣都要展示未讀數,因此要求未讀數統計的程序效率要高,不然用戶體驗就很差,很明顯這個 SQL 的耗時波動很大,取決於 offset 的位置,如果很靠後,SQL 執行時間會非常長。筆者通過 2 個策略來優化這個場景:

  1. 調整產品設計:未讀數最大顯示調整爲 99+。算是產品上的一個讓步,有很多產品也採用這個方案,所以用戶也是有這個心智的,99 + 表示 “有很多新消息”,至於具體多少,是幾百、幾千很多時候不是特別重要。所以問題就變得簡單多了,只要計算遊標是否在最新的 100 條消息以內還是以外。

  2. 合理利用數據結構:因爲有羣內有很多人,每個人登錄的時候都需要統計,所以每次都去查 MySQL 是比較低效的,因此筆者的方案是在 Redis 裏設計一個有界的 ZSET 結構。

如上圖 8 所示,每個羣都會構建一個長度爲 100,score 和 member 都是消息 ID,可以通過 zrevrank 命令得到某個 offset 的排名值,該值可以換算成未讀數。比如:用戶 1 在羣 89 的未讀消息數,'zrevrank 89 1022' = 2,也就是有 2 條未讀數。用戶 2 在羣 89 的未讀數,'zrevrank 89 890' = nil,那麼未讀數就是 99+。同時消息新增、刪除都需要同步維護該數據結構,失效或不存在時從 MySQL 初始化。

九、超大羣策略

前面提到,設計目標是在同一個羣裏能支撐百萬人,從架構上可以看到,連接服務處於流量最前端,所以它的承載力直接決定了同時在線用戶的上限。

影響它的因素有:

  1. 服務器自身配置:內存、CPU、網卡、Linux 支持的最大文件打開數;

  2. 應用自身配置:應用本身啓動需要的內存,如 Netty 依賴的堆外內存,大量的本地緩存;

  3. 性能要求:當連接數不斷變大時,消息分發的整體耗時肯定在不斷增加,因此要關注最慢的分發耗時要滿足即時性要求;
    結合以上情況,可以測試出固定配置服務器單點能支持的最大用戶連接數,假如單機能支持 20000 個用戶連接,那麼百萬在線連接,在連接服務層用 50 個服務的集羣就能解決。

9.1 消息風暴

當同時在線用戶數非常多,例如百萬時,會面臨如下幾個問題:

  1. 消息發送風暴:極端情況下,用戶同時發送消息,假設服務端承載住了這些流量,那麼瓶頸其實在用戶端,第一用戶端會經歷網絡風暴,網卡帶寬能否支撐是一個大問題;第二假設網卡能通過這些流量,用戶端上百萬條消息該如何展示,要是瞬間刷出這些消息,用戶端 CPU 能否撐住又是個問題,即使能抗住用戶體驗也很糟糕,根本就看不清消息,一直在飛速刷屏。因此服務端可以在發送消息風暴時做好限流、丟棄策略,給到用戶友好的提示。

  2. 消息提醒風暴:一條新消息的產生,就要推送提醒消息百萬次,對服務器來說,要考量整體推送完成的時效性,如果時效性差,對有些用戶來說,就是消息需要較長時間才刷出來,出現明顯的延遲。新消息持久化後,羣組服務 HTTP 回調一組連接服務,單羣百萬在線用戶,需要 50 臺連接服務集羣,那麼回調 50 次,爲了保證時效性,因此這裏要併發回調,並設置合理的線程池,然後連接服務收到回調後也需要併發完成對羣用戶的新消息提醒推送。

  3. 消息拉取風暴:連接服務收到拉取消息事件,需要去羣組服務獲取消息詳情,QPS 就非常高了,理論上集羣達到 100wQPS,20 臺羣組服務,那麼每臺羣組服務就是 5wQPS。這裏的策略是在鏈路前端連接服務上進行流量過濾,因爲用戶都是請求同一個羣的同一條消息或附近的消息,那麼就可以在連接服務裏設計羣消息的本地緩存,所有用戶都只從本地緩存裏讀,如果本地緩存裏沒有,就放一個線程去羣組服務請求加載緩存,其他線程同步等待,這樣就大大降低了打到羣組服務的 QPS。

9.2 消息壓縮

如果某一個時刻,推送消息的數量比較大,且羣同時在線人數比較多的時候,連接服務層的機房出口帶寬就會成爲消息推送的瓶頸。

做個計算,百萬人在線,需要 5 臺連接服務,一條消息 1KB,一般情況下,5 臺連接服務集羣都是部署在同一個機房,那麼這個機房的帶寬就是 1000000*1KB=1GB,如果多幾個超大羣,那麼對機房的帶寬要求就更高,所以如何有效的控制每一個消息的大小、壓縮每一個消息的大小,是需要思考的問題。

經過測試,使用 protobuf 數據交換格式,平均每一個消息可以節省 43% 的字節大小,可以大大節省機房出口帶寬。

9.3 塊消息

超大羣裏,消息推送的頻率很高,每一條消息推送都需要進行一次 IO 系統調用,顯然會影響服務器性能,可以採用將多個消息進行合併推送。

主要思路:以羣爲維度,累計一段時間內的消息,如果達到閾值,就立刻合併推送,否則就以勻速的時間間隔將在這個時間段內新增的消息進行推送。

時間間隔是 1 秒,閾值是 10,如果 500 毫秒內新增了 10 條消息,就合併推送這 10 條消息,時間週期重置;如果 1 秒內只新增了 8 條消息,那麼 1 秒後合併推送這 8 條消息。
這樣做的好處如下:

  1. 提升服務器性能:減少 IO 系統調用,減少用戶態與內核態之前的切換;

  2. 減少傳輸量:合併消息後,可以減少傳輸多餘的消息頭,進一步壓縮消息大小;

  3. 提升用戶體驗:一定程度上能減小消息風暴,消息渲染的節奏比較均勻,帶給用戶更好的體驗;

十、總結

在本文中,筆者介紹了從零開始搭建一個生產級百萬級羣聊的一些關鍵要點和實踐經驗,包括通信方案選型、消息存儲、消息順序、消息可靠性、高併發等方面,但仍有許多技術設計未涉及,比如冷熱羣、高低消息通道會放在未來的規劃裏。IM 開發業界沒有統一的標準,不同的產品有適合自己的技術方案,希望本文能夠帶給讀者更好地理解和應用這些技術實踐,爲構建高性能、高可靠性的羣聊系統提供一定的參考。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/qc7PVG0kzpsI9O5KTiDpMQ