在上一章中,我们编写了一个简单的程序从一个命名队列发送和接收消息。在本章我们将创建一个工作队列(work Queue,后面统称工作队列)用来给多个工作节点分发任务。
工作队列(又名任务队列)主要的思想是为了避免执行资源密集型的任务,因为那样我们不得不花时间等待其完成。通过工作队列我们可以适时的调度任务执行。我们可以把任务封装成一个消息发送到工作队列中。后台运行的工作进程可以从工作队列中取出任务并执行。
这个概念在web应用程序中是非常有用的,可以用来在一个短的http请求窗口中处理复杂的任务。
预备知识
上一节我们只是简单地发送一个包含“hello world”的消息。在本节我们将实现一个复杂一点的任务。但这个例子没有像调整图像大小或者呈现PDF文件这么复杂,我们只是通过使用 Thread.sleep()方法来让我们的程序看起来很忙。我们将以字符串中的点号的数量作为复杂度;每一个点表示进行一秒钟的工作(sleep 1秒);比如,内容为“hello…”的消息表示任务工作3秒。
我们对前一节的send.java代码进行简单修改,让其允许可以通过命令行输入发送的消息。这个程序将调度任务到工作队列,因此把这个程序命名为NewTask.java:
String message = getMessage(argv); channel.basicPublish("", "hello", null, message.getBytes()); System.out.println(" [x] Sent '" + message + "'");
从命令行获取参数:
private static String getMessage(String[] strings){ if (strings.length < 1) return "Hello World!"; return joinStrings(strings, " "); } private static String joinStrings(String[] strings, String delimiter) { int length = strings.length; if (length == 0) return ""; StringBuilder words = new StringBuilder(strings[0]); for (int i = 1; i < length; i++) { words.append(delimiter).append(strings[i]); } return words.toString(); }
同样上一节的Recv.java也需要修改一下:需要根据消息中点号个数来决定睡眠时间。由于它从工作队列中取出消息来当做任务执行,因此我们称其为worker.java:
while (true) { QueueingConsumer.Delivery delivery = consumer.nextDelivery(); String message = new String(delivery.getBody()); System.out.println(" [x] Received '" + message + "'"); doWork(message); System.out.println(" [x] Done"); }
根据点号个数来执行任务:
private static void doWork(String task) throws InterruptedException { for (char ch: task.toCharArray()) { if (ch == '.') Thread.sleep(1000); } }
循环调度(Round-robin)
使用工作队列的一个好处就是使任务能够并行进行。如果我们想处理一个复杂的任务,可以像上述一样轻松地添加多个工作者(后面统称worker)到工作队列中。
在本例中,我们同时创建两个worker实例来运行;它们都将从队列中获取消息。下面我们来看看执行的结果。
你需要打开三个控制台。其中两个运行worker程序,它们充当消费者C1和C2:
shell1$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar Worker [*] Waiting for messages. To exit press CTRL+C shell2$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar Worker [*] Waiting for messages. To exit press CTRL+C
第三个控制台则用来发布新的任务。一旦你启动了消费者就可以发布消息了:
shell3$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar NewTask First message. shell3$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar NewTask Second message.. shell3$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar NewTask Third message... shell3$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar NewTask Fourth message.... shell3$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar NewTask Fifth message.....
首先我们看一下workers接收到了什么:
shell1$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar Worker [*] Waiting for messages. To exit press CTRL+C [x] Received 'First message.' [x] Received 'Third message...' [x] Received 'Fifth message.....' shell2$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar Worker [*] Waiting for messages. To exit press CTRL+C [x] Received 'Second message..' [x] Received 'Fourth message....'
默认情况下,RabbitMQ会按序地往每个消费者推送消息。也就是平均每个消费者都应获得相同数量的消息。这种分配消息方式叫做轮询(后面统称round-robin)。我们可以尝试一下创建3个或者更多的worker。
消息应答
完成一个任务可能只需要几秒钟。假如一个任务执行了很久,有可能你会想知道是否该任务执行一半就停掉了。在我们当前的代码中,一旦RabbitMQ向消费 者传递了一个消息它就会马上从内存中删除掉。在这种情况下,如果你kill掉一个正在执行的worker,那么你将丢失处理中的消息。不仅如此我们还会丢 失所有已经递送给该worker的未处理的消息。
假如我们不想丢失任何消息,也就是如果一个worker宕掉了,我们可以把消息传递给其他的worker执行。这时我们该怎么做呢?
为了确保消息不会丢失,RabbitMQ提供了消息应答机制(acknowledgments,后面统称ack)。假如一个消息在消费者端被接收并处理完成,那么消费者就会给RabbitMQ server发送一个ack来告诉其可以删除该消息。
相反情况,消费者没有发送ack应答,则RabbitMQ认为该消息没被处理完成进而把消息传递给其他worker。通过这种方式,即使worker宕掉也不会丢失消息。
RabbitMQ没有超时控制机制。只有在worker与RabbitMQ之间的连接断掉后,RabbitMQ才会重发消息。即使一个消息被处理了很长时间也没有返回ack,RabbitMQ也认为是正常的。
默认情况下ack机制是打开的。前面的例子中我们通过设置autoAck=true标志来关掉ack机制。一旦我们完成任务,我们就需要清除这个标志并发送ack。
QueueingConsumer consumer = new QueueingConsumer(channel); boolean autoAck = false; channel.basicConsume("hello", autoAck, consumer); while (true) { QueueingConsumer.Delivery delivery = consumer.nextDelivery(); //... channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false); }
通过该段代码我们可以知道,即使你通过CTRL+C终止了一个正在处理消息的worker,该worker所有未返回ack的消息都不会丢失而是被重新发送。
忘记发送ack
有一个常见的错误就是忘记basicAck调用。这虽然是一个看似简单的错误,后果且是严重的。RabbitMQ因收不到ack而不断地重发消息。最终RabbitMQ由于不能释放未返回ack的消息而逐渐把内存耗掉。
为了调试这种错误,我们可以使用命令rabbitmqctl来打印messages_unacknowledged字段:
$ sudo rabbitmqctl list_queues name messages_ready messages_unacknowledged
Listing queues ...
hello 0 0
...done.
消息持久性
我们已经学会在消费者宕掉后如何保证消息不会丢失。但是假如是RabbitMQ服务器宕机了呢?
当RabbitMQ server退出或者宕掉了,它就会忘掉所有的队列和消息,除非你告诉它不要这么做。为了保证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重启了,task_queue也不会丢失。现在我们可以通过设置MessageProperties为 值PERSISTENT_TEXT_PLAIN来标记消息为持久性。假如想了解更多Channel方法与MessageProperties你可以查看javadocs online.
import com.rabbitmq.client.MessageProperties; channel.basicPublish("", "task_queue", MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes());
关于消息的持久化
消息被标志为持久性并不能完全保证消息不会丢失。虽然它告诉RabbitMQ要将消息保存在磁盘上,但是不管如何肯定有一小段时间窗口RabbitMQ已 经接收了消息但没来得及保存。同时,RabbitMQ也不会为每个消息都执行fsync(2)操作--可能只是保存在缓存中而没有真正写入到磁盘中。这种 持久性保证并不是很强,但是已能够满足我们这个简单的例子的要求。如果你需要一个更加强大的持久性保证,那么你需要使用发布者确认机制
公平分配
你可能已经注意到,上述的任务调度结果并没有完全符合我们的期望。比如假如有两个workers,奇数的消息比较重而偶数的消息较轻,那么调度的结果就是一个worker非常的忙而另一个则几乎不做任何工作。RabbitMQ并不知道这种情况而继续均匀调度消息。
这是因为当消息进来队列后RabbitMQ就分发消息。RabbitMQ并没有关注相应消费者未返回ack的消息个数。它只是盲目的分发第n个消息到第n个消费者。
为了解决上述的问题,我们可以使用参数 prefetchCount = 1 来调用basicQos方法。这个方法告诉RabbitMQ每次不能给消费者超过一个消息;换句话说,只有等到消费把先前的消息处理完并返回 ack,RabbitMQ才能分发新的消息。通过这种方式,RabbitMQ将能根据消费者负载情况来分发消息。
int prefetchCount = 1; channel.basicQos(prefetchCount);
关于队列大小
如果所有的worker都在执行任务,那么队列就可能被填满。这时你需要是添加更多的workers呢还是执行其他的补救策略。
完整的代码:
NewTask.java
import com.rabbitmq.client.ConnectionFactory; import com.rabbitmq.client.Connection; import com.rabbitmq.client.Channel; 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"); Connection connection = factory.newConnection(); Channel channel = connection.createChannel(); channel.queueDeclare(TASK_QUEUE_NAME, true, false, false, null); String message = getMessage(argv); channel.basicPublish( "", TASK_QUEUE_NAME, MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes()); System.out.println(" [x] Sent '" + message + "'"); channel.close(); connection.close(); } private static String getMessage(String[] strings){ if (strings.length < 1) return "Hello World!"; return joinStrings(strings, " "); } private static String joinStrings(String[] strings, String delimiter) { int length = strings.length; if (length == 0) return ""; StringBuilder words = new StringBuilder(strings[0]); for (int i = 1; i < length; i++) { words.append(delimiter).append(strings[i]); } return words.toString(); } }
Worker.java:
import com.rabbitmq.client.ConnectionFactory; import com.rabbitmq.client.Connection; import com.rabbitmq.client.Channel; import com.rabbitmq.client.QueueingConsumer; 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"); Connection connection = factory.newConnection(); 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); QueueingConsumer consumer = new QueueingConsumer(channel); channel.basicConsume(TASK_QUEUE_NAME, false, consumer); while (true) { QueueingConsumer.Delivery delivery = consumer.nextDelivery(); String message = new String(delivery.getBody()); System.out.println(" [x] Received '" + message + "'"); doWork(message); System.out.println(" [x] Done"); channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false); } } private static void doWork(String task) throws InterruptedException { for (char ch: task.toCharArray()) { if (ch == '.') Thread.sleep(1000); } } }