發佈者確認(Publisher confirms)是一個RabbitMQ擴展,用於實現可靠的消息發佈。當在通道上啓用發佈者確認時,客戶端發佈的消息將由代理異步確認,這意味着它們已在服務器端得到處理。
概覽
在本教程中,我們將使用publisher confirms來確保發佈的消息已安全到達代理。我們將介紹幾種使用publisher confirms的策略,並解釋它們的優缺點。
在通道上啓用發佈者確認
發佈者確認是AMQP 0.9.1協議的RabbitMQ擴展,因此默認情況下不會啓用它們。使用confirmSelect方法在通道級別啓用發佈者確認:
Channel channel = connection.createChannel();
channel.confirmSelect();
必須在您希望使用publisher confirms的每個通道上調用此方法。確認應該只啓用一次,而不是對發佈的每個消息啓用一次。
策略1:單獨發佈消息
讓我們從使用confirms發佈消息的最簡單方法開始,即發佈消息並同步等待消息的確認:
while (thereAreMessagesToPublish()) {
byte[] body = ...;
BasicProperties properties = ...;
channel.basicPublish(exchange, queue, properties, body);
// uses a 5 second timeout
channel.waitForConfirmsOrDie(5_000);
}
在前面的示例中,我們像往常一樣發佈消息,並使用Channel#waitForConfirmsOrDie(long)方法等待消息的確認。消息確認後,該方法立即返回。如果消息在超時時間內沒有得到確認,或者消息是nack-ed(這意味着代理由於某種原因無法處理它),那麼該方法將拋出一個異常。異常的處理通常包括記錄錯誤消息和/或重試發送消息。
不同的客戶端庫有不同的方法來同步處理髮布者確認,所以一定要仔細閱讀所使用的客戶端的文檔。
這種技術非常簡單,但也有一個主要的缺點:它大大減慢了發佈速度,因爲消息的確認會阻止所有後續消息的發佈。這種方法不會提供每秒超過幾百條已發佈消息的吞吐量。然而,對於某些應用來說,這已經足夠好了。
發佈者確認是異步的嗎?
我們在開始時提到代理異步確認已發佈的消息,但在第一個示例中,代碼將同步等待,直到消息被確認。客戶端實際上異步接收確認,並相應地解除對waitForConfirmsOrDie的調用的阻塞。可以把waitForConfirmsOrDie看作一個同步助手,它在幕後依賴異步通知。
策略2:批量發佈消息
爲了改進前面的示例,我們可以發佈一批消息並等待整個批被確認。以下示例使用一批100:
int batchSize = 100;
int outstandingMessageCount = 0;
while (thereAreMessagesToPublish()) {
byte[] body = ...;
BasicProperties properties = ...;
channel.basicPublish(exchange, queue, properties, body);
outstandingMessageCount++;
if (outstandingMessageCount == batchSize) {
ch.waitForConfirmsOrDie(5_000);
outstandingMessageCount = 0;
}
}
if (outstandingMessageCount > 0) {
ch.waitForConfirmsOrDie(5_000);
}
與等待單個消息的確認相比,等待一批消息被確認大大提高了吞吐量(對於遠程RabbitMQ節點,可以達到20-30倍)。一個缺點是,我們不知道在失敗的情況下到底出了什麼問題,所以我們可能必須在內存中保留一整批數據,以便記錄某些有意義的內容或重新發布消息。而且這個解決方案仍然是同步的,所以它阻止了消息的發佈。
策略3:異步處理髮布者確認
代理以異步方式確認已發佈的消息,只需在客戶端上註冊一個回調即可收到這些確認的通知:
Channel channel = connection.createChannel();
channel.confirmSelect();
channel.addConfirmListener((sequenceNumber, multiple) -> {
// code when message is confirmed
}, (sequenceNumber, multiple) -> {
// code when message is nack-ed
});
有兩個回調:一個用於確認消息,一個用於nack-ed消息(代理可以認爲丟失的消息)。每個回調有2個參數:
- sequenceNumber:標識已確認或不正確的消息的編號。我們將很快看到如何將它與發佈的消息關聯起來。
- multiple:這是一個布爾值。如果爲false,則只有一條消息被confirmed/nack-ed;如果爲true,則序列號較低或相等的所有消息都被確認/nack-ed。
在發佈之前,可以使用Channel#getNextPublishSeqNo()獲取序列號:
int sequenceNumber = channel.getNextPublishSeqNo());
ch.basicPublish(exchange, queue, properties, body);
將消息與序列號關聯的一種簡單方法是使用映射。假設我們想要發佈字符串,因爲它們很容易變成一個字節數組進行發佈。下面是一個代碼示例,它使用映射將發佈序列號與消息的字符串正文相關聯:
ConcurrentNavigableMap<Long, String> outstandingConfirms = new ConcurrentSkipListMap<>();
// ... code for confirm callbacks will come later
String body = "...";
outstandingConfirms.put(channel.getNextPublishSeqNo(), body);
channel.basicPublish(exchange, queue, properties, body.getBytes());
發佈代碼現在使用映射跟蹤出站消息。當確認到達時,我們需要清理此map,並在消息沒有被確認時記錄警告:
ConcurrentNavigableMap<Long, String> outstandingConfirms = new ConcurrentSkipListMap<>();
ConfirmCallback cleanOutstandingConfirms = (sequenceNumber, multiple) -> {
if (multiple) {
ConcurrentNavigableMap<Long, String> confirmed = outstandingConfirms.headMap(
sequenceNumber, true
);
confirmed.clear();
} else {
outstandingConfirms.remove(sequenceNumber);
}
};
channel.addConfirmListener(cleanOutstandingConfirms, (sequenceNumber, multiple) -> {
String body = outstandingConfirms.get(sequenceNumber);
System.err.format(
"Message with body %s has been nack-ed. Sequence number: %d, multiple: %b%n",
body, sequenceNumber, multiple
);
cleanOutstandingConfirms.handle(sequenceNumber, multiple);
});
// ... publishing code
上一個示例包含一個回調函數,當確認到達時,該回調函數將清理映射。這個回調可以處理一個或多個確認。此回調用於確認到達時(作爲Channel#addConfirmListener的第一個參數)。未確認消息的回調將檢索消息正文併發出警告。然後,它重新使用前面的回調來清除未完成確認的映射(無論消息是確認的還是未確認的,都必須刪除映射中相應的條目)
如何跟蹤未完成的確認?
我們的樣本使用ConcurrentNavigableMap跟蹤未完成的確認。這種數據結構很方便,有幾個原因。它允許輕鬆地將序列號與消息(無論消息數據是什麼)相關聯,並容易地將條目清理到給定的序列id(以處理多個確認/未確認)。最後,它支持併發訪問,因爲confirm回調是在客戶端庫擁有的線程中調用的,它應該與發佈線程保持不同。
與複雜的映射實現相比,還有其他跟蹤未完成確認的方法,例如使用簡單的併發哈希映射和變量來跟蹤發佈序列的下限,但它們通常涉及的內容更多,不屬於教程。
綜上所述,異步處理髮布者確認通常需要以下步驟:
- 提供一種將發佈序列號與消息關聯的方法。
- 在通道上註冊一個確認偵聽器,以便在發佈者確認/未確認到達時得到通知,以執行適當的操作,如記錄或重新發布未確認消息。在這一步中,序列號與消息的關聯關係可能還需要進行一些清理。
- 在發佈消息之前跟蹤發佈序列號。
重新發布未確認消息?
從關聯的回調中重新發布未確認消息是很有誘惑力的,但這應該避免,因爲confirm回調是在I/O線程中調度的,其中通道不應該執行操作。一個更好的解決方案是將消息排隊到由發佈線程輪詢的內存隊列中。像ConcurrentLinkedQueue這樣的類是在confirm回調和發佈線程之間傳輸消息的一個很好的候選類。
總結
在某些應用程序中,確保已發佈的消息已發送給代理程序是至關重要的。發佈者確認是一個RabbitMQ特性,有助於滿足這一要求。發佈者確認本質上是異步的,但也可以同步處理它們。沒有確定的方法來實現publisher-confirms,這通常歸結爲應用程序和整個系統中的約束。典型的技術有:
- 單獨發佈消息,同步等待確認:簡單,但吞吐量非常有限。
- 批量發佈消息,爲一個批同步等待確認:簡單、合理的吞吐量,但很難判斷出什麼時候出了問題。
- 異步處理:最佳的性能和資源利用率,在發生錯誤時能很好地控制,但正確地調用實現有難度(原文:best performance and use of resources, good control in case of error, but can be involved to implement correctly.)。
把它們放在一起
這個PublisherConfirms.java類包含我們討論的技術代碼。我們可以編譯它,按原樣執行,然後看看它們各自的執行情況:
javac -cp $CP PublisherConfirms.java
java -cp $CP PublisherConfirms
輸出如下所示:
Published 50,000 messages individually in 5,549 ms
Published 50,000 messages in batch in 2,331 ms
Published 50,000 messages and handled confirms asynchronously in 4,054 ms
如果客戶端和服務器位於同一臺計算機上,則計算機上的輸出應該類似。單獨發佈消息的性能不如預期,但是異步處理的結果與批量發佈相比有點令人失望。
發佈確認它非常依賴於網絡,因此我們最好嘗試使用遠程節點,這更現實,因爲客戶機和服務器通常不在同一臺機器上生產。PublisherConfirms.java可以輕鬆更改爲使用非本地節點:
static Connection createConnection() throws Exception {
ConnectionFactory cf = new ConnectionFactory();
cf.setHost("remote-host");
cf.setUsername("remote-user");
cf.setPassword("remote-password");
return cf.newConnection();
}
重新編譯類,再次執行,然後等待結果:
Published 50,000 messages individually in 231,541 ms
Published 50,000 messages in batch in 7,232 ms
Published 50,000 messages and handled confirms asynchronously in 6,332 ms
我們看到現在單條發送的表現非常糟糕。但是在客戶機和服務器之間的網絡中,批量發佈和異步處理現在表現得類似,但是異步處理稍有一點小優勢。
請記住,批量發佈的實現很簡單,但是在發佈者確認失敗的情況下,不容易知道哪些消息發送給代理失敗。實現異步處理髮布者確認需要耗費更多的時間,但在發佈消息爲未確認時能對要執行的操作提供更細的粒度和的更好的控制。