萬字長文深度剖析 RocketMQ 設計原理

ErnestEvan發表於2022-05-13

幸福的煩惱

張大胖最近是又喜又憂,喜的是業務量發展猛增,憂的是由於業務量猛增,一些原來不是問題的問題變成了大問題,比如說新會員註冊吧,原來註冊成功只要發個簡訊就行了,但隨著業務的發展,現在註冊成功也需要發 push,發優惠券,…等

這樣光註冊使用者這一步就需要呼叫很多服務,導致使用者註冊都需要花不少時間,假設每個服務呼叫需要 50 ms,那麼光以上服務就需要呼叫 200 ms,而且後續產品還有可能再加一些發新人紅包等活動,每加一個功能,除了引入額外的服務增加耗時外,還需要額外整合服務,重發程式碼,實在讓人煩不勝煩,張大胖想一勞永逸地解決這個問題,於是找了 CTO Bill 來商量一下,看能否提供一些思路

Bill 一眼就看出了問題的所在:你這個系統存在三個問題:同步,耦合,流量暴增時系統被壓垮的風險

  • 同步: 我們可以看到在註冊使用者後,需要同步呼叫其他模組後才能返回,這是耗時高的根本原因!
  • 耦合:註冊使用者與其他模組嚴重耦合,體現在每呼叫一個模組,都需要在註冊使用者程式碼處整合其他模組的程式碼並重新發布,此時在這些流量中只有註冊使用者這一步是核心流程,其他都是次要流程,核心流程應該與次要流程解藕,否則只要其中一個次要流程呼叫失敗,整個流程也就失敗了,體現在前端就是明明已經註冊成功了,但返回給使用者的卻是失敗的
  • 流量暴增風險:如果某天運營搞活動,比如註冊後送新人紅包,那麼很有可能導致使用者註冊的流量暴增,那麼由於我們的註冊使用者流程過長,很有可能導致註冊使用者的服務無法承載相應的流量壓力而導致系統雪崩

不愧是 CTO,一眼看出問題所在,「那該怎麼解決呢」張大胖問到

「大胖,你應該聽說過一句話:任何軟體問題都可以通過新增一層中間層來解決,如果不能,那就再加一層,同樣的針對以上問題我們也可以新增一箇中間層來解決,比如新增個佇列,把使用者註冊這個事件放到佇列中,讓其他模組去這個佇列裡取這個事件然後再做相應的操作」Bill 邊說邊畫出了他所說的中間層佇列

可以看到,這是個典型的生產者-消費者模型,使用者註冊後只要把註冊事件丟給這個佇列就可以立即返回,實現了將同步變了非同步,其他服務只要從這個佇列中拉取事件消費即可進行後續的操作,同時也實現了註冊使用者邏輯與其他服務的解耦,另外即使流量暴增也沒有影響,因為註冊使用者將事件發給佇列後馬上返回了,這一發訊息可能只要 5 ms,也就是說總耗時是 50ms+5ms = 55 ms,而原來的總耗時是 200 ms,系統的吞吐量和響應速度提升了近 4 倍,大大提升了系統的負責能力,這一步也就是我們常說的削峰,將暴增的流量放入佇列中以實現平穩過渡

「妙啊,加了一層佇列就達到了非同步解藕削峰的目的,也完美地解決了我的問題」張大胖興奮地說

「先別高興得太早,你想想這個佇列該用哪個,JDK 的內建佇列是否可行,或者說什麼樣的佇列才能滿足我們的條件呢」Bill 提醒道

張大胖想了一下如果直接使用 JDK 的佇列(Queue)可能會有以下問題:

  1. 由於佇列在生產者所在服務記憶體,其他消費者不得不從生產者中取,也就意味著生產者與訊息者緊藕合,這顯然不合理

  2. 訊息丟失:現在是把訊息儲存在佇列中,而佇列是在記憶體中的,那如果機器當機,佇列中的訊息不就丟失了嗎,顯然不可接受

  3. 單個佇列中的訊息只能被一個服務消費,也就是說如果某個服務從佇列中取訊息消費後,其他服務就取不了這個訊息了,有一個辦法倒是可以,為每一個服務準備一個佇列,這樣傳送訊息的時候只傳送給一個佇列,再通過這個佇列把完整訊息複製給其他佇列即可

    這種做法雖然理論上可以,但實踐起來顯然有問題,因為這就意味著每對接一個服務都要準備一份一模一樣的佇列,而且複製多份訊息效能也存在嚴重問題,還得保證複製中訊息不丟失,無疑增加了技術上的實現難度

broker

針對以上問題 Bill 和張大胖商量了一下決定自己設計一個獨立於生產者和消費者的訊息佇列(姑且把中間這個儲存訊息的元件稱為 Broker),這樣的話就解決了問題一,生產者把訊息發給 Broker,消費者只需把訊息從 Broker 里拉出來消費即可,生產者和消費者就徹底解耦了,如下

那麼這個 Broker 應該如何設計才能滿足我們的要求呢,顯然它應該滿足以下幾個條件:

  1. 訊息持久化:不能因為 Broker 當機了訊息就都丟失了,所以訊息不能只儲存在記憶體中,應該持久化到磁碟上,比如儲存在檔案裡,這樣由於訊息持久化了,它也可以被多個消費者消費,只要每個消費者儲存相應的消費進度,即可實現多個消費者的獨立消費
  2. 高可用:如果 Broker 當機了,producer 就發不了訊息了,consumer 也無法消費,這顯然是不可接受的,所以必須保證 Broker 的高可用
  3. 高效能:我們定一個指標,比如 10w TPS,那麼要實現這個目的就得滿足以下三個條件:
  4. producer 傳送訊息要快(或者說 broker 接收訊息要快)
  5. 持久化到檔案要快
  6. consumer 拉取訊息要快

接下來我們再來看 broker 的整體設計情況

針對問題一,我們可以把訊息儲存在檔案中,訊息通過順序寫入檔案的方式來保證寫入檔案的高效能

順序寫檔案的效能很高,接近於記憶體中的隨機寫,如下圖示

這樣 consumer 如果要消費的話,就可以從儲存檔案中讀取訊息了。好了,現在問題來了,我們都知道訊息檔案是存在硬碟中的,如果每次 broker 接收訊息都寫入檔案,每次 consumer 讀取訊息都從硬碟讀取檔案,由於都是磁碟 IO,是非常耗時的,有什麼辦法可以解決呢

page cache

磁碟 IO 是很慢的,為了避免 CPU 每次讀寫檔案都得和磁碟互動,一般先將檔案讀取到記憶體中,然後再由 CPU 訪問,這樣 CPU 直接在記憶體中讀寫檔案就快多了,那麼檔案怎麼從磁碟讀取入記憶體呢,首先我們需要明白檔案是以 block(塊)的形式讀取的,而 Linux 核心在記憶體中會以頁大小(一般為 4KB)為分配單位。對檔案進行讀寫操作時,核心會申請記憶體頁(記憶體頁即 page,多個 page 組成 page cache,即頁快取),然後將檔案的 block 載入到頁快取中(n block size = 1 page size,如果一個 block 大小等於一個 page,則 n = 1)如下圖示

這樣的話讀寫檔案的過程就一目瞭解

  • 對於讀檔案:CPU 讀取檔案時,首先會在 page cache 中查詢是否有相應的檔案資料,如果有直接對 page cache 進行操作,如果沒有則會觸發一個缺頁異常(fault page)將磁碟上的塊載入到 page cache 中,同時由於程式區域性性原理,會一次性載入多個 page(讀取資料所在的 page 及其相鄰的 page )到 page cache 中以保證讀取效率
  • 對於寫檔案:CPU 首先會將資料寫入 page cache 中,然後再將 page cache 刷入磁碟中

CPU 對檔案的讀寫操作就轉化成了對頁快取的讀寫操作,這樣只要讓 producer/consumer 在記憶體中讀寫訊息檔案,就避免了磁碟 IO

mmap

需要注意的是 page cache 是存在核心空間中的,還不能直接為應用程式所用,必須經由 CPU 將核心空間 page cache 拷貝到使用者空間中才能為程式所用(同樣的如果是寫檔案,也是先寫到使用者空間的緩衝區中,再拷貝到核心空間的 page cache,然後再刷盤)

畫外音:為啥要將 page cache 拷貝到使用者空間呢,這主要是因為頁快取處在核心空間,不能被使用者程式直接定址

上圖為程式讀取檔案完整流程:

  1. 首先是硬碟中的檔案資料載入處於核心空間中的 page cache(也就是我們平常所說的核心緩衝區)
  2. CPU 將其拷貝到使用者空間中的使用者緩衝區中
  3. 程式通過使用者空間的虛擬記憶體來對映操作使用者緩衝區(兩者通過 MMU 來轉換),進而達到了在記憶體中讀寫檔案的目的

將以上流程簡化如下

以上是傳統的檔案讀 IO 流程,可以看到程式的一次讀檔案經歷了一次 read 系統呼叫和一次 CPU 拷貝,那麼從核心緩衝區拷貝到使用者緩衝區的這一步能否取消掉呢,答案是肯定的

只要將虛擬記憶體對映到核心快取區即可,如下

mmap.drawio (2)
mmap.drawio (2)

可以看到使用這種方式有兩個好處

  1. 省去了 CPU 拷貝,原本需要 CPU 從核心緩衝區拷貝到使用者緩衝區,現在這一步省去了
  2. 節省了一半的空間: 因為不需要將 page cache 拷貝到使用者空間了,可以認為使用者空間和核心空間共享 page cache

我們把這種通過將檔案對映到程式的虛擬地址空間從而實現在記憶體中讀寫檔案的方式稱為 mmap(Memory Mapped Files)

上面這張圖畫得有點簡單了,再來看一下 mmap 的細節

  1. 先把磁碟上的檔案對映到程式的虛擬地址上(此時還未分配實體記憶體),即呼叫 mmap 函式返回指標 ptr,它指向虛擬記憶體中的一個地址,這樣程式無需再呼叫 read 或 write 對檔案進行讀寫,只需要通過 ptr 就能操作檔案,所以如果需要對檔案進行多次讀寫,顯然使用 mmap 更高效,因為只會進行一次系統呼叫,比起多次 read 或 write 造成的多次系統呼叫顯然開銷會更低
  2. 但需要注意的是此時的 ptr 指向的是邏輯地址,並未真正分配實體記憶體,只有通過 ptr 對檔案進行讀寫操作時才會分配實體記憶體,分配之後會更新頁表,將虛擬記憶體與實體記憶體對映起來,這樣虛擬記憶體即可通過 MMU 找到實體記憶體,分配完記憶體後即可將檔案載入到 page cache,於是程式就可在記憶體中愉快地讀寫檔案了

使用 mmap 有力地提升了檔案的讀寫效能,它也是我們常說的零拷貝的一種實現方式,既然 mmap 這麼好,可能有人就要問了,那為什麼檔案讀寫不都用 mmap 呢,天下沒有免費的午餐,mmap 也是有成本的,它有如下缺點

  1. 檔案無法完成擴充:因為執行 mmap 的時候,你所能操作的範圍就已經確定了,無法增加檔案長度

  2. 地址對映的開銷:為了建立並維持虛擬地址空間與檔案的對映關係,核心中需要有特定的資料結構來實現這一對映。核心為每個程式維護一個任務結構 task_struct,task_struct 中的 mm_struct 描述了虛擬記憶體的資訊,mm_struct 中的 mmap 欄位是一個 vm_area_struct 指標,核心中的 vm_area_struct 物件被組織成一個連結串列 + 紅黑樹的結構。如下圖示

    所以理論上,程式呼叫一次 mmap 就會產生一個 vm_area_struct 物件(不考慮核心自動合併相鄰且符合條件的記憶體區域),vm_area_struct 數量的增加會增大核心的管理工作量,增大系統開銷

  3. 缺頁中斷(page fault)的開銷: 呼叫 mmap 核心只是建立了邏輯地址(虛擬記憶體)到實體地址(實體記憶體)的對映表,實際並沒有任何資料載入到實體記憶體中,只有在主動讀寫檔案的時候發現資料所在分頁不在記憶體中時才會觸發缺頁中斷,分配實體記憶體,缺頁中斷一次讀寫只會觸發一個 page 的載入,一個 page 只有 4k,想象一次,如果一個檔案是 1G,那就得觸發 256 次缺頁中斷!中斷的開銷是很大的,那麼對於大檔案來說,就會發生很多次的缺頁中斷,這顯然是不可接受的,所以一般 mmap 得配合另一個系統呼叫 madvise,它有個檔案預熱的功能可以建議核心一次性將一大段檔案資料讀取入記憶體,這樣就避免了多次的缺頁中斷,同時為了避免檔案從記憶體中 swap 到磁碟,也可以對這塊記憶體區域進行鎖定,避免換出

  4. mmap 並不適合讀取超大型檔案,mmap 需要預先分配連續的虛擬記憶體空間用於對映檔案,如果檔案較大,對於 32 位地址空間(4 G)的系統來說,可能找不到足夠大的連續區域,而且如果某個檔案太大的話,會擠壓其他熱點小檔案的 page cache 空間,影響這些檔案的讀寫效能

綜上考慮,我們給每一個訊息檔案定為固定的 1G 大小,如果檔案滿了的話再建立一個即可,我們把這些儲存訊息的檔案集合稱為 commitlog。這樣的設計還有另一個好處:在刪除過期檔案的時候會很方便,直接把之前的檔案整個刪掉即可,最新的檔案無需改動,而如果把所有訊息都寫到一個檔案裡,顯然刪除之前的過期訊息會非常麻煩

consumeQueue 檔案

通過 mmap 的方式我們極大地提高了讀寫檔案的效率,這樣的話即可將 commitlog 採用 mmap 的方式載入到 page cache 中,然後再在 page cache 中讀寫訊息,如果是寫訊息直接寫入 page cache 當然沒問題,但如果是讀訊息(消費者根據消費進度拉取訊息)的話可就沒這麼簡單了,當然如果每個訊息的大小都一樣,那麼檔案讀取到記憶體中其實就相當於陣列了,根據訊息進度就能很快地定位到其在檔案的位置(假設訊息進度為 offset,每個訊息的大小為 size,則所要消費的位置為 offset * size),但很顯然每個訊息的大小基本不可能相同,實際情況很可能是類似下面這樣

如圖示,這裡有三個訊息,每個訊息的訊息體分別為 2kb,3kb,4kb,訊息大小都不一樣

這樣的話會有兩個問題

  1. 訊息邊界不清,無法區分相鄰的兩個訊息
  2. 即使解決了以上問題,也無法解決根據消費進度快速定位其所對應訊息在檔案的位置。假設 broker 重啟了,然後讀取消費進度(消費進度可以持久化到檔案中),此時不得不從頭讀取檔案來定位訊息在檔案的位置,這在效率上顯然是不可接受的

那能否既能利用到陣列的快速定址,又能快速定位消費進度對應訊息在檔案中的位置呢,答案是可以的,我們可以新建一個索引檔案(我們將其稱為 consumeQueue 檔案),每次寫入 commitlog 檔案後,都把此訊息在 commitlog 檔案中的 offset(我們將其稱為 commit offset,8 位元組) 及其大小(size,4 位元組)還有一個 tag hashcode(8 位元組,它的作用後文會提到)這三個欄位順序寫入 consumeQueue 檔案中

這樣每次追加寫入 consumeQueue 檔案的大小就固定為 20 位元組了,由於大小固定,根據陣列的特性,就能迅速定位消費進度在索引檔案中的位置,然後即可獲取 commitlog offset 和 size,進而快速定位其在 commitlog 中訊息

這裡有個問題,我們上文提到 commitlog 檔案固定大小 1G,寫滿了會再新建一個檔案,為了方便根據 commitlog offset 快速定位訊息是在哪個 commitlog 的哪個位置,我們可以以訊息偏移量來命名檔案,比如第一個檔案的偏移量是 0,第二個檔案的偏移量為 1G(1024*1024*1024 = 1073741824 B),第三個檔案偏移量為 2G(2147483648 B),如下圖示

同理,consumeQueue 檔案也會寫滿,寫滿後也要新建一個檔案再寫入,我們規定 consumeQueue 可以儲存 30w 條資料,也就是 30w * 20 byte = 600w Byte = 5.72 M,為了便於定位消費進度是在哪個 consumeQueue檔案中,每個檔案的名稱也是以偏移量來命名的,如下

知道了檔案的寫入與命名規則,我們再來看下訊息的寫入與消費過程

  1. 訊息寫入:首先是訊息被順序寫入 commitlog 檔案中,寫入後此訊息在檔案中的偏移(commitlog offset)和大小(size)會被順序寫入相應的 consumeQueue 檔案中
  2. 消費訊息:每個消費者都有一個消費進度,由於每個 consumeQueue 檔案是根據偏移量來命名的,首先消費進度可根據二分查詢快速定位到進度是在哪個 consumeQueue 檔案,進一步定義到是在此檔案的哪個位置,由此可以讀取到訊息的 commitlog offset 和 size,然後由於 commitlog 每個檔案的命名都是按照偏移量命名的,那麼根據 commitlog offset 顯然可以根據二分查詢快速定位到訊息是在哪個 commitlog 檔案,進而再獲取到訊息在檔案中的具體位置從而讀到訊息

同樣的為了提升效能, consumeQueue 也利用了 mmap 進行讀寫

有人可能會說這樣查詢了兩次檔案,效能可能會有些問題,實際上並不會,根據前文所述,可以使用 mmap + 檔案預熱 + 鎖定記憶體來將檔案載入並一直保留到記憶體中,這樣不管是 commitlog 還是 consumeQueue 都是在 page cache 中的,既然是在記憶體中查詢檔案那效能就不是問題了

對 ConsumeQueue 的改進--資料分片

目前為止我們討論的場景是多個消費者獨立消費訊息的場景,這種場景我們將其稱為廣播模式,這種情況下每個消費者都會全量消費訊息,但還有一種更常見的場景我們還沒考慮到,那就是叢集模式,叢集模式下每個消費者只會消費部分訊息,如下圖示:

叢集模式下每個消費者採用負載均衡的方式分別並行消費一部分訊息,主要目的是為了加速訊息消費以避免訊息積壓,那麼現在問題來了,Broker 中只有一個 consumerQueue,顯然沒法滿足叢集模式下並行消費的需求,該怎麼辦呢,我們可以借鑑分庫分表的設計理念:將資料分片儲存,具體做法是建立多個 consumeQueue,然後將資料平均分配到這些 consumerQueue 中,這樣的話每個 consumer 各自負責獨立的 consumerQueue 即可做到並行消費

如圖示: Producer 把訊息負載均衡分別傳送到 queue 0 和 queue 1 佇列中,consumer A 負責 queue 0,consumer B 負責 queue 1 中的訊息消費,這樣可以做到並行消費,極大地提升了效能

topic

現在所有訊息都持久化到 Broker 的檔案中,都能被 consumer 消費了,但實際上某些 consumer 可能只對某一型別的訊息感興趣,比如只對訂單類的訊息感興趣,而對使用者註冊類的訊息無感,那麼現在的設計顯然不合理,所以需要對訊息進行進一步的細分,我們把同一種業務型別的的訊息集合稱為 Topic。這樣消費者就可以只訂閱它感興趣的 Topic 進行消費,因此也不難理解 consumeQueue 是針對 Topic 而言的,producer 傳送訊息時都會指定訊息的 Topic,訊息到達 Broker 後會傳送到 Topic 中對應的 consumeQueue,這樣消費者就可以只消費它感興趣的訊息了

tag

把訊息按業務型別劃分成 Topic 粒度還是有點大,以訂單訊息為例,訂單有很多種狀態,比如訂單建立訂單關閉,訂單完結等,某些消費者可能只對某些訂單狀態感興趣,所以我們有時還需要進一步對某個 Topic 下的訊息進行分類,我們將這些分類稱為 tag,比如訂單訊息可以進一步劃分為訂單建立訂單關閉,訂單完結等 tag

topic 與 tag 關係
topic 與 tag 關係

producer 在發訊息的時候會指定 topic 和 tag,Broker 也會把 topic, tag 持久化到檔案中,那麼 consumer 就可以只訂閱它感興趣的 topic + tag 訊息了,現在問題來了,consumer 來拉訊息的時候,Broker 怎麼只傳給 consumer 根據 topic + tag 訂閱的訊息呢

還記得上文中提到訊息持久化到 commitlog 後寫入 consumeQueue 的資訊嗎

主要寫入三個欄位,最後一個欄位為 tag 的 hashcode,這樣的話由於 consumer 在拉訊息的時候會把 topic,tag 發給 Broker ,Broker 就可以先根據 tag 的 hashcode 來對比一下看看此訊息是否符合條件,如果不是略過繼續往後取,如果是再從 commitlog 中取訊息後傳給 consumer,有人可能會問為什麼存的是 tag hashcode 而不是 tag,主要有兩個原因

  1. hashcode 是整數,整數對比更快
  2. 為了保證此欄位為固定的位元組大小(hashcode 為 int 型,固定為 4 個位元組),這樣每次寫入 consumeQueue 的三個欄位即為固定的 20 位元組,即可利用陣列的特性快速定位訊息進度在檔案中的位置,如果用 tag 的話,由於 tag 是字串,是變長的,沒法保證固定的位元組大小

至此我們簡單總結下訊息的傳送,儲存與訊息流程

  1. 首先 producer 傳送 topic,queueId,message 到 Broker 中,Broker 將訊息通過順序寫的形式持久化到 commitlog 中,這裡的 queueId 是 Topic 中指定的 consumeQueue 0,consumeQueue 1,consumeQueue …,一般通過負載均衡的方式輪詢寫入對應的佇列,比如當前訊息寫入 consumeQueue 0,下一條寫入 consumeQueue 1,…,不斷地迴圈
  2. 持久化之後可以知道訊息在 commitlog 檔案中的偏移量和訊息體大小,如果 consumer 指定訂閱了 topic 和 tag,還會算出 tag hashCode,這樣的話就可以將這三者順序寫入 queueId 對應的 consumeQueue 中
  3. 消費者消費:每一個 consumeQueue 都能找到每個消費者的訊息進度(consumeOffset),據此可以快速定位其所在的 consumeQueue 的檔案位置,取出 commitlog offset,size,tag hashcode 這三個值,然後首先根據 tag hashcode 來過濾訊息,如果匹配上了再根據 commitlog offset,size 這兩個元素到 commitlog 中去查詢相應的訊息然後再發給消費者

注意:所有 Topic 的訊息都寫入同一個 commitlog 檔案(而不是每個 Topic 對應一個 commitlog 檔案),然後訊息寫入後會根據 topic,queueId 找到 Topic 所在的 consumeQueue 再寫入

需要注意的是我們的 Broker 是要設定為高效能的(10 w QPS)那麼上面這些步驟有兩個瓶頸點

  1. producer 傳送訊息到持久化至 commitlog 檔案的效能問題

    先來看下整體流程圖

    如圖示,Broker 收到訊息後是先將訊息寫到了核心緩衝區 的 page cache 中,最終將訊息刷盤,那麼訊息是寫到 page cache 返回 ack,還是刷盤後再返回呢,這取決於你訊息的重要性,如果是像日誌這樣的訊息,丟了其實也沒啥影響,這種情況下顯然可以選擇寫到 page cache 後就馬上返回,OS 會擇機將其刷盤,這種刷盤方式我們將其稱為非同步刷盤,這也是大多數業務場景選擇的刷盤方式,這種方式其實已經足夠安全了,哪怕 JVM 掛掉了,由於 page cache 是由 OS 管理的,OS 也能保證將其刷盤成功,除非 Broker 機器當機。當然對於像轉賬等安全性極高的金融場景,我們可能還是要將訊息從 page cache 刷盤後再返回 ack,這種方式我們稱為同步刷盤,顯然這種方式會讓效能大大降低,使用要慎重

  2. consumer 拉取訊息的效能問題

    很顯然這一點不是什麼問題,上文提到,不管是 commitlog 還是 consumeQueue 檔案,都快取在 page cache 中,那麼直接從 page cache 中讀訊息即可,由於是基於記憶體的操作,不存在什麼瓶頸,當然這是基於消費進度與生產進度差不多的前提,如果某個消費者指定要從某個進度開始消費,且此進度對應的 commitlog 檔案不在 page cache 中,那就會觸發磁碟 IO

Broker 的高可用

上文我們都是基於一個 Broker 來討論的,這顯然有問題,Broker 如果掛了,依賴它的 producer,consumer 不就也嗝屁了嗎,所以 broker 的高可用是必須的,一般採用主從模式來實現 broker 的高可用

如圖示:Producer 將訊息發給 主 Broker ,然後 consumer 從主 Broker 里拉訊息,而 從 Broker 則會從主 Broker 同步訊息,這樣的話一旦主 Broker 當機了,consumer 可以從 Broker 里拉訊息,同時在 RocketMQ 4.5 以後,引入一種 dledger 模式,這種模式要求一主多從(至少 3 個節點),這樣如果主 Broker 當機後,另外多個從 Broker 會根據 Raft 協議選舉出一個主 Broker,Producer 就可以向這個新選舉出來的主節點傳送訊息了

如果 QPS 很高只有一個主 Broker 的話也存在效能上的瓶頸,所以生產上一般採用多主的形式,如下圖示

這樣的話 Producer 可以負載均衡地將訊息傳送到多個 Broker 上,提高了系統的負載能力,不難發現這意味著 Topic 是分散式儲存在多個 Broker 上的,而 Topic 在每個 Broker 上的儲存都是以多個 consumeQueue 的形式存在的,這極大地提升了 Topic 的水平擴充套件與系統的併發執行能力

nameserver

目前為止我們的設計貌似不錯,通過一系列設計讓 Broker 滿足了高效能,高擴充套件的要求,但我們似乎忽略了一個問題,Producer,Consumer 該怎麼和 Broker 通訊呢,一種做法是在 Producer,Consumer 寫死要通訊的 Broker ip 地址,雖然可行,但這麼做的話顯然會有很大的問題,配置死板,擴充套件性差,考慮以下場景

  1. 如果擴容(新增 Broker),producer 和 consumer 是不是也要跟著新增 Broker ip 地址
  2. 每次新增 Topic 都要指定在哪些 Broker 儲存,我們知道 producer 在發訊息,consumer 在訂閱訊息的時候都要指定對應的 Topic ,那就意味著每次新增 Topic 後都需要在 producer,consumer 做相應變更(記錄 topic -> broker 地址)
  3. 如果 broker 當機了,producer 和 consumer 需要將其從配置中移除,這就意味著 producer,consumer 需要與相關的 brokers 通過心跳來通訊以便知道其存活與否,這樣無疑增加了設計的複雜度

參考下 dubbo 這類 RPC 框架,你會發現基本上都會新增一個類似 Zookeeper 這樣的註冊中心的中間層(一般稱其為 nameserver),如下

主要原理如下:

為了保證高可用,一般 nameserver 以叢集的形式存在(至少兩個),Broker 啟動後不管主從都會向每一個 nameserver 註冊,註冊的資訊有哪些呢,想想看 producer 要發訊息給 broker 需要知道哪些資訊呢,首先發訊息要指定 Topic,然後要指定 Topic 所在的 broker,再然後是知道 Topic 在 Broker 中的佇列數量(可以這樣負載均衡地將訊息傳送到這些 queue 中),所以 broker 向 nameserver 註冊的資訊中應該包含以下資訊

page_cache.drawio (1)
page_cache.drawio (1)

這樣的話 producer 和 consumer 就可以通過與 nameserver 建立長連線來定時(比如每隔 30 s)拉取這些路由資訊從而更新到本地,傳送/消費訊息的時候就可以依據這些路由資訊進行傳送/消費

那麼加了一個 nameserver 和原來的方案相比有什麼好處呢,可以很明顯地看出:producer/consumer 與具體的 broker 解藕了,極大提升了整體架構的可擴充套件性:

  1. producer/consumer 的所有路由資訊都能通過 nameserver 得到,比如現在要在 brokers 上新建一個 Topic,那麼 brokers 會把這些資訊同步到 nameserver,而 producer/consumer 會定時去 nameserver 拉取這些路由資訊更新到本地,做到了路由資訊配置的自動化
  2. 同樣的如果某些 broker 當機了,由於 broker 會定時上報心跳到 nameserver 以告知其存活狀態,一旦 nameserver 監測到 broker 失效了,producer/consumer 也能從中得到其失效資訊,從而在本地路由中將其剔除

可以看到通過加了一層 nameserver,producer/consumer 路由資訊做到了配置自動化,再也不用手動去操作了,整體架構甚為合理

總結

以上即我們所要闡述的 RocketMQ 的設計理念,基本上涵蓋了重要概念的介紹,我們再來簡單回顧一下:

首先根據業務場景我們提出了 RocketMQ 設計的三大目標:訊息持久化,高效能,高可用,毫無疑問 broker 的設計是實現這三大目標的關鍵,為了訊息持久化,我們設計了 commitlog 檔案,通過順序寫的方式保證了檔案寫入的高效能,但如果每次 producer 寫入訊息或者 consumer 讀取訊息都從檔案來讀寫,由於涉及到磁碟 IO 顯然效能會有很大的問題,於是我們瞭解到作業系統讀寫檔案會先將檔案載入到記憶體中的 page cache 中。對於傳統的檔案 IO,由於 page cache 存在核心空間中,還需要將其拷貝到使用者空間中才能為程式所用(同樣的,寫入訊息也要寫將訊息寫入使用者空間的 buffer,再拷貝到 核心空間中的 page cache),於是我們使用了 mmap 來避免了這次拷貝,這樣的話 producer 傳送訊息只要先把訊息寫入 page cache 再非同步刷盤,而 consumer 只要保證訊息進度能跟得上 producer 產生訊息的進度,就可以直接從 page cache 中讀取訊息進行消費,於是 producer 與 consumer 都可以直接從 page cache 中讀寫訊息,極大地提升了訊息的讀寫效能,那怎麼保證 consumer 消費足夠快以跟上 producer 產生訊息的速度的,顯然,讓訊息分散式,分片儲存是一種通用方案,這樣的話通過增加 consumer 即可達到併發消費訊息的目的

最後,為了避免每次建立 Topic 或者 broker 當機都得修改 producer/consumer 上的配置,我們引入了 nameserver, 實現了服務的自動發現功能。

仔細與其它 RPC 框架橫向對比後,你會發現這些 RPC 框架用的思想其實都很類似,比如資料使用分片儲存以提升資料儲存的水平擴充套件與併發執行能力,使用 zookeeper,nameserver 等註冊中心來達到服務註冊與自動發現的目的,所以掌握了這些思想, 我們再去觀察學習或設計 RPC 時就能達到事半功倍的效果

更多精品文章,歡迎大家掃碼關注「碼海」

相關文章