手把手教你設計一個百萬級的訊息推送系統
本文分享的內容不但可以滿足物聯網領域同時還支援以下場景:
- 基於 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 中。
這點和之前 Spring Boot 整合長連線心跳機制類似,如下圖:
同時為了可以通過 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 框架) 是一致的。
都是先定義一個介面用於處理業務邏輯,然後在解析訊息之後通過反射建立具體的物件執行其中的處理函式即可。
這樣不同的業務、不同的開發人員只需要實現這個介面同時實現自己的業務邏輯即可。
虛擬碼如下:
想要了解 cicada 的具體實現請點選這裡:
https://github.com/TogetherOS/cicada
上行還有一點需要注意: 由於是基於長連線,所以客戶端需要定期傳送心跳包用於維護本次連線。
同時服務端也會有相應的檢查,N 個時間間隔沒有收到訊息之後,將會主動斷開連線節省資源。
這點使用一個 IdleStateHandler 就可實現。
訊息下行
有了上行自然也有下行。比如在聊天的場景中,有兩個客戶端連上了 push-server,它們直接需要點對點通訊。
這時的流程是:
- A 將訊息傳送給伺服器。
- 伺服器收到訊息之後,得知訊息是要傳送給 B,需要在記憶體中找到 B 的 Channel。
- 通過 B 的 Channel 將 A 的訊息轉發下去。
這就是一個下行的流程。 甚至管理員需要給所有線上使用者傳送系統通知也是類似:遍歷儲存通道關係的 Map,挨個傳送訊息即可。這也是之前需要存放到 Map 中的主要原因。
虛擬碼如下:
具體可以參考:
https://github.com/crossoverJie/netty-action/
分散式方案
單機版的實現了,現在著重講講如何實現百萬連線。
百萬連線其實只是一個形容詞,更多的是想表達如何來實現一個分散式的方案,可以靈活的水平拓展從而能支援更多的連線。在 做這個事前,首先得搞清楚我們單機版的能支援多少連線。
影響這個的因素就比較多了:
- 伺服器自身配置 。記憶體、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 裡丟後就不管了。再由消費程式將資料取出寫入資料庫中即可。
分散式問題
分散式解決了效能問題但卻帶來了其他麻煩。
應用監控
比如如何知道線上幾十個 push-server 節點的健康狀況? 這時就得監控系統發揮作用了,我們需要知道各個節點當前的記憶體使用情況、GC。
以及作業系統本身的記憶體使用,畢竟 Netty 大量使用了堆外記憶體。 同時需要監控各個節點當前的線上數,以及 Redis 中的線上數。理論上這兩個數應該是相等的。
這樣也可以知道系統的使用情況,可以靈活的維護這些節點數量。
日誌處理
日誌記錄也變得異常重要了,比如哪天反饋有個客戶端一直連不上,你得知道問題出在哪裡。
最好是給每次請求都加上一個 traceID 記錄日誌,這樣就可以通過這個日誌在各個節點中檢視到底是卡在了哪裡。 以及 ELK 這些工具都得用起來才行。
總結
本次是結合我日常經驗得出的,有些坑可能在工作中並沒有踩到,所以還會有一些遺漏的地方。
就目前來看想做一個穩定的推送系統是比較麻煩的,其中涉及到的點非常多,只有真正做過之後才會知道。