在第一個教程中,我們編寫了從命名隊列發送和接收消息的程序。在本例中,我們將創建一個工作隊列,用於在多個工作人員之間分配耗時的任務。
工作隊列(又名:任務隊列)的主要思想是避免立即執行資源密集型任務,並且必須等待任務完成。相反,我們把任務安排在以後完成。我們將任務封裝爲消息並將其發送到隊列。在後臺運行的工作進程將從隊列彈出(pop操作)任務並最終執行該作業。當您運行多個worker時,任務將在他們之間共享。
這個概念在web應用程序中特別有用,因爲在短的HTTP請求窗口期間無法處理複雜的任務。
準備
在本教程的前一部分中,我們發送了一條包含“Hello World!”的消息。現在我們將發送代表複雜任務的字符串。我們沒有實際的任務,比如要調整大小的圖像或要呈現的pdf文件,所以讓我們假裝很忙-通過使用Thread.sleep() 功能。我們將以字符串中的點的數量作爲其複雜性;每個點將佔“工作”的1秒。例如,一個由“Hello… ”描述的假任務需要三秒鐘。
我們將輕微修改上一個示例中的代碼Send.java,使其允許從命令行發送任意消息。這個程序會將任務調度到我們的工作隊列中,所以我們來命名它NewTask.java:
String message = String.join(" ", argv);
channel.basicPublish("", "hello", null, message.getBytes());
System.out.println(" [x] Sent '" + message + "'");
我們的老朋友Recv.java程序也需要一些改變:它需要爲消息體中的每個點僞造一秒鐘的工作。它將處理傳遞的消息並執行任務,因此我們將其命名爲Worker.java:
DeliverCallback deliverCallback = (consumerTag, delivery) -> {
String message = new String(delivery.getBody(), "UTF-8");
System.out.println(" [x] Received '" + message + "'");
try {
doWork(message);
} finally {
System.out.println(" [x] Done");
}
};
boolean autoAck = true; // acknowledgment is covered below
channel.basicConsume(TASK_QUEUE_NAME, autoAck, deliverCallback, consumerTag -> { });
我們模擬執行時間的假任務:
private static void doWork(String task) throws InterruptedException {
for (char ch: task.toCharArray()) {
if (ch == '.') Thread.sleep(1000);
}
}
按照教程一(見上一篇)中的方法編譯它們(使用工作目錄中的jar文件和環境變量CP):
javac -cp $CP NewTask.java Worker.java
輪詢分發
使用任務隊列的優點之一是能夠輕鬆地並行處理工作。如果我們正在積累積壓的工作,我們可以增加更多的工作線程,這樣就可以很容易地擴大規模。
首先,讓我們嘗試同時運行兩個worker實例。它們都將從隊列中獲取消息,但具體是如何獲得的呢?讓我們看看。
你需要打開三個控制檯。兩個將運行worker程序。這些控制檯將是我們的兩個消費者-C1和C2。
# shell 1
java -cp $CP Worker
# => [*] Waiting for messages. To exit press CTRL+C
# shell 2
java -cp $CP Worker
# => [*] Waiting for messages. To exit press CTRL+C
在第三個控制檯我們將發佈新的任務。啓動消費者後,您可以發佈一些消息:
# shell 3
java -cp $CP NewTask First message.
# => [x] Sent 'First message.'
java -cp $CP NewTask Second message..
# => [x] Sent 'Second message..'
java -cp $CP NewTask Third message...
# => [x] Sent 'Third message...'
java -cp $CP NewTask Fourth message....
# => [x] Sent 'Fourth message....'
java -cp $CP NewTask Fifth message.....
# => [x] Sent 'Fifth message.....'
讓我們看看投遞到worker的是什麼:
java -cp $CP Worker
# => [*] Waiting for messages. To exit press CTRL+C
# => [x] Received 'First message.'
# => [x] Received 'Third message...'
# => [x] Received 'Fifth message.....'
java -cp $CP Worker
# => [*] Waiting for messages. To exit press CTRL+C
# => [x] Received 'Second message..'
# => [x] Received 'Fourth message....'
默認情況下,RabbitMQ將按順序將每條消息發送到下一個消費者。平均每個消費者將收到相同數量的消息。這種分發消息的方式稱爲輪詢。可以用三個或更多的消費者試試這個。
消息確認
完成任務可能需要幾秒鐘。你可能會想知道,如果一個消費者開始一項長期任務卻只完成了一部分,會發生什麼。在我們當前的代碼中,一旦RabbitMQ向消費者傳遞了一條消息,它就會立即將其標記爲刪除。在這種情況下,如果您殺掉一個消費者,我們將丟失它正在處理的消息。我們還將丟失已發送給這個線程但尚未處理(接受)的所有消息。
但我們不想失去任何任務(消息)。如果一個消費者死了,我們希望把任務交給另一個消費者。
爲了確保消息不會丟失,RabbitMQ支持消息確認。使用者會發回一個確認,告訴RabbitMQ已經收到、處理了特定的消息,並且RabbitMQ可以隨意刪除它。
如果一個使用者在沒有發送ack的情況下死亡(其通道關閉、連接關閉或TCP連接丟失),RabbitMQ將認爲消息未被完全處理,並將其重新放入隊列。如果有其他消費者同時在線,它會很快將其重新發送給另一個消費者。這樣你就可以確保沒有信息丟失,即使消費者偶爾死亡。
不會有任何消息超時;RabbitMQ將在消費者死亡時重新傳遞消息。即使處理一條消息需要很長時間也沒關係。
默認情況下,手動消息確認處於啓用狀態。在前面的例子中,我們通過autoAck=true標誌顯式地關閉了它們。一旦我們完成了一個任務,現在是時候將這個標誌設置爲false並從消費者那裏發送一個正確的確認。
channel.basicQos(1); // accept only one unack-ed message at a time (see below)
DeliverCallback deliverCallback = (consumerTag, delivery) -> {
String message = new String(delivery.getBody(), "UTF-8");
System.out.println(" [x] Received '" + message + "'");
try {
doWork(message);
} finally {
System.out.println(" [x] Done");
channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
}
};
boolean autoAck = false;
channel.basicConsume(TASK_QUEUE_NAME, autoAck, deliverCallback, consumerTag -> { });
使用這段代碼,我們可以確保即使您在處理消息時使用CTRL+C殺死一個worker(消費者),也不會丟失任何內容。在worker死後不久,所有未確認的消息都將被重新發送。
確認必須在接收傳遞的同一通道上發送。嘗試使用不同的通道進行確認將導致通道級協議異常。有關更多信息,請參閱文檔確認指南。
忘記確認
忘記basicAck是一個常見的錯誤。這是一個簡單的錯誤,但後果是嚴重的。當您的消費者客戶端退出時,消息將被重新傳遞(這看起來像隨機重新傳遞),但是RabbitMQ將消耗越來越多的內存,因爲它無法釋放任何未確認的消息。
爲了調試這種錯誤,可以使用rabbitmqctl打印messages_unacknowledged字段:
- sudo rabbitmqctl list_queues name messages_ready messages_unacknowledged
在windows上,刪除sudo- rabbitmqctl.bat list_queues name messages_ready messages_unacknowledged
消息持久化
我們已經學會了如何確保即使消費者死亡,任務(消息)也不會丟失。但是如果RabbitMQ服務器停止運行,我們的任務仍然會丟失。
當RabbitMQ退出或崩潰時,它將忘記隊列和消息,除非您告訴它不要這樣做。要確保消息不會丟失,需要做兩件事:我們需要將隊列和消息都標記爲持久的。
首先,我們需要確保隊列能夠在RabbitMQ節點重新啓動後繼續運行。爲此,我們需要聲明它是持久的:
boolean durable = true;
channel.queueDeclare("hello", durable, false, false, null);
雖然這個命令本身是正確的,但它在我們當前的設置中不起作用。這是因爲我們已經定義了一個名爲hello的隊列,它不是持久的。RabbitMQ不允許您使用不同的參數重新定義現有隊列,並將向任何嘗試重新定義的程序返回錯誤。但是有一個快速的解決方法-讓我們聲明一個具有不同名稱的隊列,例如task_queue:
boolean durable = true;
channel.queueDeclare("task_queue", durable, false, false, null);
此queueDeclare更改需要同時應用於生產者代碼和使用者代碼。
這樣我們確保即使RabbitMQ重新啓動,任務隊列隊列也不會丟失。現在我們需要將消息標記爲persistent持久化的,方法是將MessageProperties(實現BasicProperties)設置爲PERSISTENT_TEXT_PLAIN值。
import com.rabbitmq.client.MessageProperties;
channel.basicPublish("", "task_queue",
MessageProperties.PERSISTENT_TEXT_PLAIN,
message.getBytes());
關於消息持久性的說明
將消息標記爲持久性並不能完全保證消息不會丟失。儘管它告訴RabbitMQ將消息保存到磁盤上,但是RabbitMQ接受了一條消息並且還沒有保存它時,仍然有一個很短的時間窗口。而且,RabbitMQ並不是對每個消息都執行fsync(2)——它可能只是保存到緩存中,而不是真正寫入磁盤。對於我們的任務來說,持久化的保證是不夠的。如果您需要更強有力的擔保,那麼您可以使用publisher confirms。
公平分發
你可能已經注意到調度仍然不能完全按照我們的要求工作。例如,在一個有兩個消費者的情況下,當所有的奇數消息都是重消息而偶數消息都是輕消息時,一個消費者將持續忙碌,而另一個消費者幾乎不做任何工作。嗯,RabbitMQ對此一無所知,仍然會均勻地發送消息。
這是因爲RabbitMQ只是在消息進入隊列時發送消息。它不考慮消費者未確認消息的數量。它只是盲目地將第n條消息發送給第n個消費者。
爲了避免這種情況,我們可以使用basicQos方法,並設置prefetchCount=1。這告訴RabbitMQ不要一次向一個消費者發出多個消息。或者,換句話說,在處理並確認前一條消息之前,不要向消費者發送新消息。相反,它將把它發送給下一個不忙的消費者。
int prefetchCount = 1;
channel.basicQos(prefetchCount);
關於隊列大小的說明
如果所有的消費者都很忙,你的隊列可能會滿的。你會想繼續關注這一點,也許會增加更多的消費者,或者有一些其他的策略。
把它們放在一起
我們的最終代碼NewTask.java類:
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.MessageProperties;
public class NewTask {
private static final String TASK_QUEUE_NAME = "task_queue";
public static void main(String[] argv) throws Exception {
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("localhost");
try (Connection connection = factory.newConnection();
Channel channel = connection.createChannel()) {
channel.queueDeclare(TASK_QUEUE_NAME, true, false, false, null);
String message = String.join(" ", argv);
channel.basicPublish("", TASK_QUEUE_NAME,
MessageProperties.PERSISTENT_TEXT_PLAIN,
message.getBytes("UTF-8"));
System.out.println(" [x] Sent '" + message + "'");
}
}
}
消費者
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.DeliverCallback;
public class Worker {
private static final String TASK_QUEUE_NAME = "task_queue";
public static void main(String[] argv) throws Exception {
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("localhost");
final Connection connection = factory.newConnection();
final Channel channel = connection.createChannel();
channel.queueDeclare(TASK_QUEUE_NAME, true, false, false, null);
System.out.println(" [*] Waiting for messages. To exit press CTRL+C");
channel.basicQos(1);
DeliverCallback deliverCallback = (consumerTag, delivery) -> {
String message = new String(delivery.getBody(), "UTF-8");
System.out.println(" [x] Received '" + message + "'");
try {
doWork(message);
} finally {
System.out.println(" [x] Done");
channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
}
};
channel.basicConsume(TASK_QUEUE_NAME, false, deliverCallback, consumerTag -> { });
}
private static void doWork(String task) {
for (char ch : task.toCharArray()) {
if (ch == '.') {
try {
Thread.sleep(1000);
} catch (InterruptedException _ignored) {
Thread.currentThread().interrupt();
}
}
}
}
}
可以使用消息確認和預取數設置工作隊列。即使RabbitMQ重新啓動,持久性選項也可以讓任務繼續存在。
有關通道方法和消息屬性的更多信息,您可以在線瀏覽JavaDocs。
現在我們可以繼續學習教程3,學習如何向許多消費者傳遞同樣的信息。