RocketMQ 端雲一體化設計與實踐
簡介:本文首先介紹了端雲訊息場景一體化的背景,然後重點分析了終端訊息場景特點,以及終端訊息場景支撐模型,最後對架構和儲存核心進行了闡述。我們期望基於 RocketMQ 統一核心一體化支援終端和服務端不同場景的訊息接入目標,以能夠給使用者帶來一體化的價值,如降低儲存成本,避免資料在不同系統間同步帶來的一致性挑戰。
作者 | 悟幻
一體化背景
不止於分發
我們都知道以 RocketMQ 為代表的訊息(佇列)起源於不同應用服務之間的非同步解耦通訊,與以 Dubbo 為代表的 RPC 類服務通訊一同承載了分散式系統(服務)之間的通訊場景,所以服務間的訊息分發是訊息的基礎訴求。然而我們看到,在訊息(佇列)這個領域,近些年我們業界有個很重要的趨勢,就是基於訊息這份資料可以擴充套件到流批計算、事件驅動等不同場景,如 RocketMQ-streams,Kafka-Streams、Rabbit-Streams 等等。
不止於服務端
傳統的訊息佇列 MQ 主要應用於服務(端)之間的訊息通訊,比如電商領域的交易訊息、支付訊息、物流訊息等等。然而在訊息這個大類下,還有一個非常重要且常見的訊息領域,即終端訊息。訊息的本質就是傳送和接受,終端和服務端並沒有本質上的大區別。
一體化價值
如果可以有一個統一的訊息系統(產品)來提供多場景計算(如 stream、event)、多場景(IoT、APP)接入,其實是非常有價值的,因為訊息也是一種重要資料,資料如果只存在一個系統內,可以最大地降低儲存成本,同時可以有效地避免資料因在不同系統間同步帶來的一致性難題。
終端訊息分析
本文將主要描述的是終端訊息和服務端訊息一體化設計與實踐問題,所以首先我們對面向終端的這一大類訊息做一下基本分析。
場景介紹
近些年,我們看到隨著智慧家居、工業互聯而興起的面向 IoT 裝置類的訊息正在呈爆炸式增長,而已經發展十餘年的移動網際網路的手機 APP 端訊息仍然是數量級龐大。面向終端裝置的訊息數量級比傳統服務端的訊息要大很多量級,並仍然在快速增長。
特性分析
儘管無論是終端訊息還是服務端訊息,其本質都是訊息的傳送和接受,但是終端場景還是有和服務端不太一樣的特點,下面簡要分析一下:
- 輕量
服務端一般都是使用很重的客戶端 SDK 封裝了很多功能和特性,然而終端因為執行環境受限且龐雜必須使用輕量簡潔的客戶端 SDK。
- 標準協議
服務端正是因為有了重量級客戶端 SDK,其封裝了包括協議通訊在內的全部功能,甚至可以弱化協議的存在,使用者無須感知,而終端場景因為要支援各類龐雜的裝置和場景接入,必須要有個標準協議定義。
- P2P
服務端訊息如果一臺伺服器處理失敗可以由另外一臺伺服器處理成功即可,而終端訊息必須明確發給具體終端,若該終端處理失敗則必須一直重試傳送該終端直到成功,這個和服務端很不一樣。
- 廣播比
服務端訊息比如交易系統傳送了一條訂單訊息,可能有如營銷、庫存、物流等幾個系統感興趣,而終端場景比如群聊、直播可能成千上萬的終端裝置或使用者需要收到。
- 海量接入
終端場景接入的是終端裝置,而服務端接入的就是伺服器,前者在量級上肯定遠大於後者。
架構與模型
訊息基礎分析
實現一體化前我們先從理論上分析一下問題和可行性。我們知道,無論是終端訊息還是服務端訊息,其實就是一種通訊方式,從通訊的層面看要解決的基礎問題簡單總結就是:協議、匹配、觸達。
- 協議
協議就是定義了一個溝通語言頻道,通訊雙方能夠聽懂內容語義。在終端場景,目前業界廣泛使用的是 MQTT 協議,起源於物聯網 IoT 場景,OASIS 聯盟定義的標準的開放式協議。MQTT 協議定義了是一個 Pub/Sub 的通訊模型,這個與 RocketMQ 類似的,不過其在訂閱方式上比較靈活,可以支援多級 Topic 訂閱(如 “/t/t1/t2”),可以支援萬用字元訂閱(如 “/t/t1/+”)
- 匹配
匹配就是傳送一條訊息後要找到所有的接受者,這個匹配查詢過程是不可或缺的。在 RocketMQ 裡面實際上有這個類似的匹配過程,其通過將某個 Queue 通過 rebalance 方式分配到消費組內某臺機器上,訊息通過 Queue 就直接對應上了消費機器,再通過訂閱過濾(Tag 或 SQL)進行精準匹配消費者。之所以通過 Queue 就可以匹配消費機器,是因為服務端場景訊息並不需要明確指定某臺消費機器,一條訊息可以放到任意 Queue 裡面,並且任意一臺消費機器對應這個 Queue 都可以,訊息不需要明確匹配消費機器。
而在終端場景下,一條訊息必須明確指定某個接受者(裝置),必須準確找到所有接受者,而且終端裝置一般只會連到某個後端服務節點即單連線,和訊息產生的節點不是同一個,必須有個較複雜的匹配查詢目標的過程,還有如 MQTT 萬用字元這種更靈活的匹配特性。
- 觸達
觸達即通過匹配查詢後找到所有的接受者目標,需要將訊息以某種可靠方式發給接受者。常見的觸發方式有兩種:Push、Pull。Push,即服務端主動推送訊息給終端裝置,主動權在服務端側,終端裝置通過 ACK 來反饋訊息是否成功收到或處理,服務端需要根據終端是否返回 ACK 來決定是否重投。Pull,即終端裝置主動來服務端獲取其所有訊息,主動權在終端裝置側,一般通過位點 Offset 來依次獲取訊息,RocketMQ 就是這種訊息獲取方式。
對比兩種方式,我們可以看到 Pull 方式需要終端裝置主動管理訊息獲取邏輯,這個邏輯其實有一定的複雜性(可以參考 RocketMQ 的客戶端管理邏輯),而終端裝置執行環境和條件都很龐雜,不太適應較複雜的 Pull 邏輯實現,比較適合被動的 Push 方式。
另外,終端訊息有一個很重要的區別是可靠性保證的 ACK 必須是具體到一個終端裝置的,而服務端訊息的可靠性在於只要有一臺消費者機器成功處理即可,不太關心是哪臺消費者機器,訊息的可靠性 ACK 標識可以集中在消費組維度,而終端訊息的可靠性 ACK 標識需要具體離散到終端裝置維度。簡單地說,一個是客戶端裝置維度的 Retry 佇列,一個是消費組維度的 Retry 佇列。
模型與元件
基於前面的訊息基礎一般性分析,我們來設計訊息模型,主要是要解決好匹配查詢和可靠觸達兩個核心問題。
- 佇列模型
訊息能夠可靠性觸達的前提是要可靠儲存,訊息儲存的目的是為了讓接受者能獲取到訊息,接受者一般有兩種訊息檢索維度:
1)根據訂閱的主題 Topic 去查詢訊息;
2)根據訂閱者 ID 去查詢訊息。這個就是業界常說的放大模型:讀放大、寫放大。
讀放大:即訊息按 Topic 進行儲存,接受者根據訂閱的 Topic 列表去相應的 Topic 佇列讀取訊息。
寫放大:即訊息分別寫到所有訂閱的接受者佇列中,每個接受者讀取自己的客戶端佇列。
可以看到讀放大場景下訊息只寫一份,寫到 Topic 維度的佇列,但接受者讀取時需要按照訂閱的 Topic 列表多次讀取,而寫放大場景下訊息要寫多份,寫到所有接受者的客戶端佇列裡面,顯然儲存成本較大,但接受者讀取簡單,只需讀取自己客戶端一個佇列即可。
我們採用的讀放大為主,寫放大為輔的策略,因為儲存的成本和效率對使用者的體感最明顯。寫多份不僅加大了儲存成本,同時也對效能和資料準確一致性提出了挑戰。但是有一個地方我們使用了寫放大模式,就是萬用字元匹配,因為接受者訂閱的是萬用字元和訊息的 Topic 不是一樣的內容,接受者讀訊息時沒法反推出訊息的 Topic,因此需要在訊息傳送時根據萬用字元的訂閱多寫一個萬用字元佇列,這樣接受者直接可以根據其訂閱的萬用字元佇列讀取訊息。
上圖描述的接受我們的佇列儲存模型,訊息可以來自各個接入場景(如服務端的 MQ/AMQP,客戶端的 MQTT),但只會寫一份存到 commitlog 裡面,然後分發出多個需求場景的佇列索引(ConsumerQueue),如服務端場景(MQ/AMQP)可以按照一級 Topic 佇列進行傳統的服務端消費,客戶端 MQTT 場景可以按照 MQTT 多級 Topic 以及萬用字元訂閱進行消費訊息。
這樣的一個佇列模型就可以同時支援服務端和終端場景的接入和訊息收發,達到一體化的目標。
- 推拉模型
介紹了底層的佇列儲存模型後,我們再詳細描述一下匹配查詢和可靠觸達是怎麼做的。
上圖展示的是一個推拉模型,圖中的 P 節點是一個協議閘道器或 broker 外掛,終端裝置通過 MQTT 協議連到這個閘道器節點。訊息可以來自多種場景(MQ/AMQP/MQTT)傳送過來,存到 Topic 佇列後會有一個 notify 邏輯模組來實時感知這個新訊息到達,然後會生成訊息事件(就是訊息的 Topic 名稱),將該事件推送至閘道器節點,閘道器節點根據其連上的終端裝置訂閱情況進行內部匹配,找到哪些終端裝置能匹配上,然後會觸發 pull 請求去儲存層讀取訊息再推送終端裝置。
一個重要問題,就是 notify 模組怎麼知道一條訊息在哪些閘道器節點上面的終端裝置感興趣,這個其實就是關鍵的匹配查詢問題。一般有兩種方式:1)簡單的廣播事件;2)集中儲存線上訂閱關係(如圖中的 lookup 模組),然後進行匹配查詢再精準推送。事件廣播機制看起來有擴充套件性問題,但是其實效能並不差,因為我們推送的資料很小就是 Topic 名稱,而且相同 Topic 的訊息事件可以合併成一個事件,我們線上就是預設採用的這個方式。集中儲存線上訂閱關係,這個也是常見的一種做法,如儲存到 Rds、Redis 等,但要保證資料的實時一致性也有難度,而且要進行匹配查詢對整個訊息的實時鏈路 RT 開銷也會有一定的影響。
可靠觸達及實時性這塊,上圖的推拉過程中首先是通過事件通知機制來實時告知閘道器節點,然後閘道器節點通過 Pull 機制來換取訊息,然後 Push 給終端裝置。Pull+Offset 機制可以保證訊息的可靠性,這個是 RocketMQ 的傳統模型,終端節點被動接受閘道器節點的 Push,解決了終端裝置輕量問題,實時性方面因為新訊息事件通知機制而得到保障。
上圖中還有一個 Cache 模組用於做訊息佇列 cache,因為在大廣播比場景下如果為每個終端裝置都去發起佇列 Pull 請求則對 broker 讀壓力較大,既然每個請求都去讀取相同的 Topic 佇列,則可以複用本地佇列 cache。
- lookup元件
上面的推拉模型通過新訊息事件通知機制來解決實時觸達問題,事件推送至閘道器的時候需要一個匹配查詢過程,儘管簡單的事件廣播機制可以到達一定的效能要求,但畢竟是一個廣播模型,在大規模閘道器節點接入場景下仍然有效能瓶頸。另外,終端裝置場景有很多狀態查詢訴求,如查詢線上狀態,連線互踢等等,仍然需要一個 KV 查詢元件,即 lookup。
我們當然可以使用外部 KV 儲存如 Redis,但我們不能假定系統(產品)在使用者的交付環境,尤其是專有云的特殊環境一定有可靠的外部儲存服務依賴。
這個 lookup 查詢元件,實際上就是一個 KV 查詢,可以理解為是一個分散式記憶體 KV,但要比分散式 KV 實現難度至少低一個等級。我們回想一下一個分散式 KV 的基本要素有哪些:
如上圖所示,一般一個分散式 KV 讀寫流程是,Key 通過 hash 得到一個邏輯 slot,slot 通過一個對映表得到具體的 node。Hash 演算法一般是固定模數,對映表一般是集中式配置或使用一致性協議來配置。節點擴縮一般通過調整對映表來實現。
分散式 KV 實現通常有三個基本關鍵點:
1)對映表一致性
讀寫都需要根據上圖的對映表進行查詢節點的,如果規則不一致資料就亂了。對映規則配置本身可以通過集中儲存,或者 zk、raft 這類協議保證強一致性,但是新舊配置的切換不能保證節點同時進行,仍然存在不一致性視窗。
2)多副本
通過一致性協議同步儲存多個備份節點,用於容災或多讀。
3)負載分配
slot 對映 node 就是一個分配,要保證 node 負載均衡,比如擴縮情況可能要進行 slot 資料遷移等。
我們主要查詢和儲存的是線上狀態資料,如果儲存的 node 節點宕機丟失資料,我們可以即時重建資料,因為都是線上的,所以不需要考慮多副本問題,也不需要考慮擴縮情況 slot 資料遷移問題,因為可以直接丟失重建,只需要保證關鍵的一點:對映表的一致性,而且我們有一個兜底機制——廣播,當分片資料不可靠或不可用時退化到廣播機制。
架構設計
基於前面的理論和模型分析介紹,我們在考慮用什麼架構形態來支援一體化的目標,我們從分層、擴充套件、交付等方面進行一下描述。
- 分層架構
我們的目標是期望基於 RocketMQ 實現一體化且自閉環,但不希望 Broker 被侵入更多場景邏輯,我們抽象了一個協議計算層,這個計算層可以是一個閘道器,也可以是一個 broker 外掛。Broker 專注解決 Queue 的事情以及為了滿足上面的計算需求做一些 Queue 儲存的適配或改造。協議計算層負責協議接入,並且要可插拔部署。
- 擴充套件設計
我們都知道訊息產品屬於 PaaS 產品,與上層 SaaS 業務貼得最近,為了適應業務的不同需求,我們大致梳理一下關鍵的核心鏈路,在上下行鏈路上新增一些擴充套件點,如鑑權邏輯這個最偏業務化的邏輯,不同的業務需求都不一樣,又比如 Bridge 擴充套件,其能夠把終端裝置狀態和訊息資料與一些外部生態系統(產品)打通。
- 交付設計
好的架構設計還是要考慮最終的落地問題,即怎麼交付。如今面臨的現狀是公共雲、專有云,甚至是開源等各種環境條件的落地,挑戰非常大。其中最大的挑戰是外部依賴問題,如果產品要強依賴一個外部系統或產品,那對整個交付就會有非常大的不確定性。
為了應對各種複雜的交付場景,一方面會設計好擴充套件介面,根據交付環境條件進行適配實現;另一方面,我們也會盡可能對一些模組提供預設內部實現,如上文提到的 lookup 元件,重複造輪子也是不得已而為之,這個也許就是做產品與做平臺的最大區別。
統一儲存核心
前面對整個協議模型和架構進行了詳細介紹,在 Broker 儲存層這塊還需要進一步的改造和適配。我們希望基於 RocketMQ 統一儲存核心來支撐終端和服務端的訊息收發,實現一體化的目標。
前面也提到了終端訊息場景和服務端一個很大的區別是,終端必須要有個客戶端維度的佇列才能保證可靠觸達,而服務端可以使用集中式佇列,因為訊息隨便哪臺機器消費都可以,但是終端訊息必須明確可靠推送給具體客戶端。客戶端維度的佇列意味著數量級上比傳統的 RocketMQ 服務端 Topic 佇列要大得多。
另外前面介紹的佇列模型裡面,訊息也是按照 Topic 佇列進行儲存的,MQTT 的 Topic 是一個靈活的多級 Topic,客戶端可以任意生成,而不像服務端場景 Topic 是一個很重的元資料強管理,這個也意味著 Topic 佇列的數量級很大。
海量佇列
我們都知道像 Kafka 這樣的訊息佇列每個 Topic 是獨立檔案,但是隨著 Topic 增多訊息檔案數量也增多,順序寫就退化成了隨機寫,效能下降明顯。RocketMQ 在 Kafka 的基礎上進行了改進,使用了一個 Commitlog 檔案來儲存所有的訊息內容,再使用 CQ 索引檔案來表示每個 Topic 裡面的訊息佇列,因為 CQ 索引資料較小,檔案增多對 IO 影響要小很多,所以在佇列數量上可以達到十萬級。然而這終端裝置佇列場景下,十萬級的佇列數量還是太小了,我們希望進一步提升一個數量級,達到百萬級佇列數量,我們引入了 Rocksdb 引擎來進行 CQ 索引分發。
Rocksdb 是一個廣泛使用的單機 KV 儲存引擎,具有高效能的順序寫能力。因為我們有了 commitlog 已具備了訊息順序流儲存,所以可以去掉 Rocksdb 引擎裡面的 WAL,基於 Rocksdb 來儲存 CQ 索引。在分發的時候我們使用了 Rocksdb 的 WriteBatch 原子特性,分發的時候把當前的 MaxPhyOffset 注入進去,因為 Rocksdb 能夠保證原子儲存,後續可以根據這個 MaxPhyOffset 來做 Recover 的 checkpoint。我們提供了一個 Compaction 的自定義實現,來進行 PhyOffset 的確認,以清理已刪除的髒資料。
輕量Topic
我們都知道 RocketMQ 中的 Topic 是一個重要的元資料,使用前要提前建立,並且會註冊到 namesrv 上,然後通過 Topicroute 進行服務發現。前面說了,終端場景訂閱的 Topic 比較靈活可以任意生成,如果基於現有的 RocketMQ 的 Topic 重管理邏輯顯然有些困難。我們定義了一種輕量的 Topic,專門支援終端這種場景,不需要註冊 namesrv 進行管理,由上層協議邏輯層進行自管理,broker 只負責儲存。
總結
本文首先介紹了端雲訊息場景一體化的背景,然後重點分析了終端訊息場景特點,以及終端訊息場景支撐模型,最後對架構和儲存核心進行了闡述。我們期望基於 RocketMQ 統一核心一體化支援終端和服務端不同場景的訊息接入目標,以能夠給使用者帶來一體化的價值,如降低儲存成本,避免資料在不同系統間同步帶來的一致性挑戰。
本文為阿里雲原創內容,未經允許不得轉載。