RabbitMQ中釋出者透過確認機制確保訊息釋出

banq發表於2024-07-09

在本教程中,我們將學習如何使用釋出者確認來確保將訊息釋出到RabbitMQ代理。然後,我們將瞭解如何使用消費者確認來告知代理我們已成功使用訊息。

場景
在簡單的應用程式中,我們在使用 RabbitMQ 時經常會忽略顯式確認機制,而是依賴於向佇列釋出基本訊息並在使用時自動確認訊息。然而,儘管 RabbitMQ 擁有強大的基礎架構,但仍可能出現錯誤,因此需要一種方法來仔細檢查訊息是否已傳送到代理並確認訊息是否已成功使用。這就是釋出者確認和消費者確認發揮作用的地方,它們提供了安全網。

 等待發布者確認
即使我們的應用程式沒有錯誤,釋出的訊息也可能丟失。例如,由於不明原因的網路錯誤,訊息可能會在傳輸過程中丟失。為了避免這種情況,AMQP 提供了事務語義來保證訊息不會丟失。然而,這需要付出巨大的代價。由於事務繁重,處理訊息的時間可能會顯著增加,尤其是在大量事務的情況下。

相反,我們將採用確認模式,儘管這會帶來一些開銷,但比事務更快。此模式指示客戶端和代理啟動訊息計數。隨後,客戶端使用代理發回的帶有相應數字的交付標籤來驗證此計數。此過程可確保訊息的安全儲存,以便隨後分發給消費者。

要進入確認模式,我們需要在我們的頻道上呼叫一次:

channel.confirmSelect();

確認可能需要一些時間,尤其是對於持久佇列,因為存在 IO 延遲。因此,RabbitMQ 非同步等待確認,但提供了在我們的應用程式中使用的同步方法:

  • Channel.waitForConfirms() —阻止執行,直到自上次呼叫以來的所有訊息都被代理 ACK(確認)或 NACK(拒絕)。
  • Channel.waitForConfirms(timeout) —與上面的相同,但我們可以將等待時間限制為毫秒。否則,我們將收到TimeoutException。
  • Channel.waitForConfirmsOrDie() —如果自上次呼叫以來有任何訊息被 NACK,則此方法會丟擲異常。如果我們不能容忍任何訊息丟失,則此方法很有用。
  • Channel.waitForConfirmsOrDie(timeout) —與上面相同,但有超時。

釋出者設定
讓我們從釋出訊息的常規類開始。我們只會接收要連線的頻道和佇列:

class UuidPublisher {
    private Channel channel;
    private String queue;
    public UuidPublisher(Channel channel, String queue) {
        this.channel = channel;
        this.queue = queue;
    }
}

然後,我們新增一個釋出字串訊息的方法:

public void send(String message) throws IOException {
    channel.basicPublish(<font>"", queue, null, message.getBytes());
}

當我們以這種方式傳送訊息時,我們可能會在傳輸過程中丟失它們,因此讓我們包含一些程式碼來確保代理安全地接收我們的訊息。

在頻道上啟動確認模式
我們首先修改建構函式,最後在通道上呼叫confirmSelect() 。這是必要的,這樣我們才能在通道上使用“wait”方法:

public UuidPublisher(Channel channel, String queue) throws IOException {
    <font>// ...<i>
    this.channel.confirmSelect();
}

如果我們嘗試在不進入確認模式的情況下等待確認,我們將得到一個IllegalStateException。然後,我們將選擇一種同步wait()方法,並在使用send()方法釋出訊息後呼叫它。讓我們等待超時,這樣我們就可以確保我們永遠不會永遠等待:

public boolean send(String message) throws Exception {
    channel.basicPublish(<font>"", queue, null, message.getBytes());
    return channel.waitForConfirms(1000);
}

返回true 表示代理已成功接收訊息。如果我們要傳送幾條訊息,這種方法很有效。

批次確認已釋出的訊息
由於確認訊息需要時間,因此我們不應該在每次釋出後等待確認。相反,我們應該在等待確認之前傳送一堆訊息。讓我們修改我們的方法來接收訊息列表,並且僅在傳送完所有訊息後等待:

public void sendAllOrDie(List<String> messages) throws Exception {
    for (String message : messages) {
        channel.basicPublish(<font>"", queue, null, message.getBytes());
    }
    channel.waitForConfirmsOrDie(1000);
}

這次,我們使用waitForConfirmsOrDie(),因為如果waitForConfirms()返回false,則意味著代理拒絕了未知數量的訊息。雖然這確保瞭如果任何訊息被拒絕,我們都會收到異常,但我們無法判斷哪些訊息失敗了。

利用確認模式保證批次釋出
使用確認模式時,還可以在我們的頻道上註冊一個ConfirmListener 。此偵聽器需要兩個回撥處理程式:一個用於成功交付,另一個用於代理失敗。這樣,我們可以實現一種機制來確保沒有訊息遺漏。我們將從將此偵聽器新增到我們的頻道的方法開始:

private void createConfirmListener() {
    this.channel.addConfirmListener(
      (tag, multiple) -> {
        <font>// ...<i>
      }, 
      (tag, multiple) -> {
       
// ...<i>
      }
    );
}

在回撥中,tag引數指的是訊息的順序投遞標籤,而multiple表示這是否確認了多條訊息。在這種情況下, tag引數將指向最新確認的標籤。相反,如果最後一個回撥是 NACK,則所有投遞標籤大於最新 NACK 回撥標籤的訊息也將被確認。

為了協調這些回撥,我們將未確認的訊息儲存在ConcurrentSkipListMap中。我們將使用其標籤號作為鍵將待處理的訊息放在那裡。這樣,我們可以呼叫headMap()並獲取到我們現在收到的標籤之前的所有先前訊息的檢視:

private ConcurrentNavigableMap<Long, PendingMessage> pendingDelivery = new ConcurrentSkipListMap<>();

已確認訊息的回撥將從我們的地圖中刪除所有標記的訊息:

(tag, multiple) -> {
    ConcurrentNavigableMap<Long, PendingMessage> confirmed = pendingDelivery.headMap(tag, true);
    confirmed.clear();
}

如果multiple為false , headMap ()將包含單個專案,否則將包含多個專案。因此,我們不需要檢查是否收到了多條訊息的確認。

實現被拒絕訊息的重試機制
我們將為被拒絕訊息的回撥實現重試機制。此外,我們將包含最大重試次數,以避免永遠重試的情況。讓我們從一個儲存訊息當前嘗試次數的類開始,以及一個增加此計數器的簡單方法:

public class PendingMessage {
    private int tries;
    private String body;
    public PendingMessage(String body) {
        this.body = body;
    }
    public int incrementTries() {
        return ++this.tries;
    }
    <font>// standard getters<i>
}

現在,讓我們使用它來實現回撥。我們首先獲取被拒絕的訊息的檢視,然後刪除已超過最大嘗試次數的任何專案:

(tag, multiple) -> {
    ConcurrentNavigableMap<Long, PendingMessage> failed = pendingDelivery.headMap(tag, true);
    failed.values().removeIf(pending -> {
        return pending.incrementTries() >= MAX_TRIES;
    });
    <font>// ...<i>
}

然後,如果我們仍有待處理的訊息,我們會再次傳送它們。這次,如果我們的應用程式發生意外錯誤,我們還會刪除該訊息:

if (!pendingDelivery.isEmpty()) {
    pendingDelivery.values().removeIf(message -> {
        try {
            channel.basicPublish(<font>"", queue, null, message.getBody().getBytes());
            return false;
        } catch (IOException e) {
            return true;
        }
    });
}

綜合起來
最後,我們可以建立一個新方法,該方法可以批次傳送訊息,但可以檢測被拒絕的訊息並嘗試再次傳送。我們必須在通道上呼叫getNextPublishSeqNo()來找出我們的訊息標籤:

public void sendOrRetry(List<String> messages) throws IOException {
    createConfirmListener();
    for (String message : messages) {
        long tag = channel.getNextPublishSeqNo();
        pendingDelivery.put(tag, new PendingMessage(message));
        channel.basicPublish(<font>"", queue, null, message.getBytes());
    }
}

我們在釋出訊息之前建立監聽器;否則,我們將不會收到確認。這將建立一個接收回撥的迴圈,直到我們成功傳送或重試所有訊息。

傳送消費者發貨確認訊息
在研究手動確認之前,讓我們先看一個沒有手動確認的示例。使用自動確認時,只要代理將訊息傳送給消費者,即認為該訊息已成功送達。讓我們看一個簡單的示例:

public class UuidConsumer {
    private String queue;
    private Channel channel;
    <font>// all-args constructor<i>
    public void consume() throws IOException {
        channel.basicConsume(queue, true, (consumerTag, delivery) -> {
           
// processing...<i>
        }, cancelledTag -> {
           
// logging...<i>
        });
    }
}

透過autoAck引數將true傳遞 給basicConsume()時,將啟用自動確認。儘管這快速而直接,但它並不安全,因為代理會在我們處理訊息之前丟棄它。因此,最安全的選擇是停用它,並在通道上使用basickAck()傳送手動確認,保證訊息在退出佇列之前得到成功處理:

channel.basicConsume(queue, false, (consumerTag, delivery) -> {
    long deliveryTag = delivery.getEnvelope().getDeliveryTag();
    <font>// processing...<i>
    channel.basicAck(deliveryTag, false);
}, cancelledTag -> {
   
// logging...<i>
});

最簡單的形式是,我們在處理完每條訊息後確認它。我們使用收到的相同交付標籤來確認消費。最重要的是,要發出單獨確認訊號,我們必須將false傳遞給basicAck()。這可能非常慢,所以讓我們看看如何改進它。

定義頻道上的基本 QoS
通常,RabbitMQ 會在訊息可用時立即推送訊息。我們將在頻道上設定必要的服務質量設定以避免這種情況。因此,讓我們在建構函式中包含一個batchSize引數,並將其傳遞給頻道上的basicQos(),這樣只會預取此數量的訊息:

public class UuidConsumer {
    <font>// ...<i>
    private int batchSize;
    public UuidConsumer(Channel channel, String queue, int batchSize) throws IOException {
       
// ...<i>
        this.batchSize = batchSize;
        channel.basicQos(batchSize);
    }
}

這有助於在我們處理能夠處理的訊息的同時,讓其他消費者能夠獲取訊息。

定義確認策略
我們不必向處理的每條訊息傳送 ACK,而是在每次達到批處理大小時傳送一個 ACK​​,從而提高效能。為了實現更完整的場景,我們引入一個簡單的處理方法。如果我們可以將訊息解析為 UUID,則認為該訊息已處理:

private boolean process(String message) {
    try {
        UUID.fromString(message);
        return true;
    } catch (IllegalArgumentException e) {
        return false;
    }
}

現在,讓我們用一個用於傳送批次確認的基本框架來修改我們的consume()方法:

channel.basicConsume(queue, false, (consumerTag, delivery) -> {
    String message = new String(delivery.getBody(), <font>"UTF-8");
    long deliveryTag = delivery.getEnvelope().getDeliveryTag();
    if (!process(message)) {
       
// ...<i>
    } else if (deliveryTag % batchSize == 0) {
       
// ...<i>
    } else {
       
// ...<i>
    }
}

如果無法處理該訊息,我們將 NACK 該訊息,並檢查是否已達到批處理大小以 ACK 待處理的訊息。否則,我們將儲存待處理 ACK 的交付標籤,以便在以後的迭代中傳送。我們將把它儲存在類變數中:

private AtomicLong pendingTag = new AtomicLong();

拒絕訊息
如果我們不想要或無法處理訊息,我們會拒絕它們;拒絕後,我們可以重新排隊。例如,如果我們超出容量並希望另一個消費者接收它而不是告訴代理丟棄它,重新排隊很有用。我們有兩種方法可以實現這一點:

  • channel.basicReject(deliveryTag, requeue)  —拒絕單條訊息,並可選擇重新排隊或丟棄。
  • channel.basicNack(deliveryTag, multiple, requeue) — 與上面相同,但可以選擇批次拒絕。將true傳遞給multiple將拒絕自上次 ACK 到當前傳遞標籤的所有訊息。

由於我們要逐條拒絕訊息,因此我們將使用第一個選項。如果有待處理的 ACK,我們將傳送它並重置變數。最後,我們拒絕該訊息:

if (!process(message, deliveryTag)) {
    if (pendingTag.get() != 0) {
        channel.basicAck(pendingTag.get(), true);
        pendingTag.set(0);
    }
    channel.basicReject(deliveryTag, false);
}

批次確認訊息
由於交付標籤是連續的,我們可以使用模數運算子來檢查是否已達到批處理大小。 如果已達到,我們將傳送 ACK 並重置未決標籤。 這次,將true傳遞給“ multiple”引數至關重要,以便代理知道我們已成功處理了包括當前交付標籤在內的所有訊息:

else if (deliveryTag % batchSize == 0) {
    channel.basicAck(deliveryTag, true);
    pendingTag.set(0);
} else {
    pendingTag.set(deliveryTag);
}

否則,我們只需設定待處理標籤以在另一次迭代中檢查它。此外,為同一標籤傳送多個確認將導致RabbitMQ出現“ PRECONDITION_FAILED - 未知交付標籤”錯誤。

需要注意的是,當使用多個標誌傳送 ACK 時,我們必須考慮由於沒有更多訊息需要處理而永遠無法達到批處理大小的情況。一種選擇是保留一個觀察執行緒,定期檢查是否有待處理的 ACK 需要傳送。

結論
在本文中,我們探討了 RabbitMQ 中釋出者確認和消費者確認的功能,這些功能對於確保分散式系統中的資料安全性和穩健性至關重要。

釋出者確認使我們能夠驗證訊息是否已成功傳輸到 RabbitMQ 代理,從而降低訊息丟失的風險。消費者確認透過確認訊息消費來實現受控且有彈性的訊息處理。

相關文章