RabbitMQ 教程 2.工作队列(Work Queue)

382 阅读9分钟

2 工作队列(Work Queue)


第一篇教程中,我们写了两个程序用来从指定的 queue 中发送和接收消息。这篇教程,我们将创建一个工作队列,用来给多个 worker 分发一些"耗时的"任务。

工作队列(或者称之为任务队列)背后的思想,是用来避免立即处理那些很耗资源并且需要等待其运行结束的任务(课代表注:说白了就是削峰)。取而代之的是,将任务安排到稍后进行(课代表注:说白了就是异步执行)。一个后台运行的工作程序将会接收到并执行该任务。当你运行了多个工作程序,工作队列中的任务将会被他们共同分担处理。

这个思想在web应用中非常有用,因为在web应用中,通过一个短的http请求窗口无法处理复杂的任务。

准备工作(Preparation)

在前面的教程中,我们发送了一个字符串消息:“"Hello World!”。接下来我们发送一些用来代表任务很复杂的字符串。我们并没有真实世界中那些像图片缩放,PDF文件渲染之类的复杂任务,所以,让我们使用Thread.sleep()方法来假装很忙。用字符串中点号的个数当做任务的复杂度:每个点号代表一秒钟的“工作”。例如:由字符串Hello...代表的任务将耗时3秒钟。

将前面例子中Send.java的代码稍微改变一下,使其允许任意消息从终端输入。该应用会将任务安排到我们的工作队列,所以给它命名为:NewTask.java

String message = String.join(" ", argv);

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);
    }
}

像教程1中那样编译一下(确保需要的jar包都在工作目录中,并且设置了环境变量:CP):

javac -cp $CP NewTask.java Worker.java

Windows下自行将 $CP 替换为 %CP%,下同。——课代表注

轮询分发(Round-robin dispatching)

使用任务队列的优势之一是方便横向扩展。假设任务积压了,我们可以增加更多的 worker 程序,轻松扩展。

首先,让我们同时运行两个 worker 实例。他们都将从队列中获取消息,但具体是怎样运转的呢?我们一起探究一下。

你需要打开三个终端。两个用来运行worker程序。这两个将会是消费者——C1和C2

# shell 1
java -cp $CP Worker
# => [*] Waiting for messages. To exit press CTRL+C
# shell 2
java -cp $CP Worker
# => [*] Waiting for messages. To exit press CTRL+C

第三个终端用来发布新任务。当消费者启动之后,可以发送几个消息:

# shell 3
java -cp $CP NewTask First message.
# => [x] Sent 'First message.'
java -cp $CP NewTask Second message..
# => [x] Sent 'Second message..'
java -cp $CP NewTask Third message...
# => [x] Sent 'Third message...'
java -cp $CP NewTask Fourth message....
# => [x] Sent 'Fourth message....'
java -cp $CP NewTask Fifth message.....
# => [x] Sent 'Fifth message.....'

让我们看一看运行 worker 的终端打印了什么:

java -cp $CP Worker
# => [*] Waiting for messages. To exit press CTRL+C
# => [x] Received 'First message.'
# => [x] Received 'Third message...'
# => [x] Received 'Fifth message.....'
java -cp $CP Worker
# => [*] Waiting for messages. To exit press CTRL+C
# => [x] Received 'Second message..'
# => [x] Received 'Fourth message....'

默认情况下,RabbitMQ 会将每个消息按顺序发送给下一个消费者。每个消费者都会被平均分配到相同数量的消息。这种消息分发机制称为轮询

可以多运行几个 worker 实例自行尝试。

消息确认(Message acknowledgment)

执行任务可能需要一段时间。你有没有想过,如果任务还没执行完,应用挂掉了怎么办?以我们目前的代码,一旦 RabbitMQ 将消息分发给了消费者,它会立刻将该消息标记为已删除。如此看来,一旦终止 worker 程序,就会丢失它正在处理的消息,以及它已经接收,但还没开始处理的消息。

但我们并不希望丢失任务。如果一个 worker 应用挂掉了,我们希望他所处理的任务能交给给别的 worker 处理。

为了确保消息不会丢失,RabbitMQ 提供消息确认机制。消息确认由消费者发回,告诉 RabbitMQ 某个指定的消息已经被接收、处理,并且 RabbitMQ 可以删掉该消息了。

如果某个消费者没有返回确认(ack) 就挂掉了(channel 关闭,链接关闭或者TCP连接丢失了),RabbitMQ 将会认为该消息没有被正确处理,会将其重新入队(re-queue)。如果此时有其他消费者在线,RabbitMQ 会迅速将该消息发送给他们。这样就可以保证,即使 worker 突然挂了,消息也不会丢失。

消息不会超时:RabbitMQ 将会在某个消费者挂掉时重新发送该消息。即使处理一条消息需要花费很长时间也无所谓。

手工消息确认 默认开启。在前面的示例中我们通过设置autoAck=true将其关闭了。现在我们将标志位设为false,并让worker 在工作完成时发送确认信息。

channel.basicQos(1); // accept only one unack-ed message at a time (see below)

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");
    channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
  }
};
boolean autoAck = false;
channel.basicConsume(TASK_QUEUE_NAME, autoAck, deliverCallback, consumerTag -> { });

上面的代码可以确保即使你使用 CTRL+C 停止一个正在处理消息的worker,也不会丢失任何消息。worker 挂掉后未被确认的消息将会很快被重新投递。

确认消息的发送必须和接收消息时的 channel 相同。尝试使用不同的 channel 返回确认将会报 channel 协议异常。具体参见确认机制的参考文档

忘记确认

一个常见的错误就是忘记调用basicAck。这个简单错误,将会导致严重后果。当你的程序处理完消息,却忘记发送确认,消息将会被重新投递,RabbitMQ 因为无法删除未被确认的消息,导致内存占用越来越多。

为了方便排查此类问题,可以使用 rabbitmqctl 工具打印 messages_unacknowledged 字段:

sudo rabbitmqctl list_queues name messages_ready messages_unacknowledged

Windows下去掉 sudo :

rabbitmqctl.bat list_queues name messages_ready messages_unacknowledged

消息持久化(Message durability)

我们已经学习了如何在消费者挂掉的情况下保证任务不丢失。但是,如果 RabbitMQ 服务停止了,任务还是会丢。

如果没有经过配置,当 RabbitMQ 停止或崩溃时,它将会丢失 队列(queue) 中已有的消息。为了避免这种情况,我们需要将队列(queue) 和消息(message) 都设置为持久化(durable)

boolean durable = true;
channel.queueDeclare("hello", durable, false, false, null);

尽管上面的命令是对的,但目前还不能正确工作。因为我们已经在 RabbitMQ 中声明了一个名为“hello”的非持久化队列。RabbitMQ 无法修改已存在队列的参数。我们可以换个思路,命名一个新的,开启持久化的队列,比如task_queue

boolean durable = true;
channel.queueDeclare("task_queue", durable, false, false, null);

持久化参数为truequeueDeclare 方法需要在生产者和消费者代码中都加上。

此时,我们可以确定,即使 RabbitMQ 重启,task_queue 这个队列也不会丢。接下来我们通过将MessageProperties 的值设置为PERSISTENT_TEXT_PLAIN,从而将消息设置为持久化。

import com.rabbitmq.client.MessageProperties;

channel.basicPublish("", "task_queue",
            MessageProperties.PERSISTENT_TEXT_PLAIN,
            message.getBytes());

消息持久化的注意事项

将消息标记为持久化并不能完全保证消息不丢失。尽管告诉了RabbitMQ将消息保存到磁盘,仍然存在一段小的窗口期RabbitMQ接收了消息但还没来得及保存。此外,RabbitMQ不会对每条消息都执行 fsync(2) —— 它可能刚刚被写入缓存,还没真正写到磁盘上。持久化机制并不健壮,但对于task 来说队列足够了。如果需要更可靠的持久化,你需要使用 publisher confirms

公平分发(Fair dispatch)

轮询分发有时候并不能满足我们的需要。比如在只有两个 worker 的场景下,序号为奇数的消息涉及大量运算,而序号为偶数的消息都很简单。RabbitMQ 并不知道消息的难易程度,他只会均匀分发给两个 worker。

出现这种情况是因为,RabbitMQ 只负责将队列中收到的消息分发出去,他并不关心消费者未确认的消息数量。它只是盲目地将第N的消息发给第N个消费者。

为了解决这个问题,我们可以调用 basicQos方法,将它的参数 prefetchCount 设置为 1。这将告诉 RabbitMQ 同一时间内给 worker 的消息数量不要超过 1。换句话说,在 worker 没有返回确认之前,不要给他分发新消息。这样一来,RabbitMQ 会将消息发送给其他不忙的 worker。

int prefetchCount = 1;
channel.basicQos(prefetchCount);

关于队列大小

如果所有 worker 都很忙,队列有可能被塞满。你需要实时监控他的大小,或者增加 worker 的数量,或者采用其他策略(课代表注:比如控制生产者和消费者的比例)

代码整合(Putting it all together)

最终的 NewTask.java 代码如下:

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.MessageProperties;

public class NewTask {

  private static final String TASK_QUEUE_NAME = "task_queue";

  public static void main(String[] argv) throws Exception {
    ConnectionFactory factory = new ConnectionFactory();
    factory.setHost("localhost");
    try (Connection connection = factory.newConnection();
         Channel channel = connection.createChannel()) {
        channel.queueDeclare(TASK_QUEUE_NAME, true, false, false, null);

        String message = String.join(" ", argv);

        channel.basicPublish("", TASK_QUEUE_NAME,
                MessageProperties.PERSISTENT_TEXT_PLAIN,
                message.getBytes("UTF-8"));
        System.out.println(" [x] Sent '" + message + "'");
    }
  }

}

(NewTask.java 源文件)

Worker.java:

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.DeliverCallback;

public class Worker {

  private static final String TASK_QUEUE_NAME = "task_queue";

  public static void main(String[] argv) throws Exception {
    ConnectionFactory factory = new ConnectionFactory();
    factory.setHost("localhost");
    final Connection connection = factory.newConnection();
    final 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);

    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");
            channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
        }
    };
    channel.basicConsume(TASK_QUEUE_NAME, false, deliverCallback, consumerTag -> { });
  }

  private static void doWork(String task) {
    for (char ch : task.toCharArray()) {
        if (ch == '.') {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException _ignored) {
                Thread.currentThread().interrupt();
            }
        }
    }
  }
}

(Worker.java 源文件)

使用消息确认并设置prefetchCount参数建立的工作队列。其持久化设置可以让消息在 RabbitMQ 重启后依然存在。

更多关于 ChannelMessageProperties 的内容,请访问:JavaDocs online.

接下来我们进入教程3,学习如何将同一个消息发送给多个消费者。


往期干货推荐

下载的附件名总乱码?你该去读一下 RFC 文档了!

深入浅出 MySQL 优先队列(你一定会踩到的order by limit 问题)

Freemarker 教程(一)-模板开发手册


码字不易,欢迎点赞关注和分享。 搜索:【Java课代表】,关注公众号。每日一更,及时获取更多Java干货。

公众号推荐.png