技術(shù)干貨:從零開始,教你設(shè)計一個百萬級的消息推送系統(tǒng)
Posted on 2018-11-27 20:49 Jack Jiang 閱讀(331) 評論(0) 編輯 收藏1、點評
本文主要分享的是如何從零設(shè)計開發(fā)一個中大型推送系統(tǒng),因限于篇幅,文中有些鍵技術(shù)只能一筆帶過,建議有這方面興趣的讀者可以深入研究相關(guān)知識點,從而形成橫向知識體系。
本文適合有一定開發(fā)、架構(gòu)經(jīng)驗的后端程序員閱讀,文內(nèi)個別技術(shù)點可能并非最佳實踐,但至少都是生動的實踐分享,至少能起到拋磚引玉的作用。希望即時通訊網(wǎng)本次整理的文章能給予你一些啟發(fā)。
學(xué)習(xí)交流:
- 即時通訊/推送技術(shù)開發(fā)交流4群:101279154 [推薦]
- 移動端IM開發(fā)入門文章:《新手入門一篇就夠:從零開發(fā)移動端IM》
(本文同步發(fā)布于:http://www.52im.net/thread-2096-1-1.html)
2、引言
先簡單介紹下本次要分享的主題,由于我最近做的是物聯(lián)網(wǎng)相關(guān)的開發(fā)工作,其中就不免會遇到和設(shè)備的數(shù)據(jù)通信(交互)。其中最主要的工作就是要有一個系統(tǒng)來支持設(shè)備的接入、向設(shè)備推送消息,同時還得滿足大量設(shè)備接入的需求。
正好借本文,總結(jié)和沉淀一下近期的技術(shù)心得。
所以本次分享的內(nèi)容不但可以滿足物聯(lián)網(wǎng)領(lǐng)域同時還支持以下場景:
1)基于 WEB 的聊天系統(tǒng)(點對點、群聊);
2)WEB 應(yīng)用中需求服務(wù)端推送的場景;
3)基于 SDK 的消息推送平臺。
3、關(guān)于作者
crossoverJie(陳杰): 90后,畢業(yè)于重慶信息工程學(xué)院,現(xiàn)供職于重慶豬八戒網(wǎng)絡(luò)有限公司。
作者的博客:https://crossoverjie.top
作者的Github:https://github.com/crossoverJie
4、技術(shù)選型
要滿足大量的連接數(shù)、同時支持雙全工通信,并且性能也得有保障。在 Java 技術(shù)棧中進行選型首先自然是排除掉了傳統(tǒng) IO。
那就只有選 NIO 了,在這個層面其實選擇也不多,考慮到社區(qū)、資料維護等方面最終選擇了 Netty。
Netty源碼在線閱讀:
Netty-4.1.x地址是:http://docs.52im.net/extend/docs/src/netty4_1/
Netty-4.0.x地址是:http://docs.52im.net/extend/docs/src/netty4/
Netty-3.x地址是:http://docs.52im.net/extend/docs/src/netty3/
Netty在線API文檔:
Netty-4.1.x API文檔(在線版):http://docs.52im.net/extend/docs/api/netty4_1/
Netty-4.0.x API文檔(在線版):http://docs.52im.net/extend/docs/api/netty4/
Netty-3.x API文檔(在線版):http://docs.52im.net/extend/docs/api/netty3/
有關(guān)Netty的其它精華文章:
《有關(guān)“為何選擇Netty”的11個疑問及解答》
《開源NIO框架八卦——到底是先有MINA還是先有Netty?》
《Netty 4.x學(xué)習(xí)(一):ByteBuf詳解》
《Netty 4.x學(xué)習(xí)(二):Channel和Pipeline詳解》
《Netty 4.x學(xué)習(xí)(三):線程模型詳解》
《實踐總結(jié):Netty3.x升級Netty4.x遇到的那些坑(線程篇)》
《實踐總結(jié):Netty3.x VS Netty4.x的線程模型》
《Twitter:如何使用Netty 4來減少JVM的GC開銷(譯文)》
最終的架構(gòu)圖如下:
現(xiàn)在看著蒙沒關(guān)系,下文一一介紹。
5、協(xié)議解析
既然是一個消息系統(tǒng),那自然得和客戶端定義好雙方的協(xié)議格式。
常見和簡單的是 HTTP 協(xié)議,但我們的需求中有一項需要是雙全工的交互方式,同時 HTTP 更多的是服務(wù)于瀏覽器。我們需要的是一個更加精簡的協(xié)議,減少許多不必要的數(shù)據(jù)傳輸。
因此我覺得最好是在滿足業(yè)務(wù)需求的情況下定制自己的私有協(xié)議,在這個場景下有標準的物聯(lián)網(wǎng)協(xié)議。
如果是其他場景可以借鑒現(xiàn)在流行的 RPC 框架定制私有協(xié)議,使得雙方通信更加高效。
不過根據(jù)這段時間的經(jīng)驗來看,不管是哪種方式都得在協(xié)議中預(yù)留安全相關(guān)的位置。協(xié)議相關(guān)的內(nèi)容就不過多討論了,更多介紹具體的應(yīng)用。
有關(guān)通信協(xié)議、協(xié)議格式的選擇,可以閱讀以下文章:
《Protobuf通信協(xié)議詳解:代碼演示、詳細原理介紹等》
《一個基于Protocol Buffer的Java代碼演示》
《為什么QQ用的是UDP協(xié)議而不是TCP協(xié)議?》
《如何選擇即時通訊應(yīng)用的數(shù)據(jù)傳輸格式》
《強列建議將Protobuf作為你的即時通訊應(yīng)用數(shù)據(jù)傳輸格式》
《全方位評測:Protobuf性能到底有沒有比JSON快5倍?》
《移動端IM開發(fā)需要面對的技術(shù)問題(含通信協(xié)議選擇)》
《簡述移動端IM開發(fā)的那些坑:架構(gòu)設(shè)計、通信協(xié)議和客戶端》
《理論聯(lián)系實際:一套典型的IM通信協(xié)議設(shè)計詳解》
《58到家實時消息系統(tǒng)的協(xié)議設(shè)計等技術(shù)實踐分享》
《詳解如何在NodeJS中使用Google的Protobuf》
《技術(shù)掃盲:新一代基于UDP的低延時網(wǎng)絡(luò)傳輸層協(xié)議——QUIC詳解》
《金蝶隨手記團隊分享:還在用JSON? Protobuf讓數(shù)據(jù)傳輸更省更快(原理篇)》
《金蝶隨手記團隊分享:還在用JSON? Protobuf讓數(shù)據(jù)傳輸更省更快(實戰(zhàn)篇)》
>> 更多同類文章 ……
6、簡單實現(xiàn)
首先考慮如何實現(xiàn)功能,再來思考百萬連接的情況。
6.1 注冊鑒權(quán)
在做真正的消息上、下行之前首先要考慮的就是鑒權(quán)問題。就像你使用微信一樣,第一步怎么也得是登錄吧,不能無論是誰都可以直接連接到平臺。所以第一步得是注冊才行。
如上面第4節(jié)架構(gòu)圖中的注冊/鑒權(quán)模塊。通常來說都需要客戶端通過 HTTP 請求傳遞一個唯一標識,后臺鑒權(quán)通過之后會響應(yīng)一個 Token,并將這個 Token 和客戶端的關(guān)系維護到 Redis 或者是 DB 中。
客戶端將這個 Token 也保存到本地,今后的每一次請求都得帶上這個 Token。一旦這個 Token 過期,客戶端需要再次請求獲取 Token。
鑒權(quán)通過之后客戶端會直接通過 TCP 長連接到圖中的 push-server 模塊。這個模塊就是真正處理消息的上、下行。
6.2 保存通道關(guān)系
在連接接入之后,真正處理業(yè)務(wù)之前需要將當前的客戶端和 Channel 的關(guān)系維護起來。
假設(shè)客戶端的唯一標識是手機號碼,那就需要把手機號碼和當前的 Channel 維護到一個 Map 中。
這點和之前 Spring Boot 整合長連接心跳機制類似,如下圖:
同時為了可以通過 Channel 獲取到客戶端唯一標識(手機號碼),還需要在 Channel 中設(shè)置對應(yīng)的屬性:
publicstaticvoidputClientId(Channel channel, String clientId) {
channel.attr(CLIENT_ID).set(clientId);
}
獲取手機號碼時:
publicstaticString getClientId(Channel channel) {
return(String)getAttribute(channel, CLIENT_ID);
}
這樣當我們客戶端下線時便可以記錄相關(guān)日志:
String telNo = NettyAttrUtil.getClientId(ctx.channel());
NettySocketHolder.remove(telNo);
log.info("客戶端下線,TelNo="+ telNo);
這里有一點需要注意:存放客戶端與 Channel 關(guān)系的 Map 最好是預(yù)設(shè)好大小(避免經(jīng)常擴容),因為它將是使用最為頻繁同時也是占用內(nèi)存最大的一個對象。
6.3 消息上行
接下來則是真正的業(yè)務(wù)數(shù)據(jù)上傳,通常來說第一步是需要判斷上傳消息輸入什么業(yè)務(wù)類型。在聊天場景中,有可能上傳的是文本、圖片、視頻等內(nèi)容。
所以我們得進行區(qū)分,來做不同的處理,這就和客戶端協(xié)商的協(xié)議有關(guān)了:
1)可以利用消息頭中的某個字段進行區(qū)分;
2)更簡單的就是一個 JSON 消息,拿出一個字段用于區(qū)分不同消息。
不管是哪種只要可以區(qū)分出來即可。
6.4 消息解析與業(yè)務(wù)解耦
消息可以解析之后便是處理業(yè)務(wù),比如可以是寫入數(shù)據(jù)庫、調(diào)用其他接口等。
我們都知道在 Netty 中處理消息一般是在 channelRead() 方法中:
在這里可以解析消息,區(qū)分類型。但如果我們的業(yè)務(wù)邏輯也寫在里面,那這里的內(nèi)容將是巨多無比。
甚至我們分為好幾個開發(fā)來處理不同的業(yè)務(wù),這樣將會出現(xiàn)許多沖突、難以維護等問題。所以非常有必要將消息解析與業(yè)務(wù)處理完全分離開來。
這時面向接口編程就發(fā)揮作用了。這里的核心代碼和 「造個輪子」——cicada(輕量級 Web 框架)是一致的(另外,即時通訊網(wǎng)的MobileIMSDK工程也使用了同樣的API解偶設(shè)計思路)。
都是先定義一個接口用于處理業(yè)務(wù)邏輯,然后在解析消息之后通過反射創(chuàng)建具體的對象執(zhí)行其中的處理函數(shù)即可。
這樣不同的業(yè)務(wù)、不同的開發(fā)人員只需要實現(xiàn)這個接口同時實現(xiàn)自己的業(yè)務(wù)邏輯即可。
偽代碼如下:
想要了解 cicada 的具體實現(xiàn)請點擊這里:
https://github.com/TogetherOS/cicada
上行還有一點需要注意:由于是基于長連接,所以客戶端需要定期發(fā)送心跳包用于維護本次連接。
同時服務(wù)端也會有相應(yīng)的檢查,N 個時間間隔沒有收到消息之后,將會主動斷開連接節(jié)省資源。
這點使用一個 IdleStateHandler 就可實現(xiàn)。
6.5 消息下行
有了上行自然也有下行。比如在聊天的場景中,有兩個客戶端連上了 push-server,它們直接需要點對點通信。
這時的流程是:
1)A 將消息發(fā)送給服務(wù)器;
2)服務(wù)器收到消息之后,得知消息是要發(fā)送給 B,需要在內(nèi)存中找到 B 的 Channel;
3)通過 B 的 Channel 將 A 的消息轉(zhuǎn)發(fā)下去。
這就是一個下行的流程。甚至管理員需要給所有在線用戶發(fā)送系統(tǒng)通知也是類似:遍歷保存通道關(guān)系的 Map,挨個發(fā)送消息即可。這也是之前需要存放到 Map 中的主要原因。
偽代碼如下:
具體可以參考:
https://github.com/crossoverJie/netty-action/
7、分布式方案
單機版的實現(xiàn)了,現(xiàn)在著重講講如何實現(xiàn)百萬連接。
百萬連接其實只是一個形容詞,更多的是想表達如何來實現(xiàn)一個分布式的方案,可以靈活的水平拓展從而能支持更多的連接。在做這個事前,首先得搞清楚我們單機版的能支持多少連接。
影響這個的因素就比較多了:
1)服務(wù)器自身配置:內(nèi)存、CPU、網(wǎng)卡、Linux 支持的最大文件打開數(shù)等;
2)應(yīng)用自身配置:因為 Netty 本身需要依賴于堆外內(nèi)存,但是 JVM 本身也是需要占用一部分內(nèi)存的,比如存放通道關(guān)系的大 Map。這點需要結(jié)合自身情況進行調(diào)整。
結(jié)合以上的情況可以測試出單個節(jié)點能支持的最大連接數(shù)。單機無論怎么優(yōu)化都是有上限的,這也是分布式主要解決的問題。
7.1 架構(gòu)介紹
在講具體實現(xiàn)之前首先得講講上文貼出的整體架構(gòu)圖:
先從左邊開始。上文提到的注冊鑒權(quán)模塊也是集群部署的,通過前置的 Nginx 進行負載。之前也提過了它主要的目的是來做鑒權(quán)并返回一個 Token 給客戶端。
但是 push-server 集群之后它又多了一個作用。那就是得返回一臺可供當前客戶端使用的 push-server。
右側(cè)的平臺一般指管理平臺,它可以查看當前的實時在線數(shù)、給指定客戶端推送消息等。推送消息則需要經(jīng)過一個推送路由(push-server)找到真正的推送節(jié)點。
其余的中間件如:Redis、ZooKeeper、Kafka、MySQL 都是為了這些功能所準備的,具體看下面的實現(xiàn)。
7.2 注冊發(fā)現(xiàn)
首先第一個問題則是 注冊發(fā)現(xiàn),push-server 變?yōu)槎嗯_之后如何給客戶端選擇一臺可用的節(jié)點是第一個需要解決的。
這塊的內(nèi)容其實已經(jīng)在 分布式(一) 搞定服務(wù)注冊與發(fā)現(xiàn)中詳細講過了。所有的 push-server 在啟動時候需要將自身的信息注冊到 ZooKeeper 中。
注冊鑒權(quán)模塊會訂閱 ZooKeeper 中的節(jié)點,從而可以獲取最新的服務(wù)列表,結(jié)構(gòu)如下:
以下是一些偽代碼:應(yīng)用啟動注冊 ZooKeeper
對于注冊鑒權(quán)模塊來說只需要訂閱這個 ZooKeeper 節(jié)點:
7.3 路由策略
既然能獲取到所有的服務(wù)列表,那如何選擇一臺剛好合適的 push-server 給客戶端使用呢?
這個過程重點要考慮以下幾點:
1)盡量保證各個節(jié)點的連接均勻;
2)增刪節(jié)點是否要做 Rebalance。
首先保證均衡有以下幾種算法:
1)輪詢:挨個將各個節(jié)點分配給客戶端。但會出現(xiàn)新增節(jié)點分配不均勻的情況;
2)Hash 取模的方式:類似于 HashMap,但也會出現(xiàn)輪詢的問題。當然也可以像 HashMap 那樣做一次 Rebalance,讓所有的客戶端重新連接。不過這樣會導(dǎo)致所有的連接出現(xiàn)中斷重連,代價有點大。由于 Hash 取模方式的問題帶來了一致性 Hash 算法,但依然會有一部分的客戶端需要 Rebalance;
3)權(quán)重:可以手動調(diào)整各個節(jié)點的負載情況,甚至可以做成自動的,基于監(jiān)控當某些節(jié)點負載較高就自動調(diào)低權(quán)重,負載較低的可以提高權(quán)重。
還有一個問題是:當我們在重啟部分應(yīng)用進行升級時,在該節(jié)點上的客戶端怎么處理?
由于我們有心跳機制,當心跳不通之后就可以認為該節(jié)點出現(xiàn)問題了。那就得重新請求注冊鑒權(quán)模塊獲取一個可用的節(jié)點。在弱網(wǎng)情況下同樣適用。
如果這時客戶端正在發(fā)送消息,則需要將消息保存到本地等待獲取到新的節(jié)點之后再次發(fā)送。
7.4 有狀態(tài)連接
在這樣的場景中不像是 HTTP 那樣是無狀態(tài)的,我們得明確的知道各個客戶端和連接的關(guān)系。
在上文的單機版中我們將這個關(guān)系保存到本地的緩存中,但在分布式環(huán)境中顯然行不通了。
比如在平臺向客戶端推送消息的時候,它得首先知道這個客戶端的通道保存在哪臺節(jié)點上。
借助我們以前的經(jīng)驗,這樣的問題自然得引入一個第三方中間件用來存放這個關(guān)系。
也就是架構(gòu)圖中的存放路由關(guān)系的 Redis,在客戶端接入 push-server 時需要將當前客戶端唯一標識和服務(wù)節(jié)點的 ip+port 存進 Redis。
同時在客戶端下線時候得在 Redis 中刪掉這個連接關(guān)系。這樣在理想情況下各個節(jié)點內(nèi)存中的 Map 關(guān)系加起來應(yīng)該正好等于 Redis 中的數(shù)據(jù)。
偽代碼如下:
這里存放路由關(guān)系的時候會有并發(fā)問題,最好是換為一個 Lua 腳本。
7.5 推送路由
設(shè)想這樣一個場景:管理員需要給最近注冊的客戶端推送一個系統(tǒng)消息會怎么做?
結(jié)合架構(gòu)圖,假設(shè)這批客戶端有 10W 個,首先我們需要將這批號碼通過平臺下的 Nginx 下發(fā)到一個推送路由中。
為了提高效率甚至可以將這批號碼再次分散到每個 push-route 中。拿到具體號碼之后再根據(jù)號碼的數(shù)量啟動多線程的方式去之前的路由 Redis 中獲取客戶端所對應(yīng)的 push-server。
再通過 HTTP 的方式調(diào)用 push-server 進行真正的消息下發(fā)(Netty 也很好的支持 HTTP 協(xié)議)。
推送成功之后需要將結(jié)果更新到數(shù)據(jù)庫中,不在線的客戶端可以根據(jù)業(yè)務(wù)再次推送等。
7.6 消息流轉(zhuǎn)
也許有些場景對于客戶端上行的消息非常看重,需要做持久化,并且消息量非常大。
在 push-sever 做業(yè)務(wù)顯然不合適,這時完全可以選擇 Kafka 來解耦。將所有上行的數(shù)據(jù)直接往 Kafka 里丟后就不管了。再由消費程序?qū)?shù)據(jù)取出寫入數(shù)據(jù)庫中即可。
8、分布式帶來的問題
分布式解決了性能問題但卻帶來了其他麻煩。
8.1 應(yīng)用監(jiān)控
比如如何知道線上幾十個 push-server 節(jié)點的健康狀況?這時就得監(jiān)控系統(tǒng)發(fā)揮作用了,我們需要知道各個節(jié)點當前的內(nèi)存使用情況、GC。
以及操作系統(tǒng)本身的內(nèi)存使用,畢竟 Netty 大量使用了堆外內(nèi)存。同時需要監(jiān)控各個節(jié)點當前的在線數(shù),以及 Redis 中的在線數(shù)。理論上這兩個數(shù)應(yīng)該是相等的。
這樣也可以知道系統(tǒng)的使用情況,可以靈活的維護這些節(jié)點數(shù)量。
8.2 日志處理
日志記錄也變得異常重要了,比如哪天反饋有個客戶端一直連不上,你得知道問題出在哪里。
最好是給每次請求都加上一個 traceID 記錄日志,這樣就可以通過這個日志在各個節(jié)點中查看到底是卡在了哪里。以及 ELK 這些工具都得用起來才行。
9、本文小結(jié)
本次是結(jié)合我日常經(jīng)驗得出的,有些坑可能在工作中并沒有踩到,所以還會有一些遺漏的地方。
就目前來看想做一個穩(wěn)定的推送系統(tǒng)是比較麻煩的,其中涉及到的點非常多,只有真正做過之后才會知道。
附錄:更多推送技術(shù)相關(guān)文章
《iOS的推送服務(wù)APNs詳解:設(shè)計思路、技術(shù)原理及缺陷等》
《信鴿團隊原創(chuàng):一起走過 iOS10 上消息推送(APNS)的坑》
《Android端消息推送總結(jié):實現(xiàn)原理、心跳保活、遇到的問題等》
《一個基于MQTT通信協(xié)議的完整Android推送Demo》
《IBM技術(shù)經(jīng)理訪談:MQTT協(xié)議的制定歷程、發(fā)展現(xiàn)狀等》
《求教android消息推送:GCM、XMPP、MQTT三種方案的優(yōu)劣》
《掃盲貼:淺談iOS和Android后臺實時消息推送的原理和區(qū)別》
《絕對干貨:基于Netty實現(xiàn)海量接入的推送服務(wù)技術(shù)要點》
《移動端IM實踐:谷歌消息推送服務(wù)(GCM)研究(來自微信)》
《為何微信、QQ這樣的IM工具不使用GCM服務(wù)推送消息?》
《極光推送系統(tǒng)大規(guī)模高并發(fā)架構(gòu)的技術(shù)實踐分享》
《從HTTP到MQTT:一個基于位置服務(wù)的APP數(shù)據(jù)通信實踐概述》
《魅族2500萬長連接的實時消息推送架構(gòu)的技術(shù)實踐分享》
《專訪魅族架構(gòu)師:海量長連接的實時消息推送系統(tǒng)的心得體會》
《基于WebSocket實現(xiàn)Hybrid移動應(yīng)用的消息推送實踐(含代碼示例)》
《一個基于長連接的安全可擴展的訂閱/推送服務(wù)實現(xiàn)思路》
《實踐分享:如何構(gòu)建一套高可用的移動端消息推送系統(tǒng)?》
《Go語言構(gòu)建千萬級在線的高并發(fā)消息推送系統(tǒng)實踐(來自360公司)》
《騰訊信鴿技術(shù)分享:百億級實時消息推送的實戰(zhàn)經(jīng)驗》
《百萬在線的美拍直播彈幕系統(tǒng)的實時推送技術(shù)實踐之路》
《了解iOS消息推送一文就夠:史上最全iOS Push技術(shù)詳解》
《基于APNs最新HTTP/2接口實現(xiàn)iOS的高性能消息推送(服務(wù)端篇)》
《解密“達達-京東到家”的訂單即時派發(fā)技術(shù)原理和實踐》
《技術(shù)干貨:從零開始,教你設(shè)計一個百萬級的消息推送系統(tǒng)》
>> 更多同類文章 ……
(本文同步發(fā)布于:http://www.52im.net/thread-2096-1-1.html)
作者:Jack Jiang (點擊作者姓名進入Github)
出處:http://www.52im.net/space-uid-1.html
交流:歡迎加入即時通訊開發(fā)交流群 215891622
討論:http://www.52im.net/
Jack Jiang同時是【原創(chuàng)Java
Swing外觀工程BeautyEye】和【輕量級移動端即時通訊框架MobileIMSDK】的作者,可前往下載交流。
本博文
歡迎轉(zhuǎn)載,轉(zhuǎn)載請注明出處(也可前往 我的52im.net 找到我)。