在《RabbitMQ之Hello World》这篇文章中我们创建了从名称队列中发送/接收消息的程序。在这篇文章中我们将创建在多个工作者之间分发耗时任务的工作队列。
准备
这想法主要是避免立即执行资源密集型任务且必需等待任务完成。
我们将任务封装把它当成消息并发送到队列中。一个运行在后台的工作者将取出任务并最终执行这个任务,当有多个工作者同时运行时,这些任务将在它们之间共享。
现在,我们利用Thread.sleep()模拟一个耗时的任务。一个小圆点代表工作一秒。例如:“work.“ 代表将花费一秒
我们将上一篇文章中的Send.java 稍微调整,使被发送的消息从命令行中获取。程序将把任务放到工作队列中,所以我们将这段程序重新命名为NewTask.java
String message = getMessage(args);
channel.basicPublish("", QUEUE_NAME, 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
final Consumer consumer = new DefaultConsumer(channel) {
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
String message = new String(body, "UTF-8");
System.out.println(" [x] Received '" + message + "'");
try {
doWork(message);
} finally {
System.out.println(" [x] Done");
}
}
};
boolean autoAsk = true;
channel.basicConsume(TASK_QUEUE_NAME, autoAsk, consumer);
模拟处理任务需要消耗的时间
private static void doWork(String task) throws InterruptedException {
for (char ch: task.toCharArray()) {
if (ch == '.') Thread.sleep(1000);
}
}
修改完这两个文件后按照《RabbitMQ之Hello World》中介绍的方法进行编译
javac -cp amqp-client-4.0.2.jar NewTask.java Worker.java
为了减少每次执行时都要带一大堆的依赖jar,我们可以改用如下方式
首先在cmd命令行中执行
set CP=.;amqp-client-4.0.2.jar;slf4j-api-1.7.21.jar;slf4j-simple-1.7.22.jar
然后在需要引用这些jar的地方使用
javac -cp %CP% Send.java Recv.java
java -cp %CP% NewTask
代替
javac -cp amqp-client-4.0.2.jar Send.java Recv.java
java -cp .;amqp-client-4.0.2.jar;slf4j-api-1.7.21.jar;slf4j-simple-1.7.22.jar NewTask
打开两个cmd命令窗口,分别运行Worker这个类
java -cp %CP% Worker
然后再打开一个cmd命令窗口,运行NewTask这个类,利用它我们将发布新的任务
E:\RabbitMQ>java -cp %CP% NewTask First message.
[x] Sent'First message.'
E:\RabbitMQ>java -cp %CP% NewTask Second message..
[x] Sent'Second message..'
E:\RabbitMQ>java -cp %CP% NewTask Third message...
[x] Sent'Third message...'
E:\RabbitMQ>java -cp %CP% NewTask Fourth message....
[x] Sent'Fourth message....'
E:\RabbitMQ>java -cp %CP% NewTask Fifth message.....
[x] Sent'Fifth message.....'
最后在前两个命令窗口中可以看到我们发布的消息(任务)
RabbiteMQ默认将每一个消息按顺序发送到下一个工作者,平均每一个工作者将得到相同数量的消息,这种分法消息的方法叫做round-robin。
利用上面的代码,发送一个消息到工作者并将这个消费者删掉(ctrl+c),在这种情况下我们将失去正在执行的消息,同时我们也将失去已经分发到这个工作者但还没有处理的消息。而我们并不想失去任何消息,如果一个工作者被杀掉,我们希望任务能够被分发到其它的工作者。
为了确保消息不丢失,RabbitMQ支持message acknowledgments,一个ack由工作者返回给RabbitMQ,告诉它指定的消息已经被接收、执行,RabbitMQ可以随意删除这个消息。
如果一个工作者被杀掉(它的channel 被关闭、connection被关闭或TCP连接被关闭)而没有返回ack,RabbitMQ将认为这个消息没有被完全处理则将它重新放入队列中。如果此时有其它工作者在线,它将被快速的分发到其它的工作者。这种方法在工作者偶尔被杀掉的情况下你也能确保消息不会丢失。
RabbitMQ默认message acknowledgments是打开的,在前面的例子中我们利用autoAck=true显示的关掉了message acknowledgments,接下来将它打开,并通过工作者返回一个合适的acknowledgments
channel.basicQos(1); // accept only one unack-ed message at a time (see below)
final Consumer consumer = new DefaultConsumer(channel) {
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
String message = new String(body, "UTF-8");
System.out.println(" [x] Received '" + message + "'");
try {
doWork(message);
} finally {
System.out.println(" [x] Done");
channel.basicAck(envelope.getDeliveryTag(), false);
}
}
};
boolean autoAck = false;
channel.basicConsume(TASK_QUEUE_NAME, autoAck, consumer);
利用这段代码我们能确保当一个工作者在处理消息时如果你利用ctrl+c杀掉了这个工作中,也不会有任何消息丢失,这个工作者所有未返回acknowledgments的消息都将被重新分发
关于autoAck参数的理解
个人认为autoAck是用来控制工作者是否自动发送acknowledgments给RabbitMQ,当我们将该参数的值设置为false时,则需要工作者手动返回acknowledgments,所以在handleDelivery()中需要加上
channel.basicAck(envelope.getDeliveryTag(), false);
消息持久化
我们已经学习了如何在工作者被杀掉后,对应的任务不会丢失,但当RabbitMQ服务停止时,我们仍将丢失一些任务
为了确保消息不会丢失有两件事是必须的:我们需要将队列和消息持久化
首先,我们要确保RabbitMQ从不丢失我们的队列,为了实现这个目的,我们需要将它声明为持久化的
boolean durable = true;
channel.queueDeclare(QUEUE_NAME, durable, false, false, null);
虽然这个命令已经被调整了,但在我们现在的设置中它仍无法工作。这是因为我们已经定义了一个未被声明为持久化的队列“hello“,RabbitMQ不允许你利用不同的参数去引用一个已经存在的队列,当尝试这么做的时候,在任何一个程序中它都将返回一个error对象。这里有一个快速变通的方案-利用不同的名称定义一个队列,例如:task_queue
private final static String QUEUE_NAME = "task_queue";
队列声明的改变需要同时应用到生产者和消费者的代码中
通过上面的调整我们确保了即使RabbitMQ服务重新启动也不会导致task_queue队列丢失,现在我们需要通过设置MessageProperties
的PERSISTENT_TEXT_PLAIN
值确保我们的消息也是持久化的。
import com.rabbitmq.client.MessageProperties;
channel.basicPublish("", QUEUE_NAME,
MessageProperties.PERSISTENT_TEXT_PLAIN,
message.getBytes());
公平分发
你可能注意到这任务的分发仍未达到我们的预期。例如,在有两个工作者的场景下,当奇数消息是耗时的而偶数消息是容易处理的,这样,一个工作者将总是繁忙的,而另一个工作者将几乎没有什么工作。由于RabbitMQ不知道这种情况所以仍将均匀的分发消息。
这种情况发生的原因是当有消息进入队列时RabbitMQ仅分发消息,它没有考虑这个工作者没有返回ack的消息数量,盲目的分发消息到对应的工作者(n-th message to n-th consumer)
为了解决这种情况,我们能够利用basicQos()
设置prefetchCount=1
,这告诉RabbitMQ在同一时间不要将超过一条的消息给到同一个工作者。换句话说,不分发新的信息到工作者直到它完成了处理并返回前一个消息的ack,取而代之的是,新消息将被分发到下一个不是繁忙的工作者。
int prefetchCount = 1;
channel.basicQos(prefetchCount);
调整后最终的代码
NewTask.java
import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.Channel;
public class NewTask {
private final static String QUEUE_NAME = "task_queue";
public static void main(String[] args) throws java.io.IOException,java.util.concurrent.TimeoutException{
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("localhost");
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
//队列持久化,发送者、接收者 都需要调整
channel.queueDeclare(QUEUE_NAME, true, false, false, null);
String message = getMessage(args);
//消息持久化
channel.basicPublish("",QUEUE_NAME, MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes());
System.out.println("[x] Sent'"+message+"'");
channel.close();
connection.close();
}
private static String getMessage(String[] args) {
if(args.length < 1) {
return "Hello World!";
}
return joinString(args," ");
}
private static String joinString(String[] args, String delimiter) {
int len = args.length;
if(len==0) return "";
StringBuilder sbd = new StringBuilder(args[0]);
for(int i=1; i<len; i++) {
sbd.append(delimiter).append(args[i]);
}
return sbd.toString();
}
}
Worker.java
import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.Connection;
import java.io.IOException;
import java.lang.InterruptedException;
import com.rabbitmq.client.AMQP.BasicProperties;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Consumer;
import com.rabbitmq.client.DefaultConsumer;
import com.rabbitmq.client.Envelope;
public class Worker {
private final static String QUEUE_NAME = "task_queue";
public static void main(String[] args)
throws java.io.IOException,
java.util.concurrent.TimeoutException,
java.lang.InterruptedException {
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("localhost");
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
//队列持久化,发送者、接收者都需要调整
channel.queueDeclare(QUEUE_NAME, true, false, false, null);
System.out.println(" [*] Waiting for messages. To exit press CTRL+C");
//每次只发送一个消息到消费者
channel.basicQos(1);
Consumer consumer = new DefaultConsumer(channel) {
@Override
public void handleDelivery(String consumerTag, Envelope envelope, BasicProperties properties, byte[] body)
throws IOException {
String message = new String(body,"UTF-8");
System.out.println(" [x] Received '" + message + "'");
try {
doWork(message);
}catch(InterruptedException e) {
e.printStackTrace();
}
finally {
System.out.println("[x] Done");
//手动应答
channel.basicAck(envelope.getDeliveryTag(),false);
}
}
};
//关闭自动应答
boolean autoAsk = false;
channel.basicConsume(QUEUE_NAME, autoAsk, consumer);
}
private static void doWork(String task)
throws InterruptedException{
for (char ch: task.toCharArray()) {
if (ch=='.') Thread.sleep(1000);
}
}
}
利用消息ack和prefetchCount你能设置一个工作队列,持久化选项能够让任务幸存即使RabbitMQ是被重新启动