【轉(zhuǎn)】基于Netty設計一個百萬級的消息推送系統(tǒng)

出處: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吐根。

最終的架構圖如下:

1.png

現(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 整合長連接心跳機制 類似。

2.png

同時為了可以通過 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() 方法中互艾。

3.png

在這里可以解析消息,區(qū)分類型讯泣。

但如果我們的業(yè)務邏輯也寫在里面纫普,那這里的內(nèi)容將是巨多無比。

甚至我們分為好幾個開發(fā)來處理不同的業(yè)務好渠,這樣將會出現(xiàn)許多沖突昨稼、難以維護等問題。

所以非常有必要將消息解析與業(yè)務處理完全分離開來拳锚。

這時面向接口編程就發(fā)揮作用了假栓。

這里的核心代碼和 「造個輪子」——cicada(輕量級 WEB 框架) 是一致的。

都是先定義一個接口用于處理業(yè)務邏輯霍掺,然后在解析消息之后通過反射創(chuàng)建具體的對象執(zhí)行其中的 處理函數(shù)即可匾荆。

這樣不同的業(yè)務拌蜘、不同的開發(fā)人員只需要實現(xiàn)這個接口同時實現(xiàn)自己的業(yè)務邏輯即可。

偽代碼如下:

4.png
5.png

想要了解 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 中的主要原因掌实。

偽代碼如下:

6.png

具體可以參考:

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)之前首先得講講上文貼出的整體架構圖。


7.png

先從左邊開始沟突。

上文提到的 注冊鑒權 模塊也是集群部署的花颗,通過前置的 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é)點剩辟,從而可以獲取最新的服務列表。結構如下:

8.png

以下是一些偽代碼:

應用啟動注冊 Zookeeper往扔。

9.png

對于 注冊鑒權模塊來說只需要訂閱這個 Zookeeper 節(jié)點:

10.png

路由策略

既然能獲取到所有的服務列表抹沪,那如何選擇一臺剛好合適的 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ù)渔工。

偽代碼如下:

11.png

這里存放路由關系的時候會有并發(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)其實是比較麻煩的恃鞋,其中涉及到的點非常多崖媚,只有真正做過之后才會知道。

?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末山宾,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子鳍徽,更是在濱河造成了極大的恐慌资锰,老刑警劉巖,帶你破解...
    沈念sama閱讀 217,907評論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件阶祭,死亡現(xiàn)場離奇詭異绷杜,居然都是意外死亡直秆,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,987評論 3 395
  • 文/潘曉璐 我一進店門鞭盟,熙熙樓的掌柜王于貴愁眉苦臉地迎上來圾结,“玉大人,你說我怎么就攤上這事齿诉◇菀埃” “怎么了?”我有些...
    開封第一講書人閱讀 164,298評論 0 354
  • 文/不壞的土叔 我叫張陵粤剧,是天一觀的道長歇竟。 經(jīng)常有香客問我,道長抵恋,這世上最難降的妖魔是什么焕议? 我笑而不...
    開封第一講書人閱讀 58,586評論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮弧关,結果婚禮上盅安,老公的妹妹穿的比我還像新娘。我一直安慰自己世囊,他們只是感情好别瞭,可當我...
    茶點故事閱讀 67,633評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著茸习,像睡著了一般畜隶。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上号胚,一...
    開封第一講書人閱讀 51,488評論 1 302
  • 那天籽慢,我揣著相機與錄音,去河邊找鬼猫胁。 笑死箱亿,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的弃秆。 我是一名探鬼主播届惋,決...
    沈念sama閱讀 40,275評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼菠赚!你這毒婦竟也來了脑豹?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 39,176評論 0 276
  • 序言:老撾萬榮一對情侶失蹤衡查,失蹤者是張志新(化名)和其女友劉穎瘩欺,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,619評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡俱饿,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,819評論 3 336
  • 正文 我和宋清朗相戀三年戒良,在試婚紗的時候發(fā)現(xiàn)自己被綠了熏瞄。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片毙玻。...
    茶點故事閱讀 39,932評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡勺届,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出枣购,到底是詐尸還是另有隱情嬉探,我是刑警寧澤,帶...
    沈念sama閱讀 35,655評論 5 346
  • 正文 年R本政府宣布坷虑,位于F島的核電站甲馋,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏迄损。R本人自食惡果不足惜定躏,卻給世界環(huán)境...
    茶點故事閱讀 41,265評論 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望芹敌。 院中可真熱鬧痊远,春花似錦、人聲如沸氏捞。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,871評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽液茎。三九已至逞姿,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間捆等,已是汗流浹背滞造。 一陣腳步聲響...
    開封第一講書人閱讀 32,994評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留栋烤,地道東北人谒养。 一個月前我還...
    沈念sama閱讀 48,095評論 3 370
  • 正文 我出身青樓,卻偏偏與公主長得像明郭,于是被迫代替她去往敵國和親买窟。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 44,884評論 2 354

推薦閱讀更多精彩內(nèi)容

  • 前言 首先遲到的祝大家中秋快樂薯定。 最近一周多沒有更新了始绍。其實我一直想憋一個大招,分享一些大家感興趣的干貨话侄。 鑒于最...
    crossoverJie閱讀 10,040評論 5 95
  • 寫下這個標題亏推,已經(jīng)好幾天了。之所以遲遲沒寫,可能是因為径簿,我已不再執(zhí)著。所謂執(zhí)著嘀韧,原為佛教用語篇亭,指對某一事物堅持不放...
    海月先生閱讀 619評論 0 0
  • 在一起時,愛你都是偽裝的锄贷。愛的其實是你的錢译蒂,你給的希望加上自己的瘋狂幻想。 分手后谊却,不愛你是偽裝的柔昼。將你發(fā)的表白信...
    幸小川閱讀 323評論 1 0
  • 最近正在看二月河先生寫的《乾隆皇帝全集》這本書捕透,今天剛好看到和珅正在發(fā)跡的那一段歷程,真的讓人無不敬佩碴萧,和珅從一個...
    茂談教育閱讀 254評論 0 3