貝殼 IM 羣聊優化之路

介紹

貝殼 IM 爲貝殼找房提供了 70% 以上的線上商機。爲上百萬的經紀人提供了快捷的線上獲客渠道。日新增會話 300 萬 +。其既有互聯網 TO C 產品的屬性,又具有濃厚的房地產行業特色,是貝殼找房所屬的產業互聯網中重要的一環。

IM 系統相比其他服務有其特殊的特點,主要包括實時性、有序性、可靠性(不重複不丟失)、一致性(多端漫遊同步)、安全性。爲保證這些特性的實現,良好的性能是必不可少的。本文主要闡述了針對貝殼 IM 單聊羣聊消息的優化思路,通過壓測、尋找瓶頸點、提出優化方案、驗證優化方案、代碼實現的多輪次迭代,最終實現了 20 倍以上的性能提升。

背景

2020 年底的時候有業務方提出使用羣聊消息進行推廣活動,該場景預估 300 人大羣需要滿足 100QPS 的性能要求。接到需求,我們首先對羣聊場景進行了摸底壓測,效果爲 300 人大羣 QPS 爲 15 的時候系統內消息處理就會出現積壓, 投遞能力到達瓶頸。因此拉開了優化的序幕。

IM 系統整體概覽

我們首先簡要介紹下貝殼 IM 的整體架構,一是便於理解 IM 消息服務難在哪,二是便於看出 IM 消息服務的瓶頸點在哪。

圖片

上圖是 IM 消息發送的整體流程:

發送者通過 http 接口發送消息,接口處理完成寫入發送隊列後返回給用戶成功信息。此時後臺的投遞服務實時從隊列獲取待投遞消息,然後進行如下四步操作:

通過貝殼 IM 架構,可以看到消息發送到消息送達整體是一個異步服務,分爲兩個部分,一是接口層,二是投遞服務,兩者之間通過隊列通信。接口層只需要寫入隊列即返回成功,無狀態可以橫向擴展,不會成爲羣聊的瓶頸。投遞服務功能複雜並且會直接影響消息處理的及時性、可靠性。

瞭解過 IM 的同學可能會問,是否可以將寫擴散模式更改爲讀擴散,即 300 人大羣中發送一條消息之後,不再寫入每個人的收件箱,而是隻記錄到一個發件箱中,讀取的時候每個人去發件箱中讀取。讀擴散方式下,假設一個人加入了 100 個羣,那麼讀取時需要將 100 個羣的發件箱都讀取一遍,而之前的寫擴散模式,只需要在收件箱中按序列號讀取一次即可。兩者各有優劣勢,通過調研發現雲廠商以及微信服務羣聊均是在寫擴散模式下實現,證明寫擴散可行,並且對貝殼 IM 來說,繼續使用寫擴散成本可控。因此在整體架構不變的情形下我們進行了一系列優化,最終也達到了預期效果。

優化措施一: 業務隔離

IM 的消息按照業務屬性主要分爲三種:

消息投遞服務負責從 redis 隊列消費用戶發送的消息,進行處理。舉個例子, A/B/C 三人在同一會話中, A 向會話中發送一條消息, 投遞服務需要保證這條消息被寫入 A/B/C 三人各自的收件箱和歷史消息庫, 並通過 (push / 長連接) 通知 A/B/C。第一次摸底壓測出現 redis 隊列積壓,會導致用戶不能及時收到消息,這個是不可接受的。在業務側,針對我們公司的業務場景特點,最重要的是客戶和經紀人的單聊消息。從業務隔離角度考量,進行的第一個優化措施,是對單聊和羣聊進行垂直拆分,分級隔離後的效果圖如下:

圖片

優化措施二: 提高併發

我們繼續深入投遞服務的細節,看看投遞服務的代碼實現邏輯。總體來看,投遞服務消費一條消息後,會經歷兩個階段,每個階段分別由對應的 goroutine 來完成, 階段一有 256 個 goroutine, 階段二 有 1000 個 goroutine,兩個處理流程之間通過 channel 進行通信。兩階段的流程圖如下:

圖片

還是以一個 300 人的羣 GroupA 爲例,假設發送者 SenderA 發送了一個消息,階段一獲取到的消息是會話維度,即 GroupA(會話 ID 標識)的 SenderA(UCID 標識)發送了一條內容爲 xxx 的消息,階段一的主要工作是根據會話 ID 獲取到羣內的所有成員 UCID,然後將 UCID 哈希之後打散放到 1000 個 channel 中,此時 channel 中的消息是用戶維度,即用戶 ReceiveB 收到了一條內容爲 xxx 的消息。注意階段一此時還進行了一個擴散操作,即將每個收件人的未讀數計數進行了更新操作。階段二從 channel 中獲取消息之後將其放入羣內 300 個成員各自的收件箱,然後給每個人發送 push 以及長連接信息,通知收件人拉取消息。

可以看到階段一是會話消息維度的處理,IO 操作較少,寫擴散主要有兩步,一是進行一個內存 channel 的寫入,二是以收件人維度進行了未讀數的更新。階段二是收件人維度的消息處理,併發度更大(1000 個 goroutine),IO 操作也較多,例如寫入收件人收件箱,更新會話順序,向每個收件人都發送 push 通知和長連接通知。

優化措施

壓測時觀察到只有發送 Redis 隊列有積壓,channel 隊列沒有積壓,並且階段二併發能力更強,考慮將階段一中比較耗時的更新用戶未讀數操作放在在階段二進行。

效果

通過將更新用戶未讀數放到階段二,其實相當於增大了處理未讀數的併發能力,上線後進行壓測,數據爲 300 人羣,羣聊消息發送可以達到 75QPS,有 5 倍的性能提升。當 300 人大羣發送 QPS 達到 70 時,Redis 側 QPS 爲 21000,此時 IM 中使用的一組緩存 redis cpu 被打滿,單核使用率達到 92%(注意 redis6.0 版本之前只能使用單核,因此單核 cpu 使用率是 redis 一個很重要的觀察維度)。

優化措施三: 減少計算

進行第二項優化後,300 人的羣聊可以達到 75QPS,此時一組緩存的 redis 實例 cpu 基本被打滿。該組 redis 主要負責存儲用戶關係, 在投遞服務階段二發送 push 的業務邏輯中會大量使用。

問題

以投遞一條消息到 300 人的大羣爲例,假設羣爲 GroupA,發送者 SenderA 發送了一個消息,階段二中給每個收件人發送 push 的業務邏輯中存在重複獲取數據的情況,例如:

由於羣聊是從單聊代碼改造而來,可以看到簡單重複單聊的邏輯,會造成寫擴散的放大。

優化措施

針對重複獲取數據,進行相應的優化措施,SenderA 的用戶信息和 GroupA 的會話信息可以提前獲取一次,發送 push 時直接使用。用戶是否設置了免打擾,原來是從用戶到羣組的映射關係,通過增加一個羣組到用戶的反向映射關係,可以通過一個羣組直接獲取到全部設置了免打擾的用戶,減少計算量。

效果

通過合併業務數據,相當於減少了業務處理的計算量,該改進上線後進行壓測,300 人羣, 發消息 150QPS,對比優化二有了 2 倍的性能提升,對比初始值有 10 倍的性能提升。此時寫入信箱 QPS 可以達到 45k。

此時,IM 使用的另一組 Redis 出現了告警,單核 CPU 使用率超出 90%,並且有大量 redis 慢查和超時報警。通過監控發現,CPU 被打滿時,redis 每秒建連數達到 8k/s, 總連接數增長至 20k,QPS 爲 45k,通過和 DBA 同學溝通,這種情況下 redis 有一大部分 cpu 都被消耗在連接的建立與銷燬。奇怪的是 IM 中使用了 Redis 連接池,爲什麼還會有大量的新建連接呢?Redis 的極限 QPS 能夠達到多少?

優化措施四: Redis 連接池優化

排查過程

貝殼 IM 中 redis 庫是使用連接池,目的就是爲了避免新建銷燬連接帶來的消耗。目前使用的 redis 庫比較老,結合之前線上存在偶發的 redis 慢查報錯,首先懷疑是連接池的實現問題。

驗證

爲了方便在測試環境模擬復現 redis 大量建連問題,開發了一個模擬投遞程序可以指定發消息 qps 和參與會話人數。

如下是 im 使用的 redis 庫(簡寫爲 imredis)與業界比較成熟的 goredis 庫數據對比

圖片

通過測試數據發現如下三點:

連接池缺陷

// Get retrieves an available redis client. If there are none available it will  
// create a new one on the fly  
func (p *Pool) Get() (*redis.Client, error) {    
  select {    
    case conn := <-p.pool:      
      return conn, nil    
    default:      
      return p.df(p.Network, p.Addr, p.Auth, p.Db, p.Timeout)    
  }  
}

通過查看 imredis 連接池代碼,發現雖然初始化時會指定最大連接數量,但當 QPS 升高並且接池中獲取不到連接時,會新建連接進行處理。

優化措施

參考 goredis 和其他連接池實現, 採取以下優化措施:

備用連接池。大小是默認連接池的 10 倍,如果默認連接池滿了, 連接會暫時存放在備用連接池,每過固定時間對備用連接池內的連接進行釋放。避免在大量併發時頻繁新建和關閉連接。

對新建連接通過令牌桶進行限制,避免短時間內無限制大量建立連接,導致拖垮服務。

最終效果

壓測驗證 300 人大羣可以達到 320qps 左右。投遞能力較優化三有兩倍的性能提升,較去年年底有 20 倍的性能提升

總結

羣聊 300 人大羣達到 320qps,已經能夠滿足未來兩年內的業務需求。並且隨着羣聊的優化,尤其是 Redis 連接池的優化,單聊的性能也有了顯著提升,從年初的 2000 達到了 12000。未來如果有繼續提高性能的需求,只需要對 redis 進行雙倍擴容,並且將投遞服務也進行擴容即可。

通過本次優化,可以看到,大部分的性能優化只需要進行代碼層面的改造,通過壓測找到瓶頸點,摸清整個鏈路的短板並改造這個短板。此時 QPS 就只是一個數值。需要更高的 QPS 時只需要擴容、擴容、再擴容即可。

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