老舊業務重構案例——IM系統如何設計

葉小釵發表於2022-01-09

一年半之前剛來到這個團隊,便遭遇了一次挑戰:

當時有個CRM系統,老是出問題,之前大的優化進行了4次小的優化進行了10多次,要麼BUG重複出現,要麼效能十分拉胯,總之體驗是否糟糕!技術團隊因此受到了諸多質疑,也成了我這邊過來外部的第一槍。

當時排查下來,問題反覆的核心原因是:

該系統依賴一個核心IM系統;這套IM系統已經有幾年時間,之前的同學一來是沒有魄力去做重構,二來是沒有能力做重構,所以每次只能小打小鬧,但裡面的服務旁枝錯節,總有依賴服務沒被修復好。

考慮這種情況,我這邊派出了兩支團隊,一隻由小孫帶團,給一個月時間做完整重構勢必解決問題;一隻之前的小分隊,應付一下業務團隊即可,而真實業務端的壓力以及上層的質疑,由我一肩挑起,我這裡畢竟是新來的,由一個耍賴皮的視窗期,以下是重新設計的核心模組。

​— 1 業務梳理

線上上,醫患溝通、患者與健康管理師、醫生和醫生助理溝通,這些遠端交流的場景離不開IM,它是這些溝通一個基礎建設。

健康管理師和醫生助理在協助醫生幫助患者的場景下,他們使用的工作臺是讓線上的隨診、問診快速方便的開展相關重要功能。

如果要做這麼一個滿足現在業務場景的工作臺需要怎麼來實現及優化,以下講解如何搭建和優化工作臺IM核心功能。

老舊業務重構案例——IM系統如何設計

 2 IM核心架構

第一個問題業務底層的IM架構是如何的?

下圖中分了三種型別的服務一種是comet,一種是gateway以及內部使用grape-http。

comet:推送核心是長連結接實時推送,comet主要是作為tcp/websocket的一個接入層足夠簡單負責客戶端長連結的維護連結保活的心跳機制和訊息下行推送,同時還具備一些比如重鏈的一些特殊指令下發;

gateway:為了減少服務的複雜度將訊息上行的功能抽到http介面來承接,包括登陸功能、comet叢集負載均衡,傳送圖片、音視訊,基礎資訊查詢等。

grape-http:作為內部其他服務呼叫comet推送的內部服務。

老舊業務重構案例——IM系統如何設計

需要特別注意:

1)網路傳輸大小端;

在網路傳輸中需要注意大小端問題,什麼是大小端?

對於一個由2個位元組組成的16位整數,在記憶體中儲存這兩個位元組有兩種方法:

一種是將低序位元組儲存在起始地址,這稱為小端(little-endian)位元組序;

另一種方法是將高序位元組儲存在起始地址,這稱為大端(big-endian)位元組序。

總而言之,大端是高位元組存放到記憶體的低地址;小端是高位元組存放到記憶體的高地址,在網路通訊中,不同的大小端CPU需要做資料處理再進行傳輸。

2)TCP粘包;

在socket網路程式設計中,都是端到端通訊,由客戶端埠、服務端埠、客戶端IP、服務端IP和傳輸協議組成的五元組可以明確的標識一條連線。

在TCP的socket程式設計中,傳送端和接收端都有成對的socket,傳送端為了將多個發往接收端的包,更加高效的的發給接收端,於是採用了優化演算法(Nagle演算法),將多次間隔較小、資料量較小的資料,合併成一個資料量大的資料塊,然後進行封包。

那麼這樣一來,接收端就必須使用高效科學的拆包機制來分辨這些資料。

解決方式:

第一個種特定分割符格式化資料,每條資料有固定的格式(開始符,結束符)。這種方式簡單,但是選擇符號時一定要確保每條資料的內部不包含這些分隔符;

第二種自定義協議傳送定長資料,傳送每條資料時,將資料長度一併傳送,例如規定資料的前4位的資料的長度,應用層在處理時可以根據長度來判斷每個分組的開始和結束位置,如下圖自定義協議格式:老舊業務重構案例——IM系統如何設計

 3 核心流程描述

上面說了整體的架構以及網路程式設計中需要注意的大小端和TCP粘包問題,接下來描述下大致的流程

1)客戶端首先登陸,此處是採用http的方式進行登陸和鑑權

2)鑑權成功後,會返回一個comet的列表ip加埠的列表,客戶端可以選擇1個節點進行接入,通常負載最少的排在最前面,進行tcp/websocet的Auth認證通過之後連結上一個comet的節點。

3)此時已經建立了長連結,客戶端可以通過http介面傳送文字訊息、圖片、視訊、語音訊息,其中圖片、語音、視訊都是傳到cdn上,然後將連結地址放在訊息體中傳送。

4)當comet某個節點掛掉了,客戶端會嘗試重新獲取comet節點列表進行連結,如果多次都沒有可用節點或者連結不成功,會告知使用者服務連結失敗。

5)如果comet的節點需要進行灰度升級,服務端會先加入新節點,然後灰度下線某個節點,下線的n節點會分批向該節點連結的client,傳送重鏈的指令讓客戶端無損的方式斷開重新連結其他節點,當client都轉移到其他節點上之後,節點自動退出。

TCP連結是有連結的和http不一樣,為了comet高可用做多個comet的叢集不像http的負載均衡那麼簡單可以在前面掛一個nginx,每個client連結上一個comet的節點上,要推送到指定的client訊息,需要判斷這個client是連結到哪一個節點上的,comet的叢集為了讓所有節點更均衡採用了一致性hash演算法的方式來進行comet負載均衡

老舊業務重構案例——IM系統如何設計

這樣重新加入或者減少comet節點,需要client發起重鏈的就會變少。

 4業務心跳連結保火

先看下comet如何維持連結的存活,TCP協議自身已經有KeepAlive機制,難道不能保持連結存活麼?

為什麼需要應用層做心跳,這是TCP KeepAlive的機制決定的,KeepAlive存在一個探針以確定連結的可靠性,一般時間為7200s,失敗後重試10次,每次超時時間75s,預設值無法滿足我們的需求,即使修改設定後還是不能滿足,TCP KeepAlive是用於檢測連結的死活,而心跳機制則有一些業務的額外功能,檢測通訊雙方的可用的存活狀態,比如TCP是連結成功的。

但是伺服器已經CPU使用率100%無法處理業務了,此時連結成功,但是業務上是失敗的,基本上心跳回復不了。

在我們comet中有TCP/websocket的心跳機制,簡單的做法是客戶端定時心跳,比如間隔30s發起一次Ping訊息,服務端回覆Pong訊息,如果15s內沒有收到心跳Pong訊息,則此連結失效,需要斷開之後重新進行連結。

為了節省流量,心跳包要足夠小,並且頻率也不能太高,儘量拉長心跳間隔,5分鐘,10分鐘,或者更加優化的方式是5分鐘內沒有和伺服器互動訊息空閒才會觸發心跳邏輯,減少請求次數,移動端需要考慮心跳定時的範圍耗電等資源消耗。

 5 訊息時序&一致性

嚴格需要時序的場景:訊息傳送走的http上行,如何保證群訊息的有序性和一致性,根據群id進行sharding到單點序列化寫db的inc_id生成的遞增id,返回後進入推送的有序佇列中進行訊息下行階段

非嚴格時序場景:分散式叢集下,採用分散式id生成器進行遞增生成,每個群需要id序列。

分散式場景下,訊息的有序性很難,原因很多,時鐘不一致,多傳送方,多接收方,訊息量大網路傳輸問題等。也可以要有序可以客戶端,或者服務端來進行有序標誌

絕對有序場景需要嚴格控制id有序生成,單對單聊天,只需保證發出的時序與接收的時序一致,可以利用客戶端有序;群聊,只需保證所有接收方訊息時序一致,需要利用服務端seq,方法有兩種,一種單點絕對時序,另一種id序列化。

 6訊息丟失問題

作為嚴格的醫療問診場景,使用者和醫生的聊天記錄是不能重複和丟失,使用業務層進行訊息的ACK回執保證線上的訊息不丟,傳送訊息出去。

使用者線上:推送訊息,並且業務上進行ACK回覆確認傳送成功;

使用者離線:服務端會記錄未推送的未讀列表,當使用者再次進入聊天視窗,拉取歷史訊息進行ACK確認傳送成功。

在客戶端要進行訊息的去重操作,如果ACK回覆沒有返回或者操作失敗的情況下,服務端會再次推送訊息。

線上情況下:每傳送一條訊息,群裡有多少使用者,就會有多少個訊息ACK確認的應答,如何群人數足夠多會對伺服器造成瞬時的ACK請求,為了減少這種減少瞬時大量請求,通過兩個業務邏輯進行優化,每收到X條ACK一次批量ACK回執,則請求降低到1/X了;

但是如果一直達不到X條呢,需要每隔一段時間進行一次批量ACK,能補償一直達不到X條的情況。

離線情況下:在使用者長時間離線,再次登陸時,需要拉取未讀訊息,如果是APP會保持到本地,需要保證APP和服務端的訊息列表資料同步。

1)登陸成功需要拉取好友列表(id+姓名+未讀數量+最後一條資訊+最後一條資訊時間)

2)群組列表(id+群組名稱+未讀數量+最後一條資訊+最後一條資訊時間)

3)群詳情(按需載入)

裝置長期未登陸在未讀訊息量大的情況下,防止client端拉取大量未讀訊息卡頓,需要延遲分頁拉取,當進入群列表時分頁拉取訊息,下一頁的拉取,同時作為上一頁的ACK,這樣可以減少與伺服器的請求次數。

更換新裝置登陸需要拉取全量資料,可以將資料打包下發此場景也需要區分整個資料拉取的分批次,優先是好友記錄,群列表,然後拉取部分訊息,最後的訊息記錄需要按需拉取儘可能減少初始化拉取的資料量以及訪問伺服器的次數。

好友線上狀態

好友線上狀態,如果對展示的實時性要求高,可以採用推送方式同步,但是如果好友太多,這推送的資源成本太高,好友數幾十萬進行推送同步這種不太現實。

可以做按需展示,當到好友聊天介面或者進去群聊,採用拉取,延遲拉取的方式同步,介面可視的區域拉取線上狀態。

 6訊息已讀功能

老舊業務重構案例——IM系統如何設計

在進行聊天中,發出去的訊息是否對方已經收到。

誰讀了,誰假裝沒線上,要做這個功能實現前面已經說過,保證訊息不丟失有業務層ACK反饋,同樣訊息已讀也需要有回執機制。

和ACK不一樣的是已讀標記只需要記錄last_msg_id標記,在last_msg_id之前的都是已讀,有新的msg_id> last_msg_id存在未讀訊息,當client開啟訊息對話方塊,last_msg_id標記為最新的則清空未讀數量,並且需要廣播未讀人數,修改未讀數邏輯。

client傳送已讀的last_msg_id到伺服器端,則判斷使用者進入群的時間和訊息時間進行對比,如果進入群時間早則需要修改last_msg_id和之前群成員表中的last_msg_id中訊息的unread數量。

群訊息:g_msgs(msg_id, gid, suid, time, msg, unread)

群成員表:g_users(gid, uid, last_msg_id)

已讀成員列表:g_readers(msg_id,gid,uid,time)

來看群訊息流程:

1、client A 發出群訊息

2、伺服器將訊息寫入到db,然後查詢群成員。

3、分別對群成員進行線上判斷並實時推送。

4、根據client 回執的last_msg_id來記錄訊息的已讀人數。

同樣已讀回執也會出現短時間大量回執請求的情況,也需要減少回執的請求量。

做好一個IM系統支援業務真的很不容易,這些優化只是九牛一毛,歡迎大家一起討論學習,讓自己的系統更加穩定更好的服務業務。

 6結語

經過一輪操作,系統首輪重構結束,小孫在團隊中的勢能得到了很好的提升,我來團隊的對外一槍也打響了,為後續機制推行、技術升級都有莫大好處。

相關文章