关注公众号:Java课代表,每日文章更新,及时获取更多知识。
2 工作队列(Work Queue)
在第一篇教程中,我们写了两个程序用来从指定的 queue 中发送和接收消息。这篇教程,我们将创建一个工作队列,用来给多个 worker 分发一些"耗时的"任务。
工作队列(或者称之为任务队列)背后的思想,是用来避免立即处理那些很耗资源并且需要等待其运行结束的任务(课代表注:说白了就是削峰)。取而代之的是,将任务安排到稍后进行(课代表注:说白了就是异步执行)。一个后台运行的工作程序将会接收到并执行该任务。当你运行了多个工作程序,工作队列中的任务将会被他们共同分担处理。
这个思想在web应用中非常有用,因为在web应用中,通过一个短的http请求窗口无法处理复杂的任务。
准备工作(Preparation)
在前面的教程中,我们发送了一个字符串消息:“"Hello World!”。接下来我们发送一些用来代表任务很复杂的字符串。我们并没有真实世界中那些像图片缩放,PDF文件渲染之类的复杂任务,所以,让我们使用Thread.sleep()
方法来假装很忙。用字符串中点号的个数当做任务的复杂度:每个点号代表一秒钟的“工作”。例如:由字符串Hello...
代表的任务将耗时3秒钟。
将前面例子中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);
}
}
像教程1中那样编译一下(确保需要的jar包都在工作目录中,并且设置了环境变量:CP):
javac -cp $CP NewTask.java Worker.java
Windows下自行将 $CP 替换为 %CP%,下同。——课代表注
轮询分发(Round-robin dispatching)
使用任务队列的优势之一是方便横向扩展。假设任务积压了,我们可以增加更多的 worker 程序,轻松扩展。
首先,让我们同时运行两个 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 会将每个消息按顺序发送给下一个消费者。每个消费者都会被平均分配到相同数量的消息。这种消息分发机制称为轮询。
可以多运行几个 worker 实例自行尝试。
消息确认(Message acknowledgment)
执行任务可能需要一段时间。你有没有想过,如果任务还没执行完,应用挂掉了怎么办?以我们目前的代码,一旦 RabbitMQ 将消息分发给了消费者,它会立刻将该消息标记为已删除。如此看来,一旦终止 worker 程序,就会丢失它正在处理的消息,以及它已经接收,但还没开始处理的消息。
但我们并不希望丢失任务。如果一个 worker 应用挂掉了,我们希望他所处理的任务能交给给别的 worker 处理。
为了确保消息不会丢失,RabbitMQ 提供消息确认机制。消息确认由消费者发回,告诉 RabbitMQ 某个指定的消息已经被接收、处理,并且 RabbitMQ 可以删掉该消息了。
如果某个消费者没有返回确认(ack) 就挂掉了(channel 关闭,链接关闭或者TCP连接丢失了),RabbitMQ 将会认为该消息没有被正确处理,会将其重新入队(re-queue)。如果此时有其他消费者在线,RabbitMQ 会迅速将该消息发送给他们。这样就可以保证,即使 worker 突然挂了,消息也不会丢失。
消息不会超时:RabbitMQ 将会在某个消费者挂掉时重新发送该消息。即使处理一条消息需要花费很长时间也无所谓。
手工消息确认 默认开启。在前面的示例中我们通过设置autoAck=true
将其关闭了。现在我们将标志位设为false
,并让worker 在工作完成时发送确认信息。
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 挂掉后未被确认的消息将会很快被重新投递。
确认消息的发送必须和接收消息时的 channel 相同。尝试使用不同的 channel 返回确认将会报 channel 协议异常。具体参见确认机制的参考文档
忘记确认
一个常见的错误就是忘记调用
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
消息持久化(Message durability)
我们已经学习了如何在消费者挂掉的情况下保证任务不丢失。但是,如果 RabbitMQ 服务停止了,任务还是会丢。
如果没有经过配置,当 RabbitMQ 停止或崩溃时,它将会丢失 队列(queue) 中已有的消息。为了避免这种情况,我们需要将队列(queue) 和消息(message) 都设置为持久化(durable):
boolean durable = true;
channel.queueDeclare("hello", durable, false, false, null);
尽管上面的命令是对的,但目前还不能正确工作。因为我们已经在 RabbitMQ 中声明了一个名为“hello”的非持久化队列。RabbitMQ 无法修改已存在队列的参数。我们可以换个思路,命名一个新的,开启持久化的队列,比如task_queue
:
boolean durable = true;
channel.queueDeclare("task_queue", durable, false, false, null);
持久化参数为true
的queueDeclare
方法需要在生产者和消费者代码中都加上。
此时,我们可以确定,即使 RabbitMQ 重启,task_queue
这个队列也不会丢。接下来我们通过将MessageProperties
的值设置为PERSISTENT_TEXT_PLAIN
,从而将消息设置为持久化。
import com.rabbitmq.client.MessageProperties;
channel.basicPublish("", "task_queue",
MessageProperties.PERSISTENT_TEXT_PLAIN,
message.getBytes());
消息持久化的注意事项
将消息标记为持久化并不能完全保证消息不丢失。尽管告诉了
RabbitMQ
将消息保存到磁盘,仍然存在一段小的窗口期RabbitMQ接收了消息但还没来得及保存。此外,RabbitMQ
不会对每条消息都执行fsync(2)
—— 它可能刚刚被写入缓存,还没真正写到磁盘上。持久化机制并不健壮,但对于task
来说队列足够了。如果需要更可靠的持久化,你需要使用 publisher confirms。
公平分发(Fair dispatch)
轮询分发有时候并不能满足我们的需要。比如在只有两个 worker 的场景下,序号为奇数的消息涉及大量运算,而序号为偶数的消息都很简单。RabbitMQ 并不知道消息的难易程度,他只会均匀分发给两个 worker。
出现这种情况是因为,RabbitMQ 只负责将队列中收到的消息分发出去,他并不关心消费者未确认的消息数量。它只是盲目地将第N的消息发给第N个消费者。
为了解决这个问题,我们可以调用 basicQos
方法,将它的参数 prefetchCount 设置为 1。这将告诉 RabbitMQ 同一时间内给 worker 的消息数量不要超过 1。换句话说,在 worker 没有返回确认之前,不要给他分发新消息。这样一来,RabbitMQ 会将消息发送给其他不忙的 worker。
int prefetchCount = 1;
channel.basicQos(prefetchCount);
关于队列大小
如果所有 worker 都很忙,队列有可能被塞满。你需要实时监控他的大小,或者增加 worker 的数量,或者采用其他策略(课代表注:比如控制生产者和消费者的比例)
代码整合(Putting it all together)
最终的 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 + "'");
}
}
}
Worker.java:
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();
}
}
}
}
}
使用消息确认并设置prefetchCount
参数建立的工作队列。其持久化设置可以让消息在 RabbitMQ 重启后依然存在。
更多关于 Channel
和 MessageProperties
的内容,请访问:JavaDocs online.
接下来我们进入教程3,学习如何将同一个消息发送给多个消费者。
推荐阅读
RabbitMQ教程 1.“Hello World”
深入浅出 MySQL 优先队列(你一定会踩到的order by limit 问题)
码字不易,欢迎点赞分享。
搜索:【Java课代表】,关注公众号,及时获取更多Java干货。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。