2022年6月14日
RabbitMQ 的 Exchange、Queue、Binding:訊息到底是怎麼被送到佇列的?
先記住一句話: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 取出訊息處理
這也解釋了最常見的「訊息不見了」:如果一則訊息送到 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 類型
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(Competing Consumers):一則訊息只會被其中一個 Consumer 拿走。同一個服務開了三個 Pod,三個 Pod 應該共用同一個 Queue,這樣訊息會分散到三個 Pod 處理,處理量跟著 Pod 數量水平擴展
- 每個服務各自一個 Queue:不同的服務要對同一個事件做不同的事,就應該各自宣告自己的 Queue,綁到同一個 Exchange 上。Exchange 會複製一份到每個 Queue,每個服務各自處理、互不影響
一個常見的錯誤是讓「同步服務」和「通知中心」共用同一個 Queue,結果一則事件只被其中一邊收到,另一邊莫名其妙漏資料。判斷原則很簡單:同一個服務的多個實例共用 Queue;不同的服務各自一個 Queue。
使用情境:房仲物件事件的路由設計
以房仲網站為例:主力平台上的物件會新增、改價、下架,而好幾個系統都在等這些事件:
- 加盟平台:只關心「加盟品牌」的物件,要同步到加盟網站
- 通知中心:有新物件刊登時,通知訂閱了這個條件的使用者
- 稽核紀錄:所有物件異動都要留下紀錄
如果用 Fanout,每個系統都會收到全部的事件,再自己在程式裡過濾——加盟平台會收到大量和它無關的直營物件事件。改用 Topic Exchange,把「事件類型」和「品牌類別」放進 Routing Key,讓 RabbitMQ 幫忙過濾:
Exchange:house.events(topic)
Routing Key 格式:house.{事件}.{品牌類別}
house.created.franchise 加盟物件新增
house.price_changed.main 直營物件改價
house.removed.franchise 加盟物件下架
各系統依自己的需求綁定:
| Queue | Binding Key | 收到的事件 |
|---|---|---|
franchise-sync | house.*.franchise | 所有加盟物件的異動 |
notify-new-listing | house.created.# | 所有新刊登的物件 |
audit-log | # | 全部 |
宣告的程式碼(以 RabbitMQ.Client 為例):
// 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);
// 加盟平台(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 幫你完成大部分的過濾工作。