← 回文章列表

2022年6月14日

RabbitMQ 的 Exchange、Queue、Binding:訊息到底是怎麼被送到佇列的?

RabbitMQMessage Queue事件驅動系統設計

先記住一句話:Producer 不直接送到 Queue

第一次接觸 RabbitMQ 時,很直覺會以為它的運作方式是「我把訊息放進某個佇列,另一邊再從佇列拿出來」。但 RabbitMQ(更精確地說,是它實作的 AMQP 0-9-1 協定)的模型多了一層:

  • Producer 把訊息發布(Publish)給 Exchange,並附上一個 Routing Key
  • Exchange 本身不儲存訊息,它只負責「分派」:依照自己的類型和 Binding 規則,決定這則訊息要複製到哪些 Queue
  • Binding 是 Exchange 和 Queue 之間的一條規則,例如「Routing Key 是 a 的訊息,送到 Queue A」
  • Queue 才是真正存放訊息的地方,Consumer 從 Queue 取出訊息處理

RabbitMQ 核心模型示意圖:Producer 帶著 Routing Key 發布訊息到 Exchange,Exchange 依 Binding 規則把訊息分派到符合的 Queue,沒有符合 Binding 的 Queue 不會收到;Consumer 再從各自的 Queue 消費

這也解釋了最常見的「訊息不見了」:如果一則訊息送到 Exchange 時,沒有任何一條 Binding 符合,Exchange 就會直接把它丟掉(除非發布時設定了 mandatory 或 Exchange 設定了 Alternate Exchange)。訊息沒有進任何 Queue,自然也沒有人收到。

你可能看過「直接送到 Queue」的範例程式碼,例如 BasicPublish(exchange: "", routingKey: "my-queue")。這其實是送到一個名稱為空字串的預設 Exchange(Default Exchange),它是一個 Direct Exchange,而且每個 Queue 建立時都會自動以「自己的名字」綁定到它上面。所以看起來像是直接送進 Queue,本質上還是經過了 Exchange。

三者的關係

元件負責什麼會儲存訊息嗎誰決定它的存在
Exchange依類型與 Binding 分派訊息不會通常由 Producer 端宣告
Binding描述「什麼樣的訊息要進哪個 Queue」不會通常由 Consumer 端宣告
Queue存放訊息,等待 Consumer 取用會通常由 Consumer 端宣告

幾個重要的關係:

  • 一個 Exchange 可以綁定多個 Queue,一則訊息符合幾條 Binding,就會複製幾份到對應的 Queue
  • 一個 Queue 也可以綁定到多個 Exchange,或是在同一個 Exchange 上有多條不同 Routing Key 的 Binding
  • Producer 只需要知道 Exchange 的名稱,完全不需要知道有哪些 Queue、誰在消費——這正是用 RabbitMQ 做解耦的關鍵

四種 Exchange 類型

RabbitMQ Exchange 類型示意圖:Direct 只送到 Routing Key 完全相同的佇列;Fanout 忽略 Routing Key,送一份到每個綁定的佇列;Topic 以萬用字元比對 Routing Key,house.created.franchise 會送到 house.*.franchise、house.created.# 與 # 三個佇列,但不會送到 house.price_changed.#

Direct:Routing Key 完全相同

Binding 時指定一個 Binding Key,訊息的 Routing Key 和它完全相同才會送進去。適合「這種工作交給那個佇列」的一對一分派,例如報表匯出、寄信這種背景任務。

Fanout:全部送一份

完全忽略 Routing Key,所有綁定在這個 Exchange 上的 Queue 都會收到一份。適合「廣播」:有一件事發生了,所有人都要知道。

Topic:萬用字元比對

Routing Key 是以 . 分隔的多個字,Binding Key 可以使用兩種萬用字元:

  • *:剛好比對一個字
  • #:比對零到多個字

例如 Routing Key 為 house.created.franchise 時:

Binding Key是否符合原因
house.*.franchise✓* 比對到 created
house.created.#✓# 比對到 franchise
house.#✓# 比對到 created.franchise
#✓什麼都符合,等於 Fanout
house.*✗* 只能比對一個字,後面還多一個 franchise
house.price_changed.#✗第二個字不同

Topic 是最有彈性的類型:Binding Key 全部用完整字串時,它就是 Direct;用 # 時,它就是 Fanout。如果一開始不確定未來的訂閱需求,用 Topic 通常是最不會後悔的選擇。

Headers:依訊息標頭比對

不看 Routing Key,而是依訊息的 Headers 比對(可以設定全部符合 x-match: all 或任一符合 x-match: any)。實務上比較少用,大部分需求 Topic 都能滿足,而且 Topic 的規則一眼就看得懂。

同一個 Queue 還是各自一個 Queue?

這是設計時最關鍵的決定,因為它決定了訊息是「分工」還是「每個人都收到」:

同一個 Queue 多個 Consumer 與每個服務各自一個 Queue 的比較:左邊三個同步服務 Pod 共用 sync-queue,訊息 1、2、3 各自只被一個 Pod 拿走;右邊 house.events Exchange 綁定 sync-queue 與 notify-queue,同一則事件同步服務與通知中心各收到一份

  • 同一個 Queue、多個 Consumer(Competing Consumers):一則訊息只會被其中一個 Consumer 拿走。同一個服務開了三個 Pod,三個 Pod 應該共用同一個 Queue,這樣訊息會分散到三個 Pod 處理,處理量跟著 Pod 數量水平擴展
  • 每個服務各自一個 Queue:不同的服務要對同一個事件做不同的事,就應該各自宣告自己的 Queue,綁到同一個 Exchange 上。Exchange 會複製一份到每個 Queue,每個服務各自處理、互不影響

一個常見的錯誤是讓「同步服務」和「通知中心」共用同一個 Queue,結果一則事件只被其中一邊收到,另一邊莫名其妙漏資料。判斷原則很簡單:同一個服務的多個實例共用 Queue;不同的服務各自一個 Queue。

使用情境:房仲物件事件的路由設計

以房仲網站為例:主力平台上的物件會新增、改價、下架,而好幾個系統都在等這些事件:

  • 加盟平台:只關心「加盟品牌」的物件,要同步到加盟網站
  • 通知中心:有新物件刊登時,通知訂閱了這個條件的使用者
  • 稽核紀錄:所有物件異動都要留下紀錄

如果用 Fanout,每個系統都會收到全部的事件,再自己在程式裡過濾——加盟平台會收到大量和它無關的直營物件事件。改用 Topic Exchange,把「事件類型」和「品牌類別」放進 Routing Key,讓 RabbitMQ 幫忙過濾:

text
Exchange:house.events(topic)
Routing Key 格式:house.{事件}.{品牌類別}

house.created.franchise        加盟物件新增
house.price_changed.main       直營物件改價
house.removed.franchise        加盟物件下架

各系統依自己的需求綁定:

QueueBinding Key收到的事件
franchise-synchouse.*.franchise所有加盟物件的異動
notify-new-listinghouse.created.#所有新刊登的物件
audit-log#全部

宣告的程式碼(以 RabbitMQ.Client 為例):

C#
// Producer 端:只需要宣告 Exchange
channel.ExchangeDeclare("house.events", ExchangeType.Topic, durable: true);

var body = JsonSerializer.SerializeToUtf8Bytes(houseCreatedEvent);
var props = channel.CreateBasicProperties();
props.Persistent = true;

channel.BasicPublish("house.events", "house.created.franchise", props, body);
C#
// 加盟平台(Consumer 端):宣告自己的 Queue 與 Binding
channel.ExchangeDeclare("house.events", ExchangeType.Topic, durable: true);
channel.QueueDeclare("franchise-sync", durable: true, exclusive: false, autoDelete: false);
channel.QueueBind("franchise-sync", "house.events", routingKey: "house.*.franchise");

這樣設計的好處:

  • 主力平台完全不知道加盟平台的存在,它只負責把事件發到 house.events
  • 之後多一個系統要訂閱物件事件,只要新增一個 Queue 和 Binding,主力平台一行程式都不用改
  • 過濾在 Broker 端完成,加盟平台不會收到、也不用處理直營物件的事件

Fanout 的情境:通知所有實例清快取

Fanout 最適合「每個實例都要做同一件事」的場景。例如物件資料更新後,所有 Web 實例的本機快取都要清掉:每個實例啟動時宣告一個自己專屬、斷線就自動刪除的 Queue(exclusive: true、autoDelete: true),綁到 cache.invalidate 這個 Fanout Exchange 上。發一則訊息,所有實例都會收到。

注意這和前面「同一個服務共用 Queue」剛好相反:清快取是每個實例都要做,所以每個實例要有自己的 Queue。

一句話總結

Producer 把訊息交給 Exchange,Exchange 依 Binding 決定複製到哪些 Queue,Consumer 從 Queue 拿訊息;想讓同一個服務分工就共用 Queue,想讓不同服務各自收到就各自建 Queue,而 Topic Exchange 搭配設計良好的 Routing Key,可以讓 Broker 幫你完成大部分的過濾工作。