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

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

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

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

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

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

​— 1 業務梳理

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

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

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

 2 IM核心架構

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

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

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

gateway:爲了減少服務的複雜度將消息上行的功能抽到http接口來承接,包括登陸功能、comet集羣負載均衡,發送圖片、音視頻,基礎信息查詢等。

grape-http:作爲內部其他服務調用comet推送的內部服務。

需要特別注意:

1)網絡傳輸大小端;

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

對於一個由2個字節組成的16位整數,在內存中存儲這兩個字節有兩種方法:

一種是將低序字節存儲在起始地址,這稱爲小端(little-endian)字節序;

另一種方法是將高序字節存儲在起始地址,這稱爲大端(big-endian)字節序。

總而言之,大端是高字節存放到內存的低地址;小端是高字節存放到內存的高地址,在網絡通信中,不同的大小端CPU需要做數據處理再進行傳輸。

2)TCP粘包;

在socket網絡編程中,都是端到端通信,由客戶端端口、服務端端口、客戶端IP、服務端IP和傳輸協議組成的五元組可以明確的標識一條連接。

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

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

解決方式:

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

第二種自定義協議發送定長數據,發送每條數據時,將數據長度一併發送,例如規定數據的前4位的數據的長度,應用層在處理時可以根據長度來判斷每個分組的開始和結束位置,如下圖自定義協議格式:

 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負載均衡

這樣重新加入或者減少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消息已讀功能

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

誰讀了,誰假裝沒在線,要做這個功能實現前面已經說過,保證消息不丟失有業務層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結語

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章