本文原題“搭建高性能的IM系統”,作者“劉蒞”,內容有修訂和改動。為了尊重原創,如需轉載,請聯系作者獲得授權。
1、引言
相信很多朋友對微信、QQ等聊天軟件的實現原理都非常感興趣,筆者同樣對這些軟件有著深厚的興趣。而且筆者在公司也是做IM的,公司的IM每天承載著上億條消息的發送!
正好有這樣的技術資源和條件,所以前段時間,筆者利用業余時間,基于Netty開發了一套基本功能比較完善的IM系統。該系統支持私聊、群聊、會話管理、心跳檢測,支持服務注冊、負載均衡,支持任意節點水平擴容。
這段時間,網上的一些讀者,也希望筆者分享一些Netty或者IM相關的知識,所以今天筆者把開發的這套IM系統分享給大家。
本文將根據筆者這次的業余技術實踐,為你講述如何基于Netty+Zk+Redis來搭建一套高性能IM集群,包括本次實現IM集群的技術原理和實例代碼,希望能帶給你啟發。
2、本文源碼
主地址:https://github.com/nicoliuli/chat
備地址:https://github.com/52im/chat
源碼的目錄結構,如下圖所示:
3、知識準備
* 重要提示:本文不是一篇即時通訊理論文章,文章內容來自代碼實戰,如果你對即時通訊(IM)技術理論了解的太少,建議先詳細閱讀:《新手入門一篇就夠:從零開發移動端IM》。
可能有人不知道 Netty 是什么,這里簡單介紹下:
Netty 是一個 Java 開源框架。Netty 提供異步的、事件驅動的網絡應用程序框架和工具,用以快速開發高性能、高可靠性的網絡服務器和客戶端程序。
也就是說,Netty 是一個基于 NIO 的客戶、服務器端編程框架,使用Netty 可以確保你快速和簡單的開發出一個網絡應用,例如實現了某種協議的客戶,服務端應用。
Netty 相當簡化和流線化了網絡應用的編程開發過程,例如,TCP 和 UDP 的 Socket 服務開發。
以下是有關Netty的入門文章:
如果你連Java的NIO都不知道是什么,下面的文章建議優先讀:
Netty源碼和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、系列文章
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-3816-1-1.html )