【RabbitMQ】3.工作队列及发布订阅

一、工作队列

(一个任务只发给一个消费者,根据设置,若消费者异常,才可转发给另一个消费者)

当有的消费者(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();
		}
	}
}

参考:

https://blog.csdn.net/anzhsoft2008/column/info/rabbitmq

https://blog.csdn.net/lmj623565791/article/list/7

猜你喜欢

转载自blog.csdn.net/jianmingxie/article/details/85620715
今日推荐