跟著原始碼學IM(十):基於Netty,搭建高效能IM叢集(含技術思路+原始碼)

JackJiang發表於2022-01-19

本文原題“搭建高效能的IM系統”,作者“劉蒞”,內容有修訂和改動。為了尊重原創,如需轉載,請聯絡作者獲得授權。

1、引言

相信很多朋友對微信、QQ等聊天軟體的實現原理都非常感興趣,筆者同樣對這些軟體有著深厚的興趣。而且筆者在公司也是做IM的,公司的IM每天承載著上億條訊息的傳送!

正好有這樣的技術資源和條件,所以前段時間,筆者利用業餘時間,基於Netty開發了一套基本功能比較完善的IM系統。該系統支援私聊、群聊、會話管理、心跳檢測,支援服務註冊、負載均衡,支援任意節點水平擴容。

這段時間,網上的一些讀者,也希望筆者分享一些Netty或者IM相關的知識,所以今天筆者把開發的這套IM系統分享給大家。

本文將根據筆者這次的業餘技術實踐,為你講述如何基於Netty+Zk+Redis來搭建一套高效能IM叢集,包括本次實現IM叢集的技術原理和例項程式碼,希望能帶給你啟發。

學習交流:

(本文已同步釋出於:http://www.52im.net/thread-38...

2、本文原始碼

主地址:https://github.com/nicoliuli/...
備地址:https://github.com/52im/chat

原始碼的目錄結構,如下圖所示:

3、知識準備

  • 重要提示:本文不是一篇即時通訊理論文章,文章內容來自程式碼實戰,如果你對即時通訊(IM)技術理論瞭解的太少,建議先詳細閱讀:《新手入門一篇就夠:從零開發移動端IM》。

可能有人不知道 Netty 是什麼,這裡簡單介紹下:

Netty 是一個 Java 開源框架。Netty 提供非同步的、事件驅動的網路應用程式框架和工具,用以快速開發高效能、高可靠性的網路伺服器和客戶端程式。
引用
也就是說,Netty 是一個基於 NIO 的客戶、伺服器端程式設計框架,使用Netty 可以確保你快速和簡單的開發出一個網路應用,例如實現了某種協議的客戶,服務端應用。
引用
Netty 相當簡化和流線化了網路應用的程式設計開發過程,例如,TCP 和 UDP 的 Socket 服務開發。

以下是有關Netty的入門文章:

1)新手入門:目前為止最透徹的的Netty高效能原理和框架架構解析
2)寫給初學者:Java高效能NIO框架Netty的學習方法和進階策略
3)史上最通俗Netty框架入門長文:基本介紹、環境搭建、動手實戰

如果你連Java的NIO都不知道是什麼,下面的文章建議優先讀:

1)少囉嗦!一分鐘帶你讀懂Java的NIO和經典IO的區別
2)史上最強Java NIO入門:擔心從入門到放棄的,請讀這篇!
3)Java的BIO和NIO很難懂?用程式碼實踐給你看,再不懂我轉行!

Netty原始碼和API的線上查閱地址:

1)Netty-4.1.x 完整原始碼(線上閱讀版)
2)Netty-4.1.x API文件(線上版)

4、系統架構

系統的架構如上圖所示:整個系統是一個C/S系統,客戶端沒有做複雜的圖形化介面而是用Java終端開發的(黑視窗),服務端IM例項是Netty寫的socket服務。

ZK作為服務註冊中心,Redis用來做分散式會話的快取,並儲存使用者資訊和輕量級的訊息佇列。

對於整個系統架構中各部分的工作原理,我們將在接下來的各章節中一一介紹。

5、服務端的工作原理

在上述架構中:NettyServer啟動,每啟動一臺Server節點,都會把自身的節點資訊,如:ip、port等資訊註冊到ZK上(臨時節點)。

正如上節架構圖上啟動了兩臺NettyServer,所以ZK上會儲存兩個Server的資訊。

同時ZK將監聽每臺Server節點,如果Server當機ZK就會刪除當前機器所註冊的資訊(把臨時節點刪除),這樣就完成了簡單的服務註冊的功能。

6、客戶端的工作原理

Client啟動時,會先從ZK上隨機選擇一個可用的NettyServer(隨機表示可以實現負載均衡),拿到NettyServer的資訊(IP和port)後與NettyServer建立連結。

連結建立起來後,NettyServer端會生成一個Session(即會話),用來把當前客戶端的Channel等資訊組裝成一個Session物件,儲存在一個SessionMap裡,同時也會把這個Session儲存在Redis中。

這個會話特別重要,通過會話,我們能獲取當前Client和NettyServer的Channel等資訊。

7、Session的作用

我們啟動多個Client,由於每個Client啟動,都會先從ZK上隨機獲取NettyServer的的資訊,所以如果啟動多個Client,就會連線到不同的NettyServer上。

熟悉Netty的朋友都知道,Client與Server建立接連後會產生一個Channel,通過Channel,Client和Server才能進行正常的網路資料傳輸。

如果Client1和Client2連線在同一個Server上:那麼Server通過SessionMap分別拿到Client1和Client2的會話,會話中包含Channel資訊,有了兩個Client的Channel,Client1和Client2便可完成訊息通訊。

如果Client1和Client2連線到不同的NettyServer上:Client1和Client2要進行通訊,該怎麼辦?這個問題放在後面解答。

8、高效的資料傳輸

無論是IM系統,還是分散式的RPC框架,高效的網路資料傳輸,無疑會極大的提升系統的效能。

資料通過網路傳輸時,一般把物件通序列化成二進位制位元組流陣列,然後將資料通過socket傳給對方伺服器,對方伺服器拿到二進位制位元組流後再反序列化成物件,達到遠端通訊的目的。

在Java領域,Java序列化物件的方式有嚴重的效能問題,業界常用谷歌的protobuf來實現序列化反序列化(見《Protobuf通訊協議詳解:程式碼演示、詳細原理介紹等》)。

protobuf支援不同的程式語言,可以實現跨語言的系統呼叫,並且有著極高的序列化反序列化效能,本系統也採用protobuf來做資料的序列化。

關於Protobuf的基本認之,下面這幾篇可以深入讀一讀:

《強列建議將Protobuf作為你的即時通訊應用資料傳輸格式》
《全方位評測:Protobuf效能到底有沒有比JSON快5倍?》
《金蝶隨手記團隊分享:還在用JSON? Protobuf讓資料傳輸更省更快(原理篇)》

另外:《一套海量線上使用者的移動端IM架構設計實踐分享(含詳細圖文)》一文中,“3、協議設計”這一節有關於protobuf在IM中的實戰設計和使用,可以一併學習一下。

9、聊天協議定義

我們在使用各種聊天APP時,會發各種各樣的訊息,每種訊息都會對應不同的訊息格式(即“聊天協議”)。

聊天協議中主要包含幾種重要的資訊:

1)訊息型別;
2)傳送時間;
3)訊息的收發人;
4)聊天型別(群聊或私聊)。

我的這套IM系統中,聊天協議定義如下:

syntax = "proto3";
option java_package = "model.chat";
option java_outer_classname = "RpcMsg";
message Msg{
    string msg_id = 1;
    int64 from_uid = 2;
    int64 to_uid = 3;
    int32 format = 4;
    int32 msg_type = 5;
    int32 chat_type = 6;
    int64 timestamp = 7;
    string body = 8;
    repeated int64 to_uid_list = 9;
}

如上面的protobuf程式碼,欄位的具體含義如下:

1)msg_id:表示訊息的唯一id,可以用UUID表示;
2)from_uid:訊息傳送者的uid;
3)to_uid:訊息接收者的uid;
4)format:訊息格式,我們使用各種聊天軟體時,會傳送文字訊息,語音訊息,圖片訊息等等等等,每種訊息有不同的訊息格式,我們用format來表示(由於本系統是java終端,format欄位沒有太大含義,可有可無);
5)msg_type:訊息型別,比如登入訊息、聊天訊息、ack訊息、ping、pong訊息;
6)chat_type:聊天型別,如群聊、私聊;
7)timestamp:傳送訊息的時間戳;
8)body:訊息的具體內容,載體;
9)to_uid_list:這個欄位使用者群聊訊息提高群聊訊息的效能,具體作用會在群聊原理部分詳細解釋。

10、私聊訊息傳送原理

Client1給Client2發訊息時,我們需要構建上節中的訊息體。

具體就是:from_uid是Client1的uid、to_uid是Client2的uid。

NettyServer收到訊息後的處理邏輯是:

1)解析到to_uid欄位;
2)從SessionMap或者Redis中儲存的Session集合中獲取to_uid即Client2的Session;
3)從Session中取出Client2的Channel;
4)然後將訊息通過Client2的Channel發給Client2。

11、群聊訊息傳送原理

群聊訊息的分發通常有兩種技術實現方式,我們一一來看看。

方式一:假設一個群有100人,如果Client1給一個群的所有人發訊息,其實相當於Client1分別給其餘99人分別發一條訊息。我們可以直接在Client端,通過迴圈,分別給群裡的99人發訊息即可,相當於Client傳送給NettyServer傳送了99次相同的訊息(除了to_uid不同)。

上述方案有很嚴重的效能問題:Client1通過迴圈99次,分別把訊息發給NettyServer,NettyServer收到這99條訊息後,分別將訊息發給群內其餘的使用者。先拋開移動端的特殊性(比如迴圈還沒完成手機就有可能退到後臺被系統掛起),顯然Client1到NettyServer的99次迴圈存在明顯不合理地方。

方式二:上節的訊息體中to_uid_list欄位就是為了解決這個方式一的效能問題的。Client1把群內其餘99個Client的uid儲存在to_uid_list中,然後NettyServer只發一條訊息,NettyServer收到這一條訊息後,通過to_uid_list欄位解析群內其餘99的Client的uid,再通過迴圈把訊息分別傳送給群內其餘的Client。

可以看到:方式二的群聊時,Client1與NettyServer只進行1次訊息傳輸,相比於方式一,效率提高了50%。

11、技術關鍵點1:客戶端分別連線在不同IM例項時如何通訊?

針對本文中的架構,如果多個Client分別連線在不同的Server上,Client之間應該如何通訊呢?

為了回答這個問題,我們首先要明白Session的作用。

我們做過JavaWeb開發的朋友都知道,Session用來儲存使用者的登入資訊。

在IM系統中也是如此:Session中儲存使用者的Channel資訊。當Client與Server建立連結成功後,會產生一個Channel,Client和Server是通過Channel,實現資料傳輸。當兩端連結建立起來後,Server會構建出一個Session物件,儲存uid和Channel等資訊,並把這個Session儲存在一個SessionMap裡(NettyServer的記憶體裡),uid為key,我們可以通過uid就可以找到這個uid對應的Session。

但只有SessionMap還不夠:我們需要利用Redis,它的作用是儲存整個NettyServer叢集全部連結成功的使用者,這也是一種Session,但這種Session沒有儲存uid和Channel的對應關係,而是儲存Client連結到NettyServer的資訊,如Client連結到的這個NettyServer的ip、port等。通過uid,我們同樣可以從Redis中拿到當前Client連結到的NettyServer的資訊。正是有了這個資訊,我們才能做到,NettyServer叢集任意節點水平擴容。

當使用者量少的時候:我們只需要一臺NettyServer節點便可以扛住流量,所有的Client連結到同一個NettyServer上,並在NettyServer的SessionMap中儲存每個Client的會話。Client1與Client2通訊時,Client1把訊息發給NettyServer,NettyServer從SessionMap中取出Client2的Session和Channel,將訊息發給Client2。

隨著使用者量不斷增多:一臺NettyServer不夠,我們增加了幾臺NettyServer,這時Client1連結到NettyServer1上並在SessionMap和Redis中儲存了會話和Client1的連結資訊,Client2連結到NettyServer2上並在SessionMap和Redis中儲存了會話和Client2的連結資訊。Client1給Client2發訊息時,通過NettyServer1的SessionMap找不到Client2的會話,訊息無法傳送,於是便從Redis中獲取Client2連結在哪臺NettyServer上。獲取到Client2所連結的NettyServer資訊後,我們可以把訊息轉發給NettyServer2,NettyServer2收到訊息後,從NettyServer2的SessionMap中獲取Client2的Session和Channel,然後將訊息傳送給Client2。

那麼:NettyServer1的訊息如何轉發給NettyServer2呢?答案是通過訊息佇列,如Redis中的list資料結構。每臺NettyServer啟動後都需要監聽一個自己的Redis中的訊息佇列,這個佇列使用者接收其他NettyServer轉發給當前NettyServer的訊息。

  • Jack Jiang點評:上述叢集方案中,Redis既作為線上使用者列表儲存中心,又作為叢集中不同IM長連線例項的訊息中轉服務(此時的Redis作用相當於MQ),那Redis不就成為了整個分散式叢集的單點瓶頸了嗎?

12、技術關鍵點2:連結斷開,如何處理?

如果Client與NettyServer,由於某種原因(客戶端退出、服務端重啟、網路因素等)斷開連結,我們必須要從SessionMap刪除會話和Redis中保留的資料。

如果不清除這兩類資料的話,很有可能Client1傳送給Client2的訊息,可能會發給其他使用者,或者就算Client2處於登入狀態,Client2也收到不到訊息。

我們可以在Netty框架中的channelInactive方法裡,處理連結斷開後的會話清除操作。

13、技術關鍵點3:ping、pong的作用

當Client與NettyServer建立連結後,由於雙端網路較差,Client與NettyServer斷開連結後,如果NettyServer沒有感知到,也就沒有清除SessionMap和Redis中的資料,這將會造成嚴重的問題(對於服務端來說,這個Client的會話實際處於“假死”狀態,訊息是無法實時傳送過去的)。

此時就需要一種ping/pong機制(也就是心跳機制啦)。

實現原理就是:通過定時任務,Client每隔一段時間給NettyServer發一個ping訊息,NettyServer收到ping訊息後給客戶端回覆一個pong訊息,確保客戶端和服務端能一直保持連結狀態。如果Client與NettyServer斷連了,NettyServer可以立即發現並清空會話資料。Netty中的我們可以在Pipeline中新增IdleStateHandler,可達到這樣的目的。

如果你不明白心跳的作用,務必讀以下文章:

《為何基於TCP協議的移動端IM仍然需要心跳保活機制?》
《一文讀懂即時通訊應用中的網路心跳包機制:作用、原理、實現思路等》

也可以學習一下主流IM的心跳邏輯:

《微信團隊原創分享:Android版微信後臺保活實戰分享(程式保活篇)》
《微信團隊原創分享:Android版微信後臺保活實戰分享(網路保活篇)》
《移動端IM實踐:實現Android版微信的智慧心跳機制》
《移動端IM實踐:WhatsApp、Line、微信的心跳策略分析》

如果覺得理論不夠直觀,下面的程式碼例項可以直觀地進行學習:

《正確理解IM長連線的心跳及重連機制,並動手實現(有完整IM原始碼)》
《一種Android端IM智慧心跳演算法的設計與實現探討(含樣例程式碼)》
《自已開發IM有那麼難嗎?手把手教你自擼一個Andriod版簡易IM (有原始碼)》
《手把手教你用Netty實現網路通訊程式的心跳機制、斷線重連機制》

其實,心跳演算法的實際效果,還是有一些邏輯技巧的,以下兩篇建議必讀:

《Web端即時通訊實踐乾貨:如何讓你的WebSocket斷網重連更快速?》
《融雲技術分享:融雲安卓端IM產品的網路鏈路保活技術實踐》

14、技術關鍵點4:為Server和Client新增Hook

如果NettyServer重啟了或者程式被kill掉,我們需要清除當前節點的SessionMap(其實不用清理SessionMap,資料在記憶體裡重啟會自動刪除的)和Redis儲存的Client的連結資訊。

我們需要遍歷SessionMap找出所有的uid,然後一一清除Redis的資料,然後優雅退出。此時,我們就需要為我們的NettyServer新增一個Hook,來做資料清理。

15、技術關鍵點5:對方不線上該如何處理訊息?

Client1給對方發訊息,我們通過SessionMap或Redis拿不到對方的會話資料,這就表明對方不線上。

此時:我們需要把訊息儲存在離線訊息表中,當對方下次登入時,NettyServer查離線訊息表,把訊息發給登入使用者(最好是批量傳送,提高效能)。

IM中的離線訊息處理,也不是個簡單的技術點,有興趣可以深入學習一下:

《IM訊息送達保證機制實現(二):保證離線訊息的可靠投遞》
《阿里IM技術分享(六):閒魚億級IM訊息系統的離線推送到達率優化》
《IM開發乾貨分享:我是如何解決大量離線訊息導致客戶端卡頓的》
《IM開發乾貨分享:如何優雅的實現大量離線訊息的可靠投遞》
《喜馬拉雅億級使用者量的離線訊息推送系統架構設計實踐》

16、寫在最後

程式碼寫成這樣,也算是了確了自已手擼IM的心願。唯一遺憾的是,時間比較緊張,還沒來得及實現訊息ack機制,保證訊息一定會送達,這個筆者以後會補充上去的。

好了,這就是我開發的這個簡易的聊天系統,麻雀雖小,五臟俱全,大家有什麼不明白的地方,可以直接在下方留言,筆者會一一回復的,謝謝大家。

17、系列文章

《跟著原始碼學IM(一):手把手教你用Netty實現心跳機制、斷線重連機制》
《跟著原始碼學IM(二):自已開發IM很難?手把手教你擼一個Andriod版IM》
《跟著原始碼學IM(三):基於Netty,從零開發一個IM服務端》
《跟著原始碼學IM(四):拿起鍵盤就是幹,教你徒手開發一套分散式IM系統》
《跟著原始碼學IM(五):正確理解IM長連線、心跳及重連機制,並動手實現》
《跟著原始碼學IM(六):手把手教你用Go快速搭建高效能、可擴充套件的IM系統》
《跟著原始碼學IM(七):手把手教你用WebSocket打造Web端IM聊天》
《跟著原始碼學IM(八):萬字長文,手把手教你用Netty打造IM聊天》
《跟著原始碼學IM(九):基於Netty實現一套分散式IM系統》
《跟著原始碼學IM(十):基於Netty,搭建高效能IM叢集(含技術思路+原始碼)》(* 本文)

18、參考資料

[1] 新手入門:目前為止最透徹的的Netty高效能原理和框架架構解析
[2] 寫給初學者:Java高效能NIO框架Netty的學習方法和進階策略
[3] 史上最強Java NIO入門:擔心從入門到放棄的,請讀這篇!
[4] Java的BIO和NIO很難懂?用程式碼實踐給你看,再不懂我轉行!
[5] 史上最通俗Netty框架入門長文:基本介紹、環境搭建、動手實戰
[6] 理論聯絡實際:一套典型的IM通訊協議設計詳解
[7] 淺談IM系統的架構設計
[8] 簡述移動端IM開發的那些坑:架構設計、通訊協議和客戶端
[9] 一套海量線上使用者的移動端IM架構設計實踐分享(含詳細圖文)
[10] 一套原創分散式即時通訊(IM)系統理論架構方案
[11] 一套高可用、易伸縮、高併發的IM群聊、單聊架構方案設計實踐
[12] 一套億級使用者的IM架構技術乾貨(上篇):整體架構、服務拆分等
[13] 一套億級使用者的IM架構技術乾貨(下篇):可靠性、有序性、弱網優化等
[14] 從新手到專家:如何設計一套億級訊息量的分散式IM系統
[15] 基於實踐:一套百萬訊息量小規模IM系統技術要點總結

(本文已同步釋出於:http://www.52im.net/thread-38...

相關文章