一、引言
很久沒寫技術文章了,今天這篇不是原理性文章,而是爲你們分享一下由筆者主導開發實施的IM即時通信聊天系統,針對大量離線消息(包括消息漫遊)致使的用戶體驗問題的升級改造全過程。html
文章中,我將從以下幾個方面進行介紹:redis
- 1)這款IM產品的主要業務及特色;
- 2)IM系統業務現狀和痛點;
- 3)升級改造之路;
- 4)消息ACK邏輯的優化。
下述內容都是根據筆者開發IM的親身經歷總結下來的寶貴經驗,乾貨滿滿,期待你的點贊。數據庫
本文已同步發佈於「即時通信技術圈」公衆號。json
二、此IM產品的主要業務及特色
和傳統互聯網行業有所不一樣,筆者所在的公司(名字就不透露了)是一家作娛樂社交app的公司,包括小遊戲、聊天、朋友圈feed等。後端
你們應該都有體會:遊戲業務在技術上和產品形態上與電商、旅遊等行業有着本質上的區別。瀏覽器
大部分作後端開發的朋友,都在開發接口。客戶端或瀏覽器h5經過HTTP請求到咱們後端的Controller接口,後端查數據庫等返回JSON給客戶端。你們都知道,HTTP協議有短鏈接、無狀態、三次握手四次揮手等特色。而像遊戲、實時通訊等業務反而很不適合用HTTP協議。微信
緣由以下:session
- 1)HTTP達不到實時通訊的效果,能夠用客戶端輪詢可是太浪費資源;
- 2)三次握手四次揮手有嚴重的性能問題;
- 3)無狀態。
好比說,兩個用戶經過App聊天,一方發出去的消息,對方要實時感知到消息的到來。兩我的或多我的玩遊戲,玩家要實時看到對方的狀態,這些場景用HTTP根本不可能實現!由於HTTP只能pull(即「拉」),而聊天、遊戲業務須要push(即「推」)。app
三、IM系統業務現狀和痛點
3.1 業務現狀
筆者負責整個公司的實時聊天系統,相似與微信、QQ那樣,有私聊、羣聊、發消息、語音圖片、紅包等功能。異步
下面我詳細介紹一下,整個聊天系統是如何運轉的。
首先:爲了達到實時通訊的效果,咱們基於Netty開發了一套長連接網關gateway(擴展閱讀:《Netty乾貨分享:京東京麥的生產級TCP網關技術實踐總結》),採用的協議是MQTT協議,客戶端登陸時App經過MQTT協議鏈接到gateway(NettyServer),而後經過MQTT協議把聊天消息push給NettyServer,NettyServer與NettyClient保持長連接,NettyClient用於處理業務邏輯(如敏感詞攔截、數據校驗等)處理,最後將消息push給NettyServer,再由NettyServer經過MQTT push給客戶端。
其次:客戶端與服務端想要正常通訊,咱們須要制定一套統一的協議。拿聊天舉例,咱們要和對方聊天,須要經過uid等信息定位到對方的Channel(Netty中的通道,至關於一條socket鏈接),才能將消息發送給正確的客戶端,同時客戶端必須經過協議中的數據(uid、groupId等),將消息顯示在私聊或者羣聊的會話中。
協議中主要字段以下(咱們將數據編碼成protobuf格式進行傳輸):
{
"cmd":"chat",
"time":1554964794220,
"uid":"69212694",
"clientInfo":{
"deviceId":"b3b1519c-89ec",
"deviceInfo":"MI 6X"
},
"body":{
"v":1,
"msgId":"5ab2fe83-59ec-44f0-8adc-abf26c1e1029",
"chatType":1,
"ackFlg":1,
"from":"69212694",
"to":"872472068",
"time":1554964793813,
"msg":{
"message":"聊天消息"
}
}
}
補充說明:若是你不了Protobuf格式是什麼,請詳讀《Protobuf通訊協議詳解:代碼演示、詳細原理介紹等》。
如上json,協議主要字段包括:

若是客戶端不在線,咱們服務端須要把發送的消息存儲在離線消息表中,等下次對方客戶端上線,服務端NettyServer經過長連接把離線消息push給客戶端。
3.2 業務痛點
隨着業務蓬勃發展,用戶的不斷增多,用戶建立的羣、加入的羣和好友不斷增多和聊天活躍度的上升,某些用戶不在線期間,產生大量的離線消息(尤爲是針對羣聊,離線消息特別多)。
等下次客戶端上線時,服務端會給客戶端強推所有的離線消息,致使客戶端卡死在登陸後的首頁。而且產品提出的需求,要擴大羣成員的人數(由以前的百人羣擴展到千人羣、萬人羣等)。
這樣一來,某些客戶端登陸後一定會由於大量離線消息而卡死,用戶體驗極爲很差。
和客戶端的同事一塊兒分析了一下緣由:
- 1)用戶登陸,服務端經過循環分批下發全部離線消息,數據量較大;
- 2)客戶端登陸後進入首頁,須要加載的數據不光有離線消息,還有其餘初始化數據;
- 3)不一樣價位的客戶端處理數據能力有限,處理聊天消息時,須要把消息存儲到本地數據庫,而且刷新UI界面,回覆給服務端ack消息,整個過程很耗性能。
(慶幸的是,在線消息目前沒有性能問題)。
因此針對上述問題,結合產品對IM系統的遠大規劃,咱們服務端決定優化離線消息(稍微吐槽一下,客戶端處理能力不夠,爲何要服務端作優化?服務端的性能遠沒達到瓶頸。。。)。
四、升級改造之路
值得慶幸的是,筆者100%參與此次系統優化的所有過程,包括技術選型、方案制定和最後的代碼編寫。在此期間,筆者思考出多種方案,而後和服務端、客戶端同事一塊兒討論,最後定下來一套穩定的方案。
4.1 方案一(被pass掉的一個方案)
▶ 【問題症狀】:
客戶端登陸卡頓的主要緣由是,服務端會強推大量離線消息給客戶端,客戶端收到離線消息後會回覆服務端ack,而後將消息存儲到本地數據庫、刷新UI等。客戶端反饋,即便客戶端採用異步方式也會有比較嚴重的性能問題。
▶ 【因而我想】:
爲何客戶端收到消息後尚未將數據存儲到數據庫就回復給服務端ack?頗有可能存儲失敗,這自己不合理,這是其一。其二,服務端強推致使客戶端卡死,不關心客戶端的處理能力,不合理。
▶ 【僞代碼以下】:
int max = 100;
//重新庫讀
while(max > 0) {
List<OfflineMsgInfo> offlineMsgListNew = shardChatOfflineMsgDao.getByToUid(uid, 20);
if(CollectionUtils.isEmpty(offlineMsgListNew)) {
break;
}
handleOfflineMsg(uid, offlineMsgListNew, checkOnlineWhenSendingOfflineMsg);
max--;
}
▶ 【初步方案】:
既然強推不合理,咱們能夠換一種方式,根據客戶端不一樣機型的處理能力的不一樣,服務端採用不一樣的速度下發。
咱們能夠把整個過程當成一種生產者消費者模型,服務端是消息生產者,客戶端是消息消費者。客戶端收到消息,將消息存儲在本地數據庫,刷新UI界面後,再向服務端發送ack消息,服務端收到客戶端的ack消息後,再推送下一批消息。
這麼一來,消息下發速度徹底根據客戶端的處理能力,分批下發。但這種方式仍然屬於推方式。
▶ 【悲劇結果】:
然而,理想很豐滿,現實卻很骨感。
針對這個方案,客戶端提出一些問題:
- 1)雖然這種方案,客戶端不會卡死,可是若是當前用戶的離線消息特別多,那麼收到全部離線消息的時間會很是長;
- 2)客戶端每次收到消息後會刷新界面,頗有可能客戶端會發生,界面上下亂跳的畫面。
so,這個方案被否認了。。。
4.2 方案二
▶ 【個人思考】:
既然強推的數據量過大,咱們是否能夠作到,按需加載?客戶端須要讀取離線消息的時候服務端給客戶端下發,不須要的時候,服務端就不下發。
▶ 【技術方案】:針對離線消息,咱們作了以下方案的優化
1)咱們增長了離線消息計數器的概念:保存了每一個用戶的每一個會話,未讀的消息的元數據(包括未讀消息數,最近的一條未讀消息、時間戳等數據),這個計數器用於客戶端顯示未讀消息的的紅色氣泡。這個數據屬於增量數據,只保留離線期間收到的消息元數據。
消息格式以下:
{
"sessionId1":{
"count":20,
"lastMsg":[
"最後N條消息"
],
"timestamp":1234567890
},
"sessionId2":{
}
}

2)客戶端每次登陸時,服務端不推送全量離線消息,只推送離線消息計數器(這部分數據存儲在redis裏,而且數據量很小),這個數量用戶顯示在客戶端消息列表的未讀消息小紅點上。
3)客戶端拿到這些離線消息計數器數據,遍歷會話列表,依次將未讀消息數量累加(注意:不是覆蓋,服務端保存客戶端離線後的增量數據),而後通知服務端清空離線消息計數器的增量數據。
4)當客戶端進入某會話後,上拉加載時,經過消息的msgId等信息發送HTTP請求給服務端,服務端再去分頁查詢離線消息返回給客戶端。
5)客戶端收到消息並保存在本地數據庫後,向服務端發送ack,而後服務端刪除離線消息表的離線消息。
▶ 【預期結果】:
客戶端、服務端的技術人員承認這個方案。咱們經過推拉結合的方式,解決了客戶端加載離線消息卡頓的問題。(改造前是強推,改造後採用推拉結合的方式)
流程圖以下:

▶ 【新的問題】:
方案雖然經過了,可是引起了一個新問題:即客戶端消息銜接問題。
問題描述以下:客戶端登陸後進入會話頁面,由於客戶端自己就保存着歷史消息,那麼客戶端下拉加載新消息時,到底怎麼判斷要加載本地歷史消息?仍是要請求服務端加載離線消息呢?
通過一番思考,服務端和客戶端最終達成了一致的方案:
- 1)在未讀消息計數器的小紅點邏輯中,服務端會把每一個會話的最近N條消息一塊兒下發給客戶端;
- 2)客戶端進入會話時,會根據未讀消息計數器的最近N條消息展現首頁數據;
- 3)客戶端每次下拉加載時,請求服務端,服務端按時間倒排離線消息返回當前會話最近一頁離線消息,直到離線消息庫中的數據所有返回給客戶端;
- 4)當離線消息庫中沒有離線消息後,返回給客戶端一個標識,客戶端根據這個標識,在會話頁面下一次下拉加載時不請求服務端的離線消息,直接請求本地數據庫。
五、消息ACK邏輯的優化
最後,咱們也對消息ack的邏輯進行了優化。
優化前:服務端採用push模型給客戶端推消息,不管是在線消息仍是離線消息,ack的邏輯都同樣,其中還用到了kafka、redis等中間件,流程很複雜(我在這裏就不詳細展開介紹ack的具體流程了,反正不合理)。
離線消息和在線消息不一樣的是,咱們不存儲在線消息,而離線消息會有一個單獨的庫存儲。徹底不必用在線消息的ack邏輯去處理離線消息,反而很不合理,不只流程上有問題,也浪費kafka、redis等中間件性能。
優化後:咱們和客戶端決定在每次下拉加載離線消息時,將收到的上一批離線消息的msgId或消息偏移量等信息發送給服務端,服務端直接根據msgId刪除離線庫中已經發送給客戶端的離線消息,再返回給客戶端下一批離線消息。
另外:咱們還增長了消息漫遊功能,用戶切換手機登陸後仍然能夠查到歷史消息,這部份內容我就不展開詳細介紹給你們了。(本文同步發佈於:http://www.52im.net/thread-3036-1-1.html)