3-主題和佇列

LZC發表於2021-07-07

最初的訊息佇列,就是一個嚴格意義上的佇列。在計算機領域,“佇列(Queue)”是一種資料結構,有完整而嚴格的定義。在維基百科中,佇列的定義是這樣的:佇列是先進先出(FIFO, First-In-First-Out)的線性表(Linear List)。在具體應用中通常用連結串列或者陣列來實現。佇列只允許在後端(稱為 rear)進行插入操作,在前端(稱為 front)進行刪除操作。

這個定義裡面包含幾個關鍵點,第一個是先進先出,這裡面隱含著的一個要求是,在訊息入隊出隊過程中,需要保證這些訊息嚴格有序,按照什麼順序寫進佇列,必須按照同樣的順序從佇列中讀出來。不過,佇列是沒有“讀”這個操作的,“讀”就是出隊,也就是從佇列中“刪除”這條訊息。

早期的訊息佇列,就是按照“佇列”的資料結構來設計的。我們一起看下這個圖,生產者(Producer)發訊息就是入隊操作,消費者(Consumer)收訊息就是出隊也就是刪除操作,服務端存放訊息的容器自然就稱為“佇列”。

這就是最初的一種訊息模型:佇列模型。

如果有多個生產者往同一個佇列裡面傳送訊息,這個佇列中可以消費到的訊息,就是這些生產者生產的所有訊息的合集。訊息的順序就是這些生產者傳送訊息的自然順序。如果有多個消費者接收同一個佇列的訊息,這些消費者之間實際上是競爭的關係,每個消費者只能收到佇列中的一部分訊息,也就是說任何一條訊息只能被其中的一個消費者收到。

如果需要將一份訊息資料分發給多個消費者,要求每個消費者都能收到全量的訊息,例如,對於一份訂單資料,風控系統、分析系統、支付系統等都需要接收訊息。這個時候,單個佇列就滿足不了需求,一個可行的解決方式是,為每個消費者建立一個單獨的佇列,讓生產者傳送多份。

顯然這是個比較蠢的做法,同樣的一份訊息資料被複制到多個佇列中會浪費資源,更重要的是,生產者必須知道有多少個消費者。為每個消費者單獨傳送一份訊息,這實際上違背了訊息佇列“解耦”這個設計初衷。

為了解決這個問題,演化出了另外一種訊息模型:“釋出 - 訂閱模型(Publish-Subscribe Pattern)”。

在釋出 - 訂閱模型中,訊息的傳送方稱為釋出者(Publisher),訊息的接收方稱為訂閱者(Subscriber),服務端存放訊息的容器稱為主題(Topic)。釋出者將訊息傳送到主題中,訂閱者在接收訊息之前需要先“訂閱主題”。“訂閱”在這裡既是一個動作,同時還可以認為是主題在消費時的一個邏輯副本,每份訂閱中,訂閱者都可以接收到主題的所有訊息。

在訊息領域的歷史上很長的一段時間,佇列模式和釋出 - 訂閱模式是並存的,有些訊息佇列同時支援這兩種訊息模型,比如 ActiveMQ。我們仔細對比一下這兩種模型,生產者就是釋出者,消費者就是訂閱者,佇列就是主題,並沒有本質的區別。它們最大的區別其實就是,一份訊息資料能不能被消費多次的問題。

實際上,在這種釋出 - 訂閱模型中,如果只有一個訂閱者,那它和佇列模型就基本是一樣的了。也就是說,釋出 - 訂閱模型在功能層面上是可以相容佇列模型的。現代的訊息佇列產品使用的訊息模型大多是這種釋出 - 訂閱模型,當然也有例外。

這個例外就是 RabbitMQ,它是少數依然堅持使用佇列模型的產品之一。那它是怎麼解決多個消費者的問題呢?在 RabbitMQ 中,Exchange 位於生產者和佇列之間,生產者並不關心將訊息傳送給哪個佇列,而是將訊息傳送給 Exchange,由 Exchange 上配置的策略來決定將訊息投遞到哪些佇列中。

同一份訊息如果需要被多個消費者來消費,需要配置 Exchange 將訊息傳送到多個佇列,每個佇列中都存放一份完整的訊息資料,可以為每一個消費者提供消費服務。這也可以變相地實現新發布 - 訂閱模型中,“一份訊息資料可以被多個訂閱者來多次消費”這樣的功能。

RocketMQ 使用的訊息模型是標準的釋出 - 訂閱模型,在 RocketMQ 的術語表中,生產者、消費者和主題與我在上面講的釋出 - 訂閱模型中的概念是完全一樣的。

但是,在 RocketMQ 也有佇列(Queue)這個概念,並且佇列在 RocketMQ 中是一個非常重要的概念,那佇列在 RocketMQ 中的作用是什麼呢?這就要從訊息佇列的消費機制說起。

幾乎所有的訊息佇列產品都使用一種非常樸素的“請求 - 確認”機制,確保訊息不會在傳遞過程中由於網路或伺服器故障丟失。具體的做法也非常簡單。在生產端,生產者先將訊息傳送給服務端,也就是 Broker,服務端在收到訊息並將訊息寫入主題或者佇列中後,會給生產者傳送確認的響應。

如果生產者沒有收到服務端的確認或者收到失敗的響應,則會重新傳送訊息;在消費端,消費者在收到訊息並完成自己的消費業務邏輯(比如,將資料儲存到資料庫中)後,也會給服務端傳送消費成功的確認,服務端只有收到消費確認後,才認為一條訊息被成功消費,否則它會給消費者重新傳送這條訊息,直到收到對應的消費成功確認。

這個確認機制很好地保證了訊息傳遞過程中的可靠性,但是,引入這個機制在消費端帶來了一個不小的問題。什麼問題呢?為了確保訊息的有序性,在某一條訊息被成功消費之前,下一條訊息是不能被消費的,否則就會出現訊息空洞,違背了有序性這個原則。

也就是說,每個主題在任意時刻,至多隻能有一個消費者例項在進行消費,那就沒法通過水平擴充套件消費者的數量來提升消費端總體的消費效能。為了解決這個問題,RocketMQ 在主題下面增加了佇列的概念。

每個主題包含多個佇列,通過多個佇列來實現多例項並行生產和消費。需要注意的是,RocketMQ 只在佇列上保證訊息的有序性,主題層面是無法保證訊息的嚴格順序的。

RocketMQ 中,訂閱者的概念是通過消費組(Consumer Group)來體現的。每個消費組都消費主題中一份完整的訊息,不同消費組之間消費進度彼此不受影響,也就是說,一條訊息被 Consumer Group1 消費過,也會再給 Consumer Group2 消費。

消費組中包含多個消費者,同一個組內的消費者是競爭消費的關係,每個消費者負責消費組內的一部分訊息。如果一條訊息被消費者 Consumer1 消費了,那同組的其他消費者就不會再收到這條訊息。

在 Topic 的消費過程中,由於訊息需要被不同的組進行多次消費,所以消費完的訊息並不會立即被刪除,這就需要 RocketMQ 為每個消費組在每個佇列上維護一個消費位置(Consumer Offset),這個位置之前的訊息都被消費過,之後的訊息都沒有被消費過,每成功消費一條訊息,消費位置就加一。這個消費位置是非常重要的概念,我們在使用訊息佇列的時候,丟訊息的原因大多是由於消費位置處理不當導致的。

在任何一個時刻,某個佇列,對於同一個消費組,只能有一個消費者佔有,這樣就保證了佇列上訊息的有序性。

我們再來看看另一種常見的訊息佇列 Kafka,Kafka 的訊息模型和 RocketMQ 是完全一樣的,我剛剛講的所有 RocketMQ 中對應的概念,和生產消費過程中的確認機制,都完全適用於 Kafka。唯一的區別是,在 Kafka 中,佇列這個概念的名稱不一樣,Kafka 中對應的名稱是“分割槽(Partition)”,含義和功能是沒有任何區別的。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章