一、工作队列
(一个任务只发给一个消费者,根据设置,若消费者异常,才可转发给另一个消费者)
当有的消费者(Consumer)需要大量的运算时,RabbitMQ Server需要一定的分发机制来balance(平衡)每个Consumer(生产者)的load,即负载均衡。通过创建一个工作队列用来在consumer(生产者)间分发耗时任务。试想一下,对于web application来说,在一个很多的HTTP request里是没有时间来处理复杂的运算的,只能通过后台的一些工作线程来完成。
应用场景就是RabbitMQ Server会将queue的Message分发给不同的Consumer以处理计算密集型的任务:
工作队列的主要任务是:避免立刻执行资源密集型任务,然后必须等待其完成。相反地,我们进行任务调度:我们把任务封装为消息发送给队列。工作进行在后台运行并不断的从队列中取出任务然后执行。当你运行了多个工作进程时,任务队列中的任务将会被工作进程共享执行。
在现实应用中,Consumer有可能做的是一个图片的resize,或者是pdf文件的渲染或者内容提取。但是作为Demo,还是用字符串模拟吧:通过字符串中的.的数量来决定计算的复杂度,每个.都会消耗1s,即sleep(1)。
我们使用Thread.sleep来模拟耗时的任务。我们在发送到队列的消息的末尾添加一定数量的点,每个点代表在工作线程中需要耗时1秒,例如hello…将会需要等待3秒。
1.Round-robin dispatching 循环分发
RabbitMQ的分发机制非常适合扩展,而且它是专门为并发程序设计的。如果现在load加重,那么只需要创建更多的Consumer(消费者或工作者)来进行任务处理即可。当然了,对于负载还要加大怎么办?我没有遇到过这种情况,那就可以创建多个virtual Host,细化不同的通信类别了。
下面我们先运行3个工作者(Work.java)实例,然后运行NewTask.java,3个工作者实例都会得到信息。但是如何分配呢?让我们来看输出结果:[x] Sent 'helloworld.1'
[x] Sent 'helloworld..2'
[x] Sent 'helloworld...3'
[x] Sent 'helloworld....4'
[x] Sent 'helloworld.....5'
[x] Sent 'helloworld......6'
[x] Sent 'helloworld.......7'
[x] Sent 'helloworld........8'
[x] Sent 'helloworld.........9'
[x] Sent 'helloworld..........10'
工作者1:
605645 [*] Waiting for messages...
605645 [x] Received 'helloworld.1'
605645 [x] Done
605645 [x] Received 'helloworld....4'
605645 [x] Done
605645 [x] Received 'helloworld.......7'
605645 [x] Done
605645 [x] Received 'helloworld..........10'
605645 [x] Done
工作者2:
18019860 [*] Waiting for messages...
18019860 [x] Received 'helloworld..2'
18019860 [x] Done
18019860 [x] Received 'helloworld.....5'
18019860 [x] Done
18019860 [x] Received 'helloworld........8'
18019860 [x] Done
工作者3:
18019860 [*] Waiting for messages...
18019860 [x] Received 'helloworld...3'
18019860 [x] Done
18019860 [x] Received 'helloworld......6'
18019860 [x] Done
18019860 [x] Received 'helloworld.........9'
18019860 [x] Done
可以看到,默认的,RabbitMQ会一个一个的发送信息给下一个消费者(consumer),而不考虑每个任务的时长等等,且是一次性分配,并非一个一个分配。平均的每个消费者将会获得相等数量的消息。这样分发消息的方式叫做round-robin。
2.message acknowledgments 消息应答(确认)机制
执行一个任务需要花费几秒钟。你可能会担心当一个工作者在执行任务时发生中断。我们上面的代码,一旦RabbItMQ交付了一个信息给消费者,会马上从内存中移除这个信息。在这种情况下,如果杀死正在执行任务的某个工作者,我们会丢失它正在处理的信息。我们也会丢失已经转发给这个工作者且它还未执行的消息。
上面的例子,我们首先开启两个任务,然后执行发送任务的代码(NewTask.java),然后立即关闭第二个任务,结果为:
工作者2:
31054905 [*] Waiting for messages...
31054905 [x] Received 'helloworld..2'
31054905 [x] Done
31054905 [x] Received 'helloworld....4'
工作者1:
18019860 [*] Waiting for messages...
18019860 [x] Received 'helloworld.1'
18019860 [x] Done
18019860 [x] Received 'helloworld...3'
18019860 [x] Done
18019860 [x] Received 'helloworld.....5'
18019860 [x] Done
18019860 [x] Received 'helloworld.......7'
18019860 [x] Done
18019860 [x] Received 'helloworld.........9'
18019860 [x] Done
可以看到,第二个工作者至少丢失了6,8,10号任务,且4号任务未完成。
但是,我们不希望丢失任何任务(信息)。当某个工作者(接收者)被杀死时,我们希望将任务传递给另一个工作者。
为了保证消息永远不会丢失,RabbitMQ支持消息应答(message acknowledgments)。消费者发送应答给RabbitMQ,告诉它信息已经被接收和处理,然后RabbitMQ可以自由的进行信息删除。
如果消费者被杀死而没有发送应答,RabbitMQ会认为该信息没有被完全的处理,然后将会重新转发给别的消费者。通过这种方式,你可以确认信息不会被丢失,即使消者偶尔被杀死。
这种机制并没有超时时间这么一说,RabbitMQ只有在消费者连接断开时重新转发此信息。如果消费者处理一个信息需要耗费特别特别长的时间是允许的。
消息应答默认是打开的。上面的代码中我们通过显示的设置autoAsk=true关闭了这种机制。下面我们修改代码
boolean ack = false ; //打开应答机制
channel.basicConsume(QUEUE_NAME, ack, consumer);
//另外需要在每次处理完成一个消息后,手动发送一次应答。
channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
完整的代码为:
package com.bj.rabbitmq.study.second;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.QueueingConsumer;
public class Work {
//队列名称
private final static String QUEUE_NAME = "workqueue";
public static void main(String[] argv) throws java.io.IOException,
java.lang.InterruptedException
{
//区分不同工作进程的输出
int hashCode = Work.class.hashCode();
//创建连接和频道
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("localhost");
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
//声明队列
channel.queueDeclare(QUEUE_NAME, false, false, false, null);
System.out.println(hashCode
+ " [*] Waiting for messages...");
QueueingConsumer consumer = new QueueingConsumer(channel);
// 指定消费队列
boolean ack = false ; //打开应答机制
channel.basicConsume(QUEUE_NAME, ack, consumer);
while (true)
{
QueueingConsumer.Delivery delivery = consumer.nextDelivery();
String message = new String(delivery.getBody());
System.out.println(hashCode + " [x] Received '" + message + "'");
doWork(message);
System.out.println(hashCode + " [x] Done");
//发送应答
channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
}
}
/**
* 每个点耗时1s
* @param task
* @throws InterruptedException
*/
private static void doWork(String task) throws InterruptedException {
char[] taskarray = task.toCharArray();
for (char ch : taskarray){
if (ch == '.')
Thread.sleep(1000);
}
}
}
测试:
我们把消息数量改为5,然后先打开两个消费者(Work.java),然后发送任务(NewTask.java),立即关闭一个消费者,观察输出:
[x] Sent 'helloworld.1'
[x] Sent 'helloworld..2'
[x] Sent 'helloworld...3'
[x] Sent 'helloworld....4'
[x] Sent 'helloworld.....5'
工作者2
18019860 [*] Waiting for messages...
18019860 [x] Received 'helloworld..2'
18019860 [x] Done
18019860 [x] Received 'helloworld....4'
工作者1
31054905 [*] Waiting for messages...
31054905 [x] Received 'helloworld.1'
31054905 [x] Done
31054905 [x] Received 'helloworld...3'
31054905 [x] Done
31054905 [x] Received 'helloworld.....5'
31054905 [x] Done
31054905 [x] Received 'helloworld....4'
31054905 [x] Done
可以看到工作者2没有完成的任务4,重新转发给工作者1进行完成了。
如果忘记了ack,那么后果很严重。当Consumer退出时,Message会重新分发。然后RabbitMQ会占用越来越多的内存,由于RabbitMQ会长时间运行,因此这个“内存泄漏”是致命的。去调试这种错误,可以通过一下命令打印un-acked Messages。
3、 消息持久化(Message durability)
已经学习了即使消费者被杀死,消息也不会被丢失。但是如果此时RabbitMQ服务被停止,我们的消息仍然会丢失。
当RabbitMQ退出或者异常退出,将会丢失所有的队列和信息,除非你告诉它不要丢失。我们需要做两件事来确保信息不会被丢失:我们需要给所有的队列和消息设置持久化的标志。
第一, 我们需要确认RabbitMQ永远不会丢失我们的队列。为了这样,我们需要声明它为持久化的。
boolean durable = true;
channel.queueDeclare("task_queue", durable, false, false, null);
注:RabbitMQ不允许使用不同的参数重新定义一个队列,所以已经存在的队列,我们无法修改其属性。
第二, 我们需要标识我们的信息为持久化的。通过设置MessageProperties(implements BasicProperties)值为PERSISTENT_TEXT_PLAIN。
channel.basicPublish("", "task_queue",MessageProperties.PERSISTENT_TEXT_PLAIN,message.getBytes());
现在你可以执行一个发送消息的程序,然后关闭服务,再重新启动服务,运行消费者程序做下实验。
4、公平转发(Fair dispatch)
或许会发现,目前的消息转发机制(Round-robin)并非是我们想要的。例如,这样一种情况,对于两个消费者,有一系列的任务,奇数任务特别耗时,而偶数任务却很轻松,这样造成一个消费者一直繁忙,另一个消费者却很快执行完任务后等待。
造成这样的原因是因为RabbitMQ仅仅是当消息到达队列进行转发消息。并不在乎有多少任务消费者并未传递一个应答给RabbitMQ。仅仅盲目转发所有的奇数给一个消费者,偶数给另一个消费者。
为了解决这样的问题,我们可以使用basicQos方法,传递参数为prefetchCount = 1。这样告诉RabbitMQ不要在同一时间给一个消费者超过一条消息。换句话说,只有在消费者空闲的时候会发送下一条信息。
channel.basic_qos(prefetch_count=1)
注:如果所有的工作者都处于繁忙状态,你的队列有可能被填充满。你可能会观察队列的使用情况,然后增加工作者,或者使用别的什么策略。
生产者:
package com.bj.rabbitmq.study.second;
import java.io.IOException;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
public class NewsTask {
//队列名称
public final static String QUEUE_NAME = "workqueue";
public static void main(String[] args) throws IOException {
//创建连接 连接到rabbitmq消息队列
ConnectionFactory cf = new ConnectionFactory();
//设置RabbitMQ所在主机ip或者主机名
cf.setHost("localhost");
//创建一个连接
Connection c = cf.newConnection();
//创建一个频道
Channel ch = c.createChannel();
//指定一个队列
//设置队列持久化
boolean durable = true;// 1、设置队列持久化
ch.queueDeclare(QUEUE_NAME, durable, false, false, null);
//往队列中发布10调消息,依次在其后增加1至10个点
for (int i = 0; i < 10; i++)
{
String dots = "";
for (int j = 0; j <= i; j++)
{
dots += ".";
}
String message = "helloworld" + dots+dots.length();
//设置消息持久化MessageProperties
ch.basicPublish("", QUEUE_NAME, MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes());
System.out.println(" [x] Sent '" + message + "'");
}
ch.close();
c.close();
}
}
消费者:
package com.bj.rabbitmq.study.second;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.QueueingConsumer;
public class Work {
//队列名称
private final static String QUEUE_NAME = "workqueue";
public static void main(String[] argv) throws java.io.IOException,
java.lang.InterruptedException{
//区分不同工作进程的输出
int hashCode = Work.class.hashCode();
//创建连接和频道
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("localhost");
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
//声明队列
boolean durable = true;
channel.queueDeclare(QUEUE_NAME, durable, false, false, null);
System.out.println(hashCode
+ " [*] Waiting for messages...");
//设置最大服务转发消息数量
int prefetchCount = 1;
channel.basicQos(prefetchCount);
QueueingConsumer consumer = new QueueingConsumer(channel);
// 指定消费队列
boolean ack = false; // 打开应答机制
channel.basicConsume(QUEUE_NAME, ack, consumer);
while (true){
QueueingConsumer.Delivery delivery = consumer.nextDelivery();
String message = new String(delivery.getBody());
System.out.println(hashCode + " [x] Received '" + message + "'");
doWork(message);
System.out.println(hashCode + " [x] Done");
}
}
/**
* 每个点耗时1s
* @param task
* @throws InterruptedException
*/
private static void doWork(String task) throws InterruptedException {
char[] taskarray = task.toCharArray();
for (char ch : taskarray){
if (ch == '.')
Thread.sleep(1000);
}
}
}
二、发布/订阅
把一个消息发给多个消费者,这种模式称之为发布/订阅(类似观察者模式)
通过创建一个日志系统,来验证这种模式。它包含两个部分:第一个部分是发出log(Producer),第二个部分接收到并打印(Consumer)。 我们将构建两个Consumer,第一个将log写到物理磁盘上;第二个将log输出的屏幕。
就是发布的日志消息会转发给所有的接收者。
1.Exchanges(交换器/转发器)
之前主要介绍的都是发送者发送消息给队列,接收者从队列接收消息,现在我们引入exchanges,展示RabbitMQ的完整的消息模型。
RabbitMQ消息模型的核心理念是生产者永远不会直接发送任何消息给队列,一般的情况生产者甚至不知道消息应该发送到哪些队列。相反的,生产者只能发送消息给转发器(Exchange)。
转发器是非常简单的,一边接收从生产者发来的消息,另一边把消息推送到队列中。转发器必须清楚的知道消息如何处理它收到的每一条消息。是否应该追加到一个指定的队列?是否应该追加到多个队列?或者是否应该丢弃?这些规则通过转发器的类型进行定义。
转发器的类型:Direct、Topic、Headers、Fanout(广播模式)
我们示例通过fanout来实现。channel.exchangeDeclare("logs","fanout");
fanout类型转发器特别简单,把所有它介绍到的消息,广播到所有它所知道的队列。不过这正是我们前述的日志系统所需要的
2、匿名转发器(nameless exchange)
之前讲解中并没有使用到转发器,我们仍可以发送和接收消息,是因为使用了一个默认的转发器,它的标识符为””。之前发送消息的代码:channel.basicPublish("", QUEUE_NAME,MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes());
其中第一个参数即为转发器的名称,第二个参数为routingKey,若存在routingKey,则由其决定将消息发送到哪个队列。
3、临时队列(Temporary queues)
截至现在,我们用的queue都是有名字的,都为队列指定了一个特定的名称。能够为队列命名对我们来说是很关键的,我们需要指定消费者为某个队列。当我们希望在生产者和消费者间共享队列时,为队列命名是很重要的。
不过,对于我们的日志系统我们并不关心队列的名称。我们想要接收到所有的log消息,而且我们也只对当前正在传递的数据感兴趣。为了满足我们的需求,需要做两件事:
第一, 无论什么时间Consumer连接到RabbitMQ Server时,我们都需要一个新的空的队列。为了实现,我们可以使用随机数创建队列,或者更好的,让服务器给我们提供一个随机的名称。
Java中我们可以使用queueDeclare()方法,不传递任何参数,来创建一个非持久的、唯一的、自动删除的队列且队列名称由服务器随机产生。
String queueName = channel.queueDeclare().getQueue();
第二, 一旦消费者与RabbitMQ Server断开,消费者所接收的那个队列应该被自动删除。
String queueName = channel.queueDeclare().getQueue();
一般情况这个名称与amq.gen-JzTY20BRgKO-HjmUJj0wLg 类似。
4、绑定(Bindings)
我们已经创建了一个fanout转发器和队列,我们现在需要通过binding告诉转发器把消息发送给我们的队列。
channel.queueBind(queueName, “logs”, ””)参数1:队列名称 ;参数2:转发器名称
5、完整的例子
日志发送端
与之前的例子不同之处在于publish通过了exchange而不是routing_key。即声明队列的代码,改为声明转发器了,同样的消息的传递也交给了转发器。若没有队列绑定该转发器,则该日志信息将会被抛弃。
package com.bj.rabbitmq.study.third;
import java.io.IOException;
import java.util.Date;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
public class LogTask {
//转发器
public final static String EXCHANGE_NAME = "workqueue";
public static void main(String[] args) throws IOException {
//创建连接 连接到rabbitmq消息队列
ConnectionFactory cf = new ConnectionFactory();
//设置RabbitMQ所在主机ip或者主机名
cf.setHost("localhost");
//创建一个连接
Connection c = cf.newConnection();
//创建一个频道
Channel ch = c.createChannel();
// 声明转发器和类型
ch.exchangeDeclare(EXCHANGE_NAME, "fanout" );
String message = new Date().toLocaleString()+" : log something";
// 往转发器上发送消息
ch.basicPublish(EXCHANGE_NAME, "", null, message.getBytes());
ch.close();
c.close();
}
}
接收端
随机创建一个队列,然后将队列与转发器绑定,然后将消费者与该队列绑定。
接收端1输出到控制台
package com.bj.rabbitmq.study.third;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.QueueingConsumer;
public class LogWorkConsole {
//转发器
public final static String EXCHANGE_NAME = "workqueue";
public static void main(String[] argv) throws Exception {
//区分不同工作进程的输出
int hashCode = LogWorkConsole.class.hashCode();
//创建连接和频道
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("localhost");
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
channel.exchangeDeclare(EXCHANGE_NAME, "fanout");
// 创建一个非持久的、唯一的且自动删除的队列
String queueName = channel.queueDeclare().getQueue();
// 为转发器指定队列,设置binding
channel.queueBind(queueName, EXCHANGE_NAME, "");
System.out.println(hashCode + " [*] Waiting for messages...");
QueueingConsumer consumer = new QueueingConsumer(channel);
// 指定消费队列,第二个参数为自动应答,无需手动应答
channel.basicConsume(queueName, true, consumer);
while (true) {
QueueingConsumer.Delivery delivery = consumer.nextDelivery();
String message = new String(delivery.getBody());
System.out.println(hashCode + " [x] Received '" + message + "'");
}
}
}
接收端2 保存到文件
package com.bj.rabbitmq.study.third;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.QueueingConsumer;
public class LogWorkFile {
//转发器
public final static String EXCHANGE_NAME = "workqueue";
public static void main(String[] argv) throws Exception {
//区分不同工作进程的输出
int hashCode = LogWorkFile.class.hashCode();
//创建连接和频道
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("localhost");
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
channel.exchangeDeclare(EXCHANGE_NAME, "fanout");
// 创建一个非持久的、唯一的且自动删除的队列
String queueName = channel.queueDeclare().getQueue();
// 为转发器指定队列,设置binding
channel.queueBind(queueName, EXCHANGE_NAME, "");
System.out.println(hashCode + " [*] Waiting for messages...");
QueueingConsumer consumer = new QueueingConsumer(channel);
// 指定消费队列,第二个参数为自动应答,无需手动应答
channel.basicConsume(queueName, true, consumer);
while (true) {
QueueingConsumer.Delivery delivery = consumer.nextDelivery();
String message = new String(delivery.getBody());
print2File(message);
}
}
private static void print2File(String msg) {
try {
String dir = LogWorkFile.class.getClassLoader().getResource("").getPath();
String logFileName = new SimpleDateFormat("yyyy-MM-dd")
.format(new Date());
File file = new File(dir, logFileName+".txt");
FileOutputStream fos = new FileOutputStream(file, true);
fos.write((msg + "\r\n").getBytes());
fos.flush();
fos.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
参考: