如何使用Netty技術設計一個百萬級的消息推送系統

先簡單說下本次的主題,由於我最近做的是物聯網相關的開發工作,其中就不免會遇到和設備的交互。

最主要的工作就是要有一個系統來支持設備的接入、向設備推送消息;同時還得滿足大量設備接入的需求。

所以本次分享的內容不但可以滿足物聯網領域同時還支持以下場景:

  • 基於 WEB 的聊天系統(點對點、羣聊)。

  • WEB 應用中需求服務端推送的場景。

  • 基於 SDK 的消息推送平臺。

技術選型

要滿足大量的連接數、同時支持雙全工通信,並且性能也得有保障。

在 Java 技術棧中進行選型首先自然是排除掉了傳統 IO

那就只有選 NIO 了,在這個層面其實選擇也不多,考慮到社區、資料維護等方面最終選擇了 Netty。

最終的架構圖如下:

現在看着蒙沒關係,下文一一介紹。

協議解析

既然是一個消息系統,那自然得和客戶端定義好雙方的協議格式。

常見和簡單的是 HTTP 協議,但我們的需求中有一項需要是雙全工的交互方式,同時 HTTP 更多的是服務於瀏覽器。我們需要的是一個更加精簡的協議,減少許多不必要的數據傳輸。

因此我覺得最好是在滿足業務需求的情況下定製自己的私有協議,在我這個場景下其實有標準的物聯網協議。

如果是其他場景可以借鑑現在流行的 RPC 框架定製私有協議,使得雙方通信更加高效。

不過根據這段時間的經驗來看,不管是哪種方式都得在協議中預留安全相關的位置。

協議相關的內容就不過討論了,更多介紹具體的應用。

簡單實現

首先考慮如何實現功能,再來思考百萬連接的情況。

註冊鑑權

在做真正的消息上、下行之前首先要考慮的就是鑑權問題。

就像你使用微信一樣,第一步怎麼也得是登錄吧,不能無論是誰都可以直接連接到平臺。

所以第一步得是註冊才行。

如上面架構圖中的 註冊/鑑權 模塊。通常來說都需要客戶端通過 HTTP 請求傳遞一個唯一標識,後臺鑑權通過之後會響應一個 token,並將這個 token 和客戶端的關係維護到 Redis 或者是 DB 中。

客戶端將這個 token 也保存到本地,今後的每一次請求都得帶上這個 token。一旦這個 token 過期,客戶端需要再次請求獲取 token。

鑑權通過之後客戶端會直接通過TCP 長連接到圖中的 push-server 模塊。

這個模塊就是真正處理消息的上、下行。

保存通道關係

在連接接入之後,真正處理業務之前需要將當前的客戶端和 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 最好是預設好大小(避免經常擴容),因爲它將是使用最爲頻繁同時也是佔用內存最大的一個對象。

消息上行

接下來則是真正的業務數據上傳,通常來說第一步是需要判斷上傳消息輸入什麼業務類型。

在聊天場景中,有可能上傳的是文本、圖片、視頻等內容。

所以我們得進行區分,來做不同的處理;這就和客戶端協商的協議有關了。

  • 可以利用消息頭中的某個字段進行區分。

  • 更簡單的就是一個 JSON 消息,拿出一個字段用於區分不同消息。

不管是哪種只有可以區分出來即可。

消息解析與業務解耦

消息可以解析之後便是處理業務,比如可以是寫入數據庫、調用其他接口等。

我們都知道在 Netty 中處理消息一般是在 channelRead() 方法中。

在這裏可以解析消息,區分類型。

但如果我們的業務邏輯也寫在裏面,那這裏的內容將是巨多無比。

甚至我們分爲好幾個開發來處理不同的業務,這樣將會出現許多衝突、難以維護等問題。

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

這時面向接口編程就發揮作用了。

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

都是先定義一個接口用於處理業務邏輯,然後在解析消息之後通過反射創建具體的對象執行其中的處理函數即可。

這樣不同的業務、不同的開發人員只需要實現這個接口同時實現自己的業務邏輯即可。

僞代碼如下:

 

上行還有一點需要注意;由於是基於長連接,所以客戶端需要定期發送心跳包用於維護本次連接。同時服務端也會有相應的檢查,N 個時間間隔沒有收到消息之後將會主動斷開連接節省資源。

這點使用一個 IdleStateHandler 就可實現,更多內容可以查看 SpringBoot開發案例之整合Dubbo分佈式服務

消息下行

有了上行自然也有下行。比如在聊天的場景中,有兩個客戶端連上了 push-server,他們直接需要點對點通信。

這時的流程是:

  • A 將消息發送給服務器。

  • 服務器收到消息之後,得知消息是要發送給 B,需要在內存中找到 B 的 Channel。

  • 通過 B 的 Channel 將 A 的消息轉發下去。

這就是一個下行的流程。

甚至管理員需要給所有在線用戶發送系統通知也是類似:

遍歷保存通道關係的 Map,挨個發送消息即可。這也是之前需要存放到 Map 中的主要原因。

僞代碼如下:

 

分佈式方案

單機版的實現了,現在着重講講如何實現百萬連接。

百萬連接其實只是一個形容詞,更多的是想表達如何來實現一個分佈式的方案,可以靈活的水平拓展從而能支持更多的連接。

再做這個事前首先得搞清楚我們單機版的能支持多少連接。影響這個的因素就比較多了。

  • 服務器自身配置。內存、CPU、網卡、Linux 支持的最大文件打開數等。

  • 應用自身配置,因爲 Netty 本身需要依賴於堆外內存,但是 JVM 本身也是需要佔用一部分內存的,比如存放通道關係的大 Map。這點需要結合自身情況進行調整。

結合以上的情況可以測試出單個節點能支持的最大連接數。

單機無論怎麼優化都是有上限的,這也是分佈式主要解決的問題。

架構介紹

在將具體實現之前首先得講講上文貼出的整體架構圖。

先從左邊開始。

上文提到的 註冊鑑權 模塊也是集羣部署的,通過前置的 Nginx 進行負載。之前也提過了它主要的目的是來做鑑權並返回一個 token 給客戶端。

但是 push-server 集羣之後它又多了一個作用。那就是得返回一臺可供當前客戶端使用的 push-server

右側的 平臺 一般指管理平臺,它可以查看當前的實時在線數、給指定客戶端推送消息等。

推送消息則需要經過一個推送路由(push-server)找到真正的推送節點。

其餘的中間件如:Redis、Zookeeper、Kafka、MySQL 都是爲了這些功能所準備的,具體看下面的實現。

註冊發現

首先第一個問題則是 註冊發現push-server 變爲多臺之後如何給客戶端選擇一臺可用的節點是第一個需要解決的。

所有的 push-server 在啓動時候需要將自身的信息註冊到 Zookeeper 中。

註冊鑑權 模塊會訂閱 Zookeeper 中的節點,從而可以獲取最新的服務列表。結構如下:

以下是一些僞代碼:

應用啓動註冊 Zookeeper。

對於註冊鑑權模塊來說只需要訂閱這個 Zookeeper 節點:

路由策略

既然能獲取到所有的服務列表,那如何選擇一臺剛好合適的 push-server 給客戶端使用呢?

這個過程重點要考慮以下幾點:

  • 儘量保證各個節點的連接均勻。

  • 增刪節點是否要做 Rebalance。

首先保證均衡有以下幾種算法:

  • 輪詢。挨個將各個節點分配給客戶端。但會出現新增節點分配不均勻的情況。

  • Hash 取模的方式。類似於 HashMap,但也會出現輪詢的問題。當然也可以像 HashMap 那樣做一次 Rebalance,讓所有的客戶端重新連接。不過這樣會導致所有的連接出現中斷重連,代價有點大。

  • 由於 Hash 取模方式的問題帶來了一致性 Hash算法,但依然會有一部分的客戶端需要 Rebalance。

  • 權重。可以手動調整各個節點的負載情況,甚至可以做成自動的,基於監控當某些節點負載較高就自動調低權重,負載較低的可以提高權重。

還有一個問題是:

當我們在重啓部分應用進行升級時,在該節點上的客戶端怎麼處理?

由於我們有心跳機制,當心跳不通之後就可以認爲該節點出現問題了。那就得重新請求註冊鑑權模塊獲取一個可用的節點。在弱網情況下同樣適用。

如果這時客戶端正在發送消息,則需要將消息保存到本地等待獲取到新的節點之後再次發送。

有狀態連接

在這樣的場景中不像是 HTTP 那樣是無狀態的,我們得明確的知道各個客戶端和連接的關係。

在上文的單機版中我們將這個關係保存到本地的緩存中,但在分佈式環境中顯然行不通了。

比如在平臺向客戶端推送消息的時候,它得首先知道這個客戶端的通道保存在哪臺節點上。

藉助我們以前的經驗,這樣的問題自然得引入一個第三方中間件用來存放這個關係。

也就是架構圖中的存放路由關係的 Redis,在客戶端接入 push-server 時需要將當前客戶端唯一標識和服務節點的 ip+port 存進 Redis

同時在客戶端下線時候得在 Redis 中刪掉這個連接關係。

這樣在理想情況下各個節點內存中的 map 關係加起來應該正好等於 Redis 中的數據。

僞代碼如下:

這裏存放路由關係的時候會有併發問題,最好是換爲一個 lua 腳本。

推送路由

設想這樣一個場景:管理員需要給最近註冊的客戶端推送一個系統消息會怎麼做?

結合架構圖

假設這批客戶端有 10W 個,首先我們需要將這批號碼通過平臺下的 Nginx 下發到一個推送路由中。

爲了提高效率甚至可以將這批號碼再次分散到每個 push-route 中。

拿到具體號碼之後再根據號碼的數量啓動多線程的方式去之前的路由 Redis 中獲取客戶端所對應的 push-server

再通過 HTTP 的方式調用 push-server 進行真正的消息下發(Netty 也很好的支持 HTTP 協議)。

推送成功之後需要將結果更新到數據庫中,不在線的客戶端可以根據業務再次推送等。

消息流轉

也許有些場景對於客戶端上行的消息非常看重,需要做持久化,並且消息量非常大。

在 push-sever 做業務顯然不合適,這時完全可以選擇 Kafka 來解耦。

將所有上行的數據直接往 Kafka 裏丟後就不管了。

再由消費程序將數據取出寫入數據庫中即可。

後續談到 Kafka 再做詳細介紹。

分佈式問題

分佈式解決了性能問題但卻帶來了其他麻煩。

說到這裏順便給大家推薦一個Java架構方面的交流學習社區:854613173,裏面不僅可以交流討論,

還有面試經驗分享以及免費的資料下載,包括Spring,MyBatis,Netty源碼分析,高併發、高性能、分佈式、微服務架構的原理,

JVM性能優化這些成爲架構師必備的知識體系。相信對於已經工作和遇到技術瓶頸的碼友,在這個羣裏會有你需要的內容。

應用監控

比如如何知道線上幾十個 push-server 節點的健康狀況?

這時就得監控系統發揮作用了,我們需要知道各個節點當前的內存使用情況、GC。

以及操作系統本身的內存使用,畢竟 Netty 大量使用了堆外內存。

同時需要監控各個節點當前的在線數,以及 Redis 中的在線數。理論上這兩個數應該是相等的。

這樣也可以知道系統的使用情況,可以靈活的維護這些節點數量。

日誌處理

日誌記錄也變得異常重要了,比如哪天反饋有個客戶端一直連不上,你得知道問題出在哪裏。

最好是給每次請求都加上一個 traceID 記錄日誌,這樣就可以通過這個日誌在各個節點中查看到底是卡在了哪裏。

以及 ELK 這些工具都得用起來才行。

總結

本次是結合我日常經驗得出的,有些坑可能在工作中並沒有踩到,所有還會有一些遺漏的地方。

就目前來看想做一個穩定的推送系統其實是比較麻煩的,其中涉及到的點非常多,只有真正做過之後纔會知道。

看完之後覺得有幫助的還請不吝轉發分享。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章