如何設計一個百萬級的消息推送系統?

前言

首先遲到的祝你們中秋快樂。html

最近一週多沒有更新了。其實我一直想憋一個大招,分享一些你們感興趣的乾貨。前端

鑑於最近我我的的工做內容,因而利用這三天小長假憋了一個出來(實際上是玩了兩天🤣)。java


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

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

因此本次分享的內容不但能夠知足物聯網領域同時還支持如下場景:git

  • 基於 WEB 的聊天系統(點對點、羣聊)。
  • WEB 應用中需求服務端推送的場景。
  • 基於 SDK 的消息推送平臺。

技術選型

要知足大量的鏈接數、同時支持雙全工通訊,而且性能也得有保障。github

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

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

最終的架構圖以下:sql

如今看着蒙不要緊,下文一一介紹。

協議解析

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

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

所以我以爲最好是在知足業務需求的狀況下定製本身的私有協議,在我這個場景下其實有標準的物聯網協議。

若是是其餘場景能夠借鑑如今流行的 RPC 框架定製私有協議,使得雙方通訊更加高效。

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

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

簡單實現

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

註冊鑑權

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

就像你使用微信同樣,第一步怎麼也得是登陸吧,不能不管是誰均可以直接鏈接到平臺。

因此第一步得是註冊才行。

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

客戶端將這個 token 也保存到本地,從此的每一次請求都得帶上這個 token。一旦這個 token 過時,客戶端須要再次請求獲取 token。

鑑權經過以後客戶端會直接經過TCP 長鏈接到圖中的 push-server 模塊。

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

保存通道關係

在鏈接接入以後,真正處理業務以前須要將當前的客戶端和 Channel 的關係維護起來。

假設客戶端的惟一標識是手機號碼,那就須要把手機號碼和當前的 Channel 維護到一個 Map 中。

這點和以前 SpringBoot 整合長鏈接心跳機制 相似。

同時爲了能夠經過 Channel 獲取到客戶端惟一標識(手機號碼),還須要在 Channel 中設置對應的屬性:

123 public static void putClientId(Channel channel, String clientId) { channel.attr(CLIENT_ID).set(clientId);}

獲取時手機號碼時:

123 public static String getClientId(Channel channel) { return (String)getAttribute(channel, CLIENT_ID);}

這樣當咱們客戶端下線的時即可以記錄相關日誌:

123 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 就可實現,更多內容能夠查看 Netty(一) SpringBoot 整合長鏈接心跳機制

消息下行

有了上行天然也有下行。好比在聊天的場景中,有兩個客戶端連上了 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 裏丟後就無論了。

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

其實這塊內容也很值得討論,能夠先看這篇瞭解下:強如 Disruptor 也發生內存溢出?

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

分佈式問題

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

應用監控

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

這時就得監控系統發揮做用了,咱們須要知道各個節點當前的內存使用狀況、GC。

以及操做系統自己的內存使用,畢竟 Netty 大量使用了堆外內存。

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

這樣也能夠知道系統的使用狀況,能夠靈活的維護這些節點數量。

日誌處理

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

最好是給每次請求都加上一個 traceID 記錄日誌,這樣就能夠經過這個日誌在各個節點中查看究竟是卡在了哪裏。

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

總結

本次是結合我平常經驗得出的,有些坑可能在工做中並無踩到,全部還會有一些遺漏的地方。

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

看完以後以爲有幫助的還請不吝轉發分享。

往期文章

Nginx系列教程(1)nginx基本介紹和安裝入門

Nginx系列教程(2)nginx搭建靜態資源web服務器

Nginx系列教程(3)nginx緩存服務器上的靜態文件

Nginx系列教程(4)nginx處理web應用負載均衡問題以保證高併發

Nginx系列教程(5)如何保障nginx的高可用性(keepalived)

Nginx系列教程(6)nginx location 匹配規則詳細解說

Nginx系列教程(7)nginx rewrite配置規則詳細說明

Nginx系列教程(8)nginx配置安全證書SSL

Nginx系列教程(9)nginx 解決session一致性

Nginx系列教程(10)基於nginx解決前端訪問後端服務跨域問題(Session和cookie無效)

相關文章
相關標籤/搜索