出處:https://yq.aliyun.com/articles/644781
前言
首先遲到的祝大家中秋快樂胳泉。
最近一周多沒有更新了绒北。其實我一直想憋一個大招菲宴,分享一些大家感興趣的干貨速蕊。
鑒于最近我個人的工作內(nèi)容途乃,于是利用這三天小長假憋了一個出來(其實是玩了兩天)。
先簡單說下本次的主題幻枉,由于我最近做的是物聯(lián)網(wǎng)相關的開發(fā)工作碰声,其中就不免會遇到和設備的交互。
最主要的工作就是要有一個系統(tǒng)來支持設備的接入熬甫、向設備推送消息胰挑;同時還得滿足大量設備接入的需求。
所以本次分享的內(nèi)容不但可以滿足物聯(lián)網(wǎng)領域同時還支持以下場景:
基于
WEB
的聊天系統(tǒng)(點對點、群聊)瞻颂。WEB
應用中需求服務端推送的場景豺谈。基于 SDK 的消息推送平臺。
技術選型
要滿足大量的連接數(shù)贡这、同時支持雙全工通信茬末,并且性能也得有保障。
在 Java 技術棧中進行選型首先自然是排除掉了傳統(tǒng) IO
盖矫。
那就只有選 NIO 了丽惭,在這個層面其實選擇也不多,考慮到社區(qū)辈双、資料維護等方面最終選擇了 Netty吐根。
最終的架構圖如下:
現(xiàn)在看著蒙沒關系,下文一一介紹辐马。
協(xié)議解析
既然是一個消息系統(tǒng),那自然得和客戶端定義好雙方的協(xié)議格式局义。
常見和簡單的是 HTTP 協(xié)議喜爷,但我們的需求中有一項需要是雙全工的交互方式,同時 HTTP 更多的是服務于瀏覽器萄唇。我們需要的是一個更加精簡的協(xié)議檩帐,減少許多不必要的數(shù)據(jù)傳輸。
因此我覺得最好是在滿足業(yè)務需求的情況下定制自己的私有協(xié)議另萤,在我這個場景下其實有標準的物聯(lián)網(wǎng)協(xié)議湃密。
如果是其他場景可以借鑒現(xiàn)在流行的 RPC
框架定制私有協(xié)議,使得雙方通信更加高效四敞。
不過根據(jù)這段時間的經(jīng)驗來看泛源,不管是哪種方式都得在協(xié)議中預留安全相關的位置。
協(xié)議相關的內(nèi)容就不過討論了忿危,更多介紹具體的應用达箍。
簡單實現(xiàn)
首先考慮如何實現(xiàn)功能,再來思考百萬連接的情況铺厨。
注冊鑒權
在做真正的消息上缎玫、下行之前首先要考慮的就是鑒權問題。
就像你使用微信一樣解滓,第一步怎么也得是登錄吧赃磨,不能無論是誰都可以直接連接到平臺洼裤。
所以第一步得是注冊才行邻辉。
如上面架構圖中的 注冊/鑒權
模塊。通常來說都需要客戶端通過 HTTP
請求傳遞一個唯一標識搅裙,后臺鑒權通過之后會響應一個 token
颅和,并將這個 token
和客戶端的關系維護到 Redis
或者是 DB 中峡扩。
客戶端將這個 token 也保存到本地,今后的每一次請求都得帶上這個 token。一旦這個 token 過期倦卖,客戶端需要再次請求獲取 token怕膛。
鑒權通過之后客戶端會直接通過 TCP長連接
到圖中的 push-server
模塊。
這個模塊就是真正處理消息的上秦踪、下行褐捻。
保存通道關系
在連接接入之后掸茅,真正處理業(yè)務之前需要將當前的客戶端和 Channel 的關系維護起來。
假設客戶端的唯一標識是手機號碼柠逞,那就需要把手機號碼和當前的 Channel 維護到一個 Map 中昧狮。
這點和之前 SpringBoot 整合長連接心跳機制 類似。
同時為了可以通過 Channel 獲取到客戶端唯一標識(手機號碼)板壮,還需要在 Channel 中設置對應的屬性:
public static void putClientId(Channel channel, String clientId) {
channel.attr(CLIENT_ID).set(clientId);
}
獲取時手機號碼時:
public static String getClientId(Channel channel) {
return (String)getAttribute(channel, CLIENT_ID);
}
這樣當我們客戶端下線的時便可以記錄相關日志:
String telNo = NettyAttrUtil.getClientId(ctx.channel());
NettySocketHolder.remove(telNo);
log.info("客戶端下線逗鸣,TelNo=" + telNo);
這里有一點需要注意:存放客戶端與 Channel 關系的 Map 最好是預設好大小(避免經(jīng)常擴容)绰精,因為它將是使用最為頻繁同時也是占用內(nèi)存最大的一個對象撒璧。
消息上行
接下來則是真正的業(yè)務數(shù)據(jù)上傳,通常來說第一步是需要判斷上傳消息輸入什么業(yè)務類型笨使。
在聊天場景中卿樱,有可能上傳的是文本、圖片硫椰、視頻等內(nèi)容繁调。
所以我們得進行區(qū)分,來做不同的處理靶草;這就和客戶端協(xié)商的協(xié)議有關了涉馁。
可以利用消息頭中的某個字段進行區(qū)分。
更簡單的就是一個
JSON
消息爱致,拿出一個字段用于區(qū)分不同消息。
不管是哪種只有可以區(qū)分出來即可寒随。
消息解析與業(yè)務解耦
消息可以解析之后便是處理業(yè)務糠悯,比如可以是寫入數(shù)據(jù)庫、調(diào)用其他接口等妻往。
我們都知道在 Netty 中處理消息一般是在 channelRead()
方法中互艾。
在這里可以解析消息,區(qū)分類型讯泣。
但如果我們的業(yè)務邏輯也寫在里面纫普,那這里的內(nèi)容將是巨多無比。
甚至我們分為好幾個開發(fā)來處理不同的業(yè)務好渠,這樣將會出現(xiàn)許多沖突昨稼、難以維護等問題。
所以非常有必要將消息解析與業(yè)務處理完全分離開來拳锚。
這時面向接口編程就發(fā)揮作用了假栓。
這里的核心代碼和 「造個輪子」——cicada(輕量級 WEB 框架) 是一致的。
都是先定義一個接口用于處理業(yè)務邏輯霍掺,然后在解析消息之后通過反射創(chuàng)建具體的對象執(zhí)行其中的 處理函數(shù)
即可匾荆。
這樣不同的業(yè)務拌蜘、不同的開發(fā)人員只需要實現(xiàn)這個接口同時實現(xiàn)自己的業(yè)務邏輯即可。
偽代碼如下:
想要了解 cicada 的具體實現(xiàn)請點擊這里:
https://github.com/TogetherOS/cicada
上行還有一點需要注意牙丽;由于是基于長連接简卧,所以客戶端需要定期發(fā)送心跳包用于維護本次連接。同時服務端也會有相應的檢查烤芦,N 個時間間隔沒有收到消息之后將會主動斷開連接節(jié)省資源举娩。
這點使用一個 IdleStateHandler
就可實現(xiàn),更多內(nèi)容可以查看 Netty(一) SpringBoot 整合長連接心跳機制拍棕。
消息下行
有了上行自然也有下行晓铆。比如在聊天的場景中,有兩個客戶端連上了 push-server
,他們直接需要點對點通信绰播。
這時的流程是:
A 將消息發(fā)送給服務器骄噪。
服務器收到消息之后,得知消息是要發(fā)送給 B蠢箩,需要在內(nèi)存中找到 B 的 Channel链蕊。
通過 B 的 Channel 將 A 的消息轉(zhuǎn)發(fā)下去。
這就是一個下行的流程谬泌。
甚至管理員需要給所有在線用戶發(fā)送系統(tǒng)通知也是類似:
遍歷保存通道關系的 Map滔韵,挨個發(fā)送消息即可。這也是之前需要存放到 Map 中的主要原因掌实。
偽代碼如下:
具體可以參考:
https://github.com/crossoverJie/netty-action/
分布式方案
單機版的實現(xiàn)了陪蜻,現(xiàn)在著重講講如何實現(xiàn)百萬連接。
百萬連接其實只是一個形容詞贱鼻,更多的是想表達如何來實現(xiàn)一個分布式的方案宴卖,可以靈活的水平拓展從而能支持更多的連接。
再做這個事前首先得搞清楚我們單機版的能支持多少連接邻悬。影響這個的因素就比較多了症昏。
服務器自身配置。內(nèi)存父丰、CPU肝谭、網(wǎng)卡、Linux 支持的最大文件打開數(shù)等蛾扇。
應用自身配置攘烛,因為 Netty 本身需要依賴于堆外內(nèi)存,但是 JVM 本身也是需要占用一部分內(nèi)存的镀首,比如存放通道關系的大
Map
医寿。這點需要結合自身情況進行調(diào)整。
結合以上的情況可以測試出單個節(jié)點能支持的最大連接數(shù)蘑斧。
單機無論怎么優(yōu)化都是有上限的靖秩,這也是分布式主要解決的問題须眷。
架構介紹
在將具體實現(xiàn)之前首先得講講上文貼出的整體架構圖。
先從左邊開始沟突。
上文提到的 注冊鑒權
模塊也是集群部署的花颗,通過前置的 Nginx 進行負載。之前也提過了它主要的目的是來做鑒權并返回一個 token 給客戶端惠拭。
但是 push-server
集群之后它又多了一個作用扩劝。那就是得返回一臺可供當前客戶端使用的 push-server
。
右側的 平臺
一般指管理平臺职辅,它可以查看當前的實時在線數(shù)棒呛、給指定客戶端推送消息等。
推送消息則需要經(jīng)過一個推送路由( push-server
)找到真正的推送節(jié)點域携。
其余的中間件如:Redis簇秒、Zookeeper、Kafka秀鞭、MySQL 都是為了這些功能所準備的趋观,具體看下面的實現(xiàn)。
注冊發(fā)現(xiàn)
首先第一個問題則是 注冊發(fā)現(xiàn)
锋边, push-server
變?yōu)槎嗯_之后如何給客戶端選擇一臺可用的節(jié)點是第一個需要解決的皱坛。
這塊的內(nèi)容其實已經(jīng)在 分布式(一) 搞定服務注冊與發(fā)現(xiàn) 中詳細講過了。
所有的 push-server
在啟動時候需要將自身的信息注冊到 Zookeeper 中豆巨。
注冊鑒權
模塊會訂閱 Zookeeper 中的節(jié)點剩辟,從而可以獲取最新的服務列表。結構如下:
以下是一些偽代碼:
應用啟動注冊 Zookeeper往扔。
對于 注冊鑒權
模塊來說只需要訂閱這個 Zookeeper 節(jié)點:
路由策略
既然能獲取到所有的服務列表抹沪,那如何選擇一臺剛好合適的 push-server
給客戶端使用呢?
這個過程重點要考慮以下幾點:
盡量保證各個節(jié)點的連接均勻瓤球。
增刪節(jié)點是否要做 Rebalance。
首先保證均衡有以下幾種算法:
輪詢敏弃。挨個將各個節(jié)點分配給客戶端卦羡。但會出現(xiàn)新增節(jié)點分配不均勻的情況。
Hash 取模的方式麦到。類似于 HashMap绿饵,但也會出現(xiàn)輪詢的問題。當然也可以像 HashMap 那樣做一次 Rebalance瓶颠,讓所有的客戶端重新連接拟赊。不過這樣會導致所有的連接出現(xiàn)中斷重連,代價有點大粹淋。
由于 Hash 取模方式的問題帶來了
一致性Hash
算法吸祟,但依然會有一部分的客戶端需要 Rebalance瑟慈。權重∥葚埃可以手動調(diào)整各個節(jié)點的負載情況葛碧,甚至可以做成自動的,基于監(jiān)控當某些節(jié)點負載較高就自動調(diào)低權重过吻,負載較低的可以提高權重进泼。
還有一個問題是:
當我們在重啟部分應用進行升級時,在該節(jié)點上的客戶端怎么處理纤虽?
由于我們有心跳機制乳绕,當心跳不通之后就可以認為該節(jié)點出現(xiàn)問題了。那就得重新請求 注冊鑒權
模塊獲取一個可用的節(jié)點逼纸。在弱網(wǎng)情況下同樣適用洋措。
如果這時客戶端正在發(fā)送消息,則需要將消息保存到本地等待獲取到新的節(jié)點之后再次發(fā)送樊展。
有狀態(tài)連接
在這樣的場景中不像是 HTTP 那樣是無狀態(tài)的呻纹,我們得明確的知道各個客戶端和連接的關系。
在上文的單機版中我們將這個關系保存到本地的緩存中专缠,但在分布式環(huán)境中顯然行不通了雷酪。
比如在平臺向客戶端推送消息的時候,它得首先知道這個客戶端的通道保存在哪臺節(jié)點上涝婉。
借助我們以前的經(jīng)驗哥力,這樣的問題自然得引入一個第三方中間件用來存放這個關系。
也就是架構圖中的存放 路由關系的Redis
墩弯,在客戶端接入 push-server
時需要將當前客戶端唯一標識和服務節(jié)點的 ip+port
存進 Redis
吩跋。
同時在客戶端下線時候得在 Redis 中刪掉這個連接關系。
這樣在理想情況下各個節(jié)點內(nèi)存中的 map 關系加起來應該正好等于 Redis 中的數(shù)據(jù)渔工。
偽代碼如下:
這里存放路由關系的時候會有并發(fā)問題锌钮,最好是換為一個 lua
腳本。
推送路由
設想這樣一個場景:管理員需要給最近注冊的客戶端推送一個系統(tǒng)消息會怎么做引矩?
結合架構圖
假設這批客戶端有 10W 個梁丘,首先我們需要將這批號碼通過 平臺
下的 Nginx
下發(fā)到一個推送路由中。
為了提高效率甚至可以將這批號碼再次分散到每個 push-route
中旺韭。
拿到具體號碼之后再根據(jù)號碼的數(shù)量啟動多線程的方式去之前的路由 Redis 中獲取客戶端所對應的 push-server
氛谜。
再通過 HTTP 的方式調(diào)用 push-server
進行真正的消息下發(fā)(Netty 也很好的支持 HTTP 協(xié)議)。
推送成功之后需要將結果更新到數(shù)據(jù)庫中区端,不在線的客戶端可以根據(jù)業(yè)務再次推送等值漫。
消息流轉(zhuǎn)
也許有些場景對于客戶端上行的消息非常看重织盼,需要做持久化杨何,并且消息量非常大酱塔。
在 push-sever
做業(yè)務顯然不合適,這時完全可以選擇 Kafka 來解耦晚吞。
將所有上行的數(shù)據(jù)直接往 Kafka 里丟后就不管了延旧。
再由消費程序?qū)?shù)據(jù)取出寫入數(shù)據(jù)庫中即可。
其實這塊內(nèi)容也很值得討論槽地,可以先看這篇了解下:強如 Disruptor 也發(fā)生內(nèi)存溢出迁沫?
后續(xù)談到 Kafka 再做詳細介紹。
分布式問題
分布式解決了性能問題但卻帶來了其他麻煩捌蚊。
應用監(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ù)應該是相等的赴涵。
這樣也可以知道系統(tǒng)的使用情況媒怯,可以靈活的維護這些節(jié)點數(shù)量。
日志處理
日志記錄也變得異常重要了髓窜,比如哪天反饋有個客戶端一直連不上扇苞,你得知道問題出在哪里。
最好是給每次請求都加上一個 traceID 記錄日志寄纵,這樣就可以通過這個日志在各個節(jié)點中查看到底是卡在了哪里鳖敷。
以及 ELK 這些工具都得用起來才行。
總結
本次是結合我日常經(jīng)驗得出的程拭,有些坑可能在工作中并沒有踩到定踱,所有還會有一些遺漏的地方。
就目前來看想做一個穩(wěn)定的推送系統(tǒng)其實是比較麻煩的恃鞋,其中涉及到的點非常多崖媚,只有真正做過之后才會知道。