你管這破玩意兒叫 MQ?

幸福的煩惱

張大胖最近是又喜又憂,喜的是業務量發展猛增,憂的是由於業務量猛增,一些原來不是問題的問題變成了大問題,比如說新會員註冊吧,原來註冊成功只要發個短信就行了,但隨着業務的發展,現在註冊成功也需要發 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 對文件的讀寫操作就轉化成了對頁緩存的讀寫操作,這樣只要讓 producer/consumer 在內存中讀寫消息文件,就避免了磁盤 IO

mmap

需要注意的是 page cache 是存在內核空間中的,還不能直接爲應用程序所用,必須經由 CPU 將內核空間 page cache 拷貝到用戶空間中才能爲進程所用(同樣的如果是寫文件,也是先寫到用戶空間的緩衝區中,再拷貝到內核空間的 page cache,然後再刷盤)

畫外音:爲啥要將 page cache 拷貝到用戶空間呢,這主要是因爲頁緩存處在內核空間,不能被用戶進程直接尋址

上圖爲程序讀取文件完整流程:

  1. 首先是硬盤中的文件數據載入處於內核空間中的 page cache(也就是我們平常所說的內核緩衝區)

  2. CPU 將其拷貝到用戶空間中的用戶緩衝區中

  3. 程序通過用戶空間的虛擬內存來映射操作用戶緩衝區(兩者通過 MMU 來轉換),進而達到了在內存中讀寫文件的目的

將以上流程簡化如下

以上是傳統的文件讀 IO 流程,可以看到程序的一次讀文件經歷了一次 read 系統調用和一次 CPU 拷貝,那麼從內核緩衝區拷貝到用戶緩衝區的這一步能否取消掉呢,答案是肯定的

只要將虛擬內存映射到內核緩存區即可,如下

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

  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(102410241024 = 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 關係

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 註冊的信息中應該包含以下信息

這樣的話 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 時就能達到事半功倍的效果

你好,我是坤哥,前獨角獸技術專家,現創業者,持續分享個人的成長收穫,歡迎大家加我微信,圍觀朋友圈,關注我一定能提升你的視野,讓我們一起進階吧!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/hd8z2F1hLbrykxZL6gQb1g