在上篇中我们实现了程序来从一个已经命名的队列里发送和接收消息。本篇博文中我们将要创建工作队列用来把一些比较耗时的任务分配给多个worker。
工作队列的主要思想就是避开立刻处理某个资源消耗交大的任务并且需要等待它执行完成。取而代之的是我们可以将它加入计划列表,并在后边执行这些任务。我们将任务分装成一个消息,并发送到队列中。后台的工作程序在接收到消息后将会立刻执行任务。当运行多个执行器时,任务将会在他们之间共享。
这个概念在web应用程序中是比较实用的,对于一些在一个短的http请求里无法完成的复杂任务。
1、准备
上篇博文中是发送一个包含”Hello World“的消息。现在我们来发送一条代表复杂任务的字符串。我们这里没有一个真实存在的任务,例如修改图片大小和渲染pdf文件这类的任务,这里我们模拟一个任务繁忙的场景(使用Thread.sleep()函数)。这里我们使用字符串类的点号个数来代表任务的复杂性,每一个点号都占用一秒钟的处理时间。例如,一个用”Hello…”来描述的伪造的任务将会占用三秒时间。
我们稍微修改一下上篇博文中的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();
}
旧的接收端也要做稍微的修改:消息体里的一个逗号代表一个一秒钟的任务,接收端会接收到消息,然后执行任务。这里重新命名为Work.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);
}
}
2、轮询调度
任务队列的一个较大的优势就是能够很方便的安排工作。如果后台队列里正在积压一些工作一直没有被执行的话,通过添加更多的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.....
然后查看我们的worker执行了什么任务:
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会把每个消息以此轮询发到各个消费者那,把消息平均的发到各个消费者那。这种分配管理的方式叫轮询,还可以测试多个worker的情形。
3、消息应答机制
完成一个任务需要花费几秒钟。你一定很好奇,如果某个消费者开始执行某个任务花费了很长的时间并且在执行到某个部分时崩溃了那会怎么样。在我们现在的代码中,在向消费者推送某条消息后,RabbitMQ会立刻删除掉这条消息。这样的话,如果我们kill掉某个worker的话,那么我们将会流失掉该worker正在处理任务的消息(改任务未处理完成),我们也会丢失所有被发送到这个消费者且未处理完成的消息。
但是,我们不想丢失这部分消息,我们希望这类消息可以再次被发送到其它worker那。
为了保证永远不会丢失消息,RabbitMQ支持消息应答机制。当消费者接收到消息并完成任务后会往RabbitMQ服务器发送一条确认的命令,然后RabbitMQ才会将消息删除。
如果某个消费者在还有发送确认信息就挂了,RabbitMQ将会视为服务没有执行完成,然后把执行消息的服务再发给另外一个消费者。这种方式下,即时某个worker挂了,也不会使得消息丢失。
这里不是用超时来判断的,只有在某个消费者连接断开时,RabbitMQ才会把重新发送该消费者没有返回确认的消息到其它消费者那。即时处理某条任务花费了很长的时间,在这里也是没有问题的。
消息应答机制默认是打开的,在上边例子中我们明确的关闭了它(autoAck=true),那么现在应该如下修改程序:
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);
}
这样就可以保证即时你kill掉了worker也不会出现信息丢失的现象,worker被kill掉之后,所有的未确认消息将会被重新发送。
易错点:
很多人都会忘记调用basicAck方法,虽然这是一个很简单的错误,但往往却是致命。消费者退出后消息将会被重发,但是由于一些未能被确认消息不能被释放,RabbitMQ将会消耗掉越来越多的内存。
为了能够调试这种错误,你可以使用rabbitmqctl来打印出messages_unacknowledged字段。
$ sudo rabbitmqctl list_queues name messages_ready messages_unacknowledged
Listing queues ...
hello 0 0
...done.
4、消息的持久化
我们已经学习了在发生消费者挂掉或是任务被kill掉时的容错机制,下边将来看看当RabbitMQ服务被停止后,怎么保证消息不丢失。
当RabbitMQ退出或是宕机时会丢失队列和消息,当然有两个地方需要注意才能解决这类问题的发生:将队列和消息都持久化存储。
首先,我们要确保RabbitMQ永远不会丢失消息队列,那就需要声明它为持久化存储:
boolean durable = true;
channel.queueDeclare("hello", durable, false, false, null);
虽然这里的操作是正确的,但在这里依然不会生效,因为命名为“hello”的队列在之前已经被创建(非持久化),现在已经存在了。RabbitMQ不允许你重新定义一个已经存在的消息队列,如果你尝试着去修改它的某些属性的话,那么你的程序将会报错。所以,这里你需要更换一个消息队列名称:
boolean durable = true;
channel.queueDeclare("task_queue", durable, false, false, null);
生产者和消费者都需要使用queueDeclare方法来指定持久化属性。
现在我们可以确保即使RabbitMQ重启了,任务队列也不会丢失。下边我就来实现消息持久化(通过设置属性MessageProperties. PERSISTENT_TEXT_PLAIN,其中MessageProperties实现了BasicProperties接口)。
import com.rabbitmq.client.MessageProperties;
channel.basicPublish("", "task_queue",
MessageProperties.PERSISTENT_TEXT_PLAIN,
message.getBytes());
标记消息持久化并不能百分百的保证消息一定不会被丢失,虽然RabbitMQ会把消息写到磁盘上,但是从RabbitMQ接收到消息到写到磁盘上,这个短时间的过程中发生的RabbitMQ重启依然会使得为写入到磁盘的消息被丢失。事实上是这样的,RabbitMQ接收到消息后,首先会把该消息写到内存缓冲区中,并不是直接把单条消息实时写到磁盘上的。消息的持久化不是健壮的,但是对于简单的任务队列是够用了。如果你需要一套很健壮的持久化方案,那么你可以使用publisher confirms(稍后会更新详细的使用方法)。
5、公平的任务分发策略
你可能会注意到有的时候RabbitMQ不能像你预想中的那样分发消息。例如有两个worker,第奇数个消息对应的任务都很耗时,第偶数个消息对应的任务都很快就能执行完。这样的话其中有个worker就会一直都很繁忙,另外一个worker几乎不做任务。RabbitMQ不会去对这种现象做任何处理,依然均匀的去推送消息。
这是因为RabbitMQ在消息被生产者推送过来后就被推送到消费者端,它不会去查看未接收到消费者确认的消息数量。它只会把N个消息均与的分发到N个消费者那。
image.png为了能解决这个问题,我们可以使用basicQos放来来设置消费者最多会同时接收多少个消息。这里设置为1,表示RabbitMQ同一时间发给消费者的消息不超过一条。这样就能保证消费者在处理完某个任务,并发送确认信息后,RabbitMQ才会向它推送新的消息,在此之间若是有新的消息话,将会被推送到其它消费者,若所有的消费者都在处理任务,那么就会等待。
int prefetchCount = 1;
channel.basicQos(prefetchCount);
注意消息队列的大小:
如果所有的worker都处于较忙的状态下,你的消息队列有可能会太长(出现内存或磁盘瓶颈)。需要尽量多的关注这些信息,出现的时候可以适当的添加worker。
6、代码的最后实现
发送端:
import java.io.IOException;
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 java.io.IOException {
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();
}
//...
}
接收端:
import java.io.IOException;
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 java.io.IOException,
java.lang.InterruptedException {
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);
}
}
//...
}
使用消息应答机制和prefetchCount可以实现一个工作队列了。持久化的选项可以使任务即使队列和消息即使在RabbitMQ重启后,依然不会丢失。
关于Channel和MessageProperties的更多应用可以参考Java官方API文档:
http://www.rabbitmq.com/releases/rabbitmq-java-client/current-javadoc/
最后总结:
1、消费者端在信道上打开消息应答机制,并确保能返回接收消息的确认信息,这样可以保证消费者发生故障也不会丢失消息。
2、服务器端和客户端都要指定队列的持久化和消息的持久化,这样可以保证RabbitMQ重启,队列和消息也不会。
3、指定消费者接收的消息个数,避免出现消息均匀推送出现的资源不合理利用的问题。
参考链接:http://www.rabbitmq.com/tutorials/tutorial-two-java.html
网友评论