工作队列
在第一篇入门教程中我们写了一个简单程序通过命名队列的方式发送和接收消息,在这篇教程中,我们将学习创建一个工作队列(work queue),用于发送耗时的任务给多个工作者(worker)。
工作队列(或者叫任务队列,task queue)的主要思想在于避免那些资源密集型的任务占用大量资源和空间,其他任务需要一直等待它完成。我们可以安排稍后再进行这种任务,将任务封装成消息并且发送到队列。一个工作者(worker)会在后台运行,从队列中pop出任务并且执行该任务。当你的环境中运行多个worker的话,任务会被这些worker共同分享执行。
这种概念在web应用中非常有用,因为在web应用中不可能在一个短http请求窗口中处理太复杂的任务。
准备工作
在上一部分的教程中我们发送了一条包含 “Hello World” 的消息。现在我们会发送字符串代替复杂任务,因为我们没有现实生活的场景,所以我们可以使用Thread.sleep()方法来睡眠线程,模拟复杂任务的耗时操作。使用点(.)来表示任务的复杂度,每一个点都会模拟一秒钟的工作时间,例如Hello...就代表了一个耗时三秒的任务。
对之前的Send.java代码稍作调整,让生产者可以随意发送消息。这个程序会安排任务到消息队列,这次我们新建一个叫NewTask.java的文件。
String message = String.join(" ", args);
channel.basicPublish("", "hello", null, message.getBytes());
System.out.println(" [x] Sent '" + message + "'");
原来的Recv.java代码也需要做一些变化:消息体中每一个点都需要假装一秒的工作,新的代码既接收消息,又执行任务,所以我们新建一个类叫Worker.java。
DeliverCallback deliverCallback = (consumerTag, delivery) -> {
String message = new String(delivery.getBody(), "UTF-8");
System.out.println(" [x] Received '" + message + "'");
try {
doWork(message);
} finally {
System.out.println(" [x] Done");
}
};
boolean autoAck = true; // acknowledgment is covered below
channel.basicConsume(TASK_QUEUE_NAME, autoAck, deliverCallback, consumerTag -> { });
每次遇到消息体中的点,线程睡一秒模拟任务
private static void doWork(String task) throws InterruptedException {
for (char ch: task.toCharArray()) {
if (ch == '.') Thread.sleep(1000);
}
}
这里我们给出完整的NewTask.java和Worker.java的源码。
轮询调度
使用任务队列的一个好处就是可以非常轻松的并行工作,通过这种方式,如果我们积压了很多任务的话,可以简单的通过增加worker的数量来处理积压任务。
我们先来试试用两个worker工作。启动一个NewTask和两个Worker程序,模拟多个worker共同执行复杂任务的场景,可以使用命令行操作,如果你习惯用IDE的话也没问题,一般的IDE都有相应的功能,这里不再展开不同IDE的用法。
NewTask启动时添加参数message. message.. message... message.... message.....模拟五个复杂任务,应该可以看到如下的效果,两个worker分别处理了不同的message任务
NewTask发送消息到工作队列:
Worker1:
Worker2:
默认情况下,RabbitMQ会按顺序将消息发送给每个消费者,每个消费者都可以平均的得到相同数量的消息任务,这种方式我们称之为轮询,从图中结果可以看出worker1处理了1 3 5号消息,worker2处理了2 4号消息,你可以尝试更多的任务或者添加更多的worker观察结果。
消息确认
执行一个任务可能会用很长的时间,你也许会想,如果消费者执行一个耗时的任务但是执行了一部分服务器就挂掉了怎么办呢。在当前的代码中,一旦RabbitMQ给消费者发送了消息,该消息会被立刻标记删除。这样的话如果在任务执行期间把worker停掉的话这条消息就会丢失了,而且其他所有已经发送给这个消费者的消息也会一同丢失。这显然不是我们想要的结果,我们应该是希望如果哪个worker突然宕机了,那么可以把对应的消息发送给其他worker节点。
为了确保消息不会丢失,RabbitMQ支持消息确认功能。消费者会回调给RabbitMQ一个ack(acknowledge,确认回执),告诉中间件这个消息已经被成功接收并且处理,原消息可以删除了。
如果一个消费者因为各种原因(通道关闭,连接中断,tcp中断等)没有发送ack回来,那么RabbitMQ就会知道这条消息其实并没有被完全处理,那么就需要将这条消息再次放入队列中。如果此时有其他的消费者存在,那么这条消息就被发送过去,通过这样的方式来保证消息不会丢失,哪怕是有消费者节点偶尔出现意外情况。
RabbitMQ中没有消息超时,所以只会在消费者节点下线了才会重新发送消息,所以即使一个任务要执行非常非常久的时间,只要节点还在,消息就不会被重复消费。
消息确认默认是开启自动确认的,在之前的例子中我们是特意的通过autoAck = true使用自动ack确认,现在可以把这个值设置成false,这样就可以确保你的消息不会丢失。
消息持久化
我们已经学会了如何在消费者宕机的情况下保证任务不丢失。但是任务还是有可能丢失,那就是当RabbitMQ服务直接停止了的情况下,那么消息队列自然也就消失了。
当RabbitMQ退出或意外关闭的时候,服务器中的队列和消息都会丢失,除非你提前对此进行了设置。所以保证消息不丢失还有两点需要保证:一是队列,二是消息,两者都需要持久化。
首先是对于队列来说,保证RabbitMQ不丢失队列,需要声明成durable,注意这个更改需要写在生产者和消费者端
现在我们可以保证即使RabbitMQ重启消息队列也不会丢失。然后我们再来设置消息的持久化,通过设置MessageProperties.PERSISTENT_TEXT_PLAIN
公平分配
其实RabbitMQ的轮询分配还是存在一些问题,假如说某种业务场景下有两个worker,但是所有序号为奇数的消息都是非常耗时的重量级任务,而所有偶数的消息都是轻量级的,那么两个worker节点就显然失衡了,但是RabbitMQ本身并不能察觉到这一点,还是会按照老样子分配消息。
因为RabbitMQ是在消息进入队列后就进行分配,而并不会观察各个消费者节点还有多少未被消费的消息,所以还是继续盲目的继续分配消息。为了解决这个问题可以使用basicQsos方法,传参为1。这个方法是告诉RabbitMQ不要在同一时间给一个worker分配多余一个消息,或者换句话说,如果消费者节点还有正在处理的消息时就不要再分配新的消息了,而是继续往下轮询,分配给下一个节点。
在这一部分中我们学习了使用消息队列,以及简单介绍了消息队列的发消息机制。还解决了消息队列和消息在宕机情况下的丢失问题,并且还了解了如何让中间件本身实现类似于负载均衡的功能,进一步提高了RabbitMQ的数据安全性和可用性。