RabbitMQ第二篇--工作队列(任务队列)

原创
2016/12/14 22:23
阅读数 354

1.在上一节中,我们写了一个通过命名队列发送和消费消息的程序,本节中我们将创建一个工作队列。用于在多个工作者之间分配耗时的任务.工作队列又名任务队列,它的主要思想是避免将任务分配给正在执行复杂任务的消费者.如果已有的消费者正在执行某些复杂而耗时的任务时,那么新的消息任务将被堆积在队列中,并等待现有队列空闲时才会把消息发送给消费者.
你可以启用多个消费者同时来处理复杂任务.这些任务将在多个消费者之间共享.以上来自官网的翻译,但是个人感觉最后一句话换成:这些任务将会平均分配给多个消费者会更适合.后面的例子你会明白为什么为什么我这样说.

2.准备工作:
在上一节的例子中,我们发送了一个包含"hello rabbitmq"的消息.本节我们的实例将发送代表复杂耗时任务
的字符串消息.因为我们并没有真实的复杂业务场景,所以在此处通过字符串模拟复杂任务.我们将通过命令行参数发送带点(.)的字符串,其中一个点代表这个任务将耗时一秒.这个功能我们通过Thread.sleep()函数来实现。比如我们通过命令行参数发送消息message...,则代表此任务将会耗时三秒。

3.修改上一节中的Send.java代码中发送的消息内容:

/发送的消息体
String message = getMessage(args);
//接受命令行参数
public static String getMessage(String[] args){
    if(args.length < 1){
        return "Hello, World!";
    }
    return joinStrings(args, " ");
}
//组装命令行参数
public static String joinStrings(String[] args, String delimiter){
    int length = args.length;
    if(length == 0){
        return "";
    }
    StringBuilder word = new StringBuilder(args[0]);
    for(int i = 1; i < length; i ++){
        word.append(delimiter).append(args[i]);
    }
    return word.toString();
}

两个很简单的方法,用户从命令行获取参数,然后用指定分隔符拼接成字符串.

接下来修改Recv.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);
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            System.out.println(" [x] Done");
        }
    }
};
boolean autoAck = true;
channel.basicConsume(QUEUE_NAME, autoAck, consumer);
public static void doWork(String task) throws InterruptedException{
    for(char ch : task.toCharArray()){
        if(ch == '.'){
            Thread.sleep(1000);
        }
    }
}

其中doWork方法用来根据.的数量来模拟复杂任务,每个点程序休眠1秒.

autoAck稍后会说明它的用途。

此时代码的修改已完成,为了和之前的分开,我写了另外两个Java文件。

完整的发送和接收代码如下:

NewTask.java(消息生产者)

public class NewTask {
    private static final String QUEUE_NAME = "worker_queue";

    public static void main(String[] args) throws Exception{
        //创建连接工厂,设置连接地址,用户名,密码
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("localhost");
        factory.setUsername("admin");
        factory.setPassword("admin");
        //创建连接
        Connection connection = factory.newConnection();
        //通过连接创建信道,信道的创建和销毁代价比连接要小的多
        Channel channel = connection.createChannel();
        //设置队列是否持久化,默认设置为false.不开启持久化
        boolean durable = false;
        //声明队列
        channel.queueDeclare(QUEUE_NAME, durable, false, false, null);
        //发送的消息体
        String message = getMessage(args);
        //发送消息,消息体格式为字节数组
        channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
        System.out.println(" [x] Sent '" + message + "'");
        //关闭连接和通道
        channel.close();
        connection.close();
    }

    /**
     * 解析命令行参数
     * @param args
     * @return
     */
    public static String getMessage(String[] args){
        if(args.length < 1){
            return "Hello, World!";
        }
        return joinStrings(args, " ");
    }

    /**
     * 使用指定分隔符对数组分割
     * @param args
     * @param delimiter
     * @return 拼接后的字符串
     */
    public static String joinStrings(String[] args, String delimiter){
        int length = args.length;
        if(length == 0){
            return "";
        }
        StringBuilder word = new StringBuilder(args[0]);
        for(int i = 1; i < length; i ++){
            word.append(delimiter).append(args[i]);
        }
        return word.toString();
    }
}

消费者(Worker.java)

public class Worker {
    private static final String QUEUE_NAME = "worker_queue";

    public static void main(String[] args) throws Exception{
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("localhost");
        factory.setUsername("admin");
        factory.setPassword("admin");
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();
        boolean durable = false;
        //声明队列,消息接受者再次声明队列.确保消息队列存在,队列的创建是幂等的,存在的队列不会再次创建
        channel.queueDeclare(QUEUE_NAME, durable, false, false, null);
        System.out.println(" [*] Waiting for messages. To exit press CTRL+C");
        //使用信道创建消息消费者
        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);
                }catch (Exception e){
                    e.printStackTrace();
                }finally {
                    System.out.println(" [x] Done");
                }
            }
        };
        //设置自动确认为true.此处的自动确认会导致丢失消息.比如消费者未处理完成而被杀死,链接断开等等.
        boolean autoAck = true;
        channel.basicConsume(QUEUE_NAME, autoAck, consumer);
    }

    /**
     * 判断消息中.的数量,每个.休眠一秒,模拟耗时的任务
     * @param task
     * @throws InterruptedException
     */
    public static void doWork(String task) throws InterruptedException{
        for(char ch : task.toCharArray()){
            if(ch == '.'){
                Thread.sleep(1000);
            }
        }
    }
}

使用任务队列的优点之一便是能够轻松创建并行的工作。如果任务过多,我们只需要增加多个消费者来同时处理更多的任务即可。

下面我们运行实例,来看看任务队列的效果:

1.上一节说过,之前的例子中发送方和消费方都做了声明队列的操作,

因为无法确定先启动消费者还是生产者,而此处我们就会先启用消费者,再启动生产者。运行两次Worker.java中的main方法,启动两个消费者

2.NewTask.java启动前先设置命令行启动参数,如图:

Program arguments在第一次运行时设置:First Message.

然后运行NewTask.java之后修改Program arguments,一共发送5次,每次在First Message后追加.

比如第二次发送的命令行参数为First Message..第三次为:First Message...依次类推。

然后观察控制台输出结果,如下图所示:

一共发了5次消息,第一个消费者消费了1.3.5三条消息.(从点的数量即可判断)

第二个消费者消费了2.4两条消息。

从结果可以看出,默认情况下RabbitMQ采用round-robin(循环)转发模式

依次将消息平均分发给每一个消费者处理。

但是此模式有一个问题:我们用.来模拟很耗时的任务,一个.耗时1秒,假如我现在

发送三条消息分别是First Message....................此条消息将耗时20秒才能完成,

然后发送第二条First Message...此条消息三秒即可完成。

然后再发送First Message.此条消息1秒即可完成。

按上述流程分发送这几条消息,你会发现,第一个消费者执行第一条消息耗时20秒.

而第二个消费者执行第二条消息仅仅耗时3秒,

此时再发送第三条仅仅耗时1秒的消息。你会发现,即使第二个消费已经处理完成空闲下来了,

可是RabbitMQ依然将消息发给了第一个消费者,而此时第一个消费者仍然在执行那个20秒的任务

需要等待20秒过后才能执行最后那个仅仅耗时1秒的任务。

在某些场景下,这可能并不是我们需要的结果,我们希望当多个消费者中某个空闲时就将消息发送给

空闲的消费者处理。

在后边我们讨论如何实现这种需求。

4.消息确认:

在我们当前的场景下,模拟复杂任务,执行一个消费者可能会耗时N秒。如果在任务执行期间,

切断消费者的链接,或者强行终止消费者,会发生什么?在上边的例子中,如果我们执行一个耗时10秒

的任务,在任务执行中强行终止消费者,那么正在被处理的这条消息将会丢失。

消费者在接收到消息后,队列中的消息就已经被清空。在真实的业务场景下,这会导致数据的丢失。

为了避免消息的丢失,我们在消费者回调方法中启用消息确认机制,通知发送者已经接受并成功处理了消息。只有消费者发送了确认消息,RabbitMQ服务才会将队列中的消息清空,这样就避免了消息丢失的问题。确认机制并没有超时的概念,如果消费者的channel断开,tcp连接断开等,RabbitMQ因为没有收到消息确认,会将消息缓存在队列中,发送给下个消费者,如果当前没有消费者,则一直缓存。直到有消费者来再次消费该消息。

消息确认机制默认启用,上边的例子中我们通过设置boolean autoAck = true关闭了消息确认

解释:关于消息的自动确认,初看可能有疑惑,设置为true为什么是关闭了消息确认?我在用代码做了测试后,说一下自己的理解,其实这里的消息确认按我的理解指的是生产者只要将消息发送出来,并且消费者收到了,那么RabbitMQ就默认消息已经成功处理了,做了一次自动确认(清空了队列中的该条消息)。这是RabbitMQ默认的自动确认机制。这种确认机制的问题就在于:消费者仅仅收到了消息,但是处理是否成功,RabbitMQ却不管也不问了。如果消费者处理失败,那么消息也随之丢失了。

还有另外一种消息确认机制,就是手工确认,在回调中显示通知RabbitMQ消息处理成功,这种确认机制的好处就是我们可以在处理完自己的业务逻辑后显示通知服务器,业务已经处理完成。此时服务器再删除队列中的缓存消息就不会对我们产生影响了。而将autoAck设置为true,是打开RabbitMQ自动确认机制,但是却关闭了显示通知的机制。所以从这个角度来说,boolean autoAck = true确实是关闭了消息确认,只不过关闭的是在回调中显示通知的机制。

以上的理解是个人理解,如有偏差还请指正。

下面我们修改程序让它支持未确认消息的重发。

要修改的代码在Worker.java文件中,首先修改回调处理方法

在finally块中加入如下代码:

//消息处理完成后程序显示调用方法来确认给消息发送者.
channel.basicAck(envelope.getDeliveryTag(), false);

然后修改boolean autoAck = false;

修改后的完整代码如下:(此处我新建了一个类,命名为RequeueWorker.java)

代码已经有比较详细的注释了,不再解释代码

public class RequeueWorker {
    private static final String QUEUE_NAME = "worker_queue";

    public static void main(String[] args) throws Exception{
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("localhost");
        factory.setUsername("admin");
        factory.setPassword("admin");
        Connection connection = factory.newConnection();
        final Channel channel = connection.createChannel();
        //声明队列,消息接受者再次声明队列.确保消息队列存在,队列的创建是幂等的,存在的队列不会再次创建
        //开启消息持久化默认为true,此处还未用到消息持久化,所以队列声明时第二个参数直接设置为false,
        //当需要持久化时请打开该行代码的注释,并将队列声明的第二个参数设置为durable即可。
        //boolean durable = true;
        channel.queueDeclare(QUEUE_NAME, false, false, false, null);
        System.out.println(" [*] Waiting for messages. To exit press CTRL+C");
        //使用信道创建消息消费者
        /**
         * 设置通道同时处理的最大的消息数量,此处设置为1,那么当该消费者有未完成的任务时,而且没有其他空闲消费者
         * 那么消息会堆积在队列中等待处理
         */
        channel.basicQos(1);
        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);
                }catch (Exception e){
                    e.printStackTrace();
                }finally {
                    System.out.println(" [x] Done");
                    //消息处理完成后程序显示调用方法来确认给消息发送者.
                    channel.basicAck(envelope.getDeliveryTag(), false);
                }
            }
        };
        /**
         * 设置自动确认为false.为false时rabbitmq不会自动确认消息的消费(在发送方未收到消费者
         * 确认之前不会删除队列中的消息).以此来保证消息不丢失,即使消费者被杀死或断开连接
         * 启动两个RequeueWorker,发送一个耗时5秒的任务,在任务执行期间断开正在执行任务的消费者连接
         * 会发现正在处理的消息被转发给另一个空闲的消费者处理了
         */
        boolean autoAck = false;
        channel.basicConsume(QUEUE_NAME, autoAck, consumer);
    }

    /**
     * 判断消息中.的数量,每个.休眠一秒,模拟耗时的任务
     * @param task
     * @throws InterruptedException
     */
    public static void doWork(String task) throws InterruptedException{
        for(char ch : task.toCharArray()){
            if(ch == '.'){
                Thread.sleep(1000);
            }
        }
    }
}

此时启动两个消费者,然后启动生产者。发送一条耗时时间长的任务消息。比如耗时10秒的任务:First Message..........

然后在控制台观察哪个消费者处理了该消息,在消息未完成处理之前强制终止掉该消费者。此时可以看到未被成功处理的消息已经被重发给另一个消费处理了。如果正在处理的消费者被终止,并且没有其他消费者,那么消息将会缓存在队列中。等待其他消费者来消费,避免消息丢失。

5.消息的持久化:

到此时我们已经解决了从应用程序层面解决丢失消息的问题,但是依然有其他可能会造成消息的丢失。比如,RabbitMQ服务挂掉,RabbitMQ服务所在主机宕机、断电等都会导致消息丢失,为了应对这些情况,我们需要消息的持久化策略。

修改上边的RequeueWorker.java中的代码,打开被注释的行:boolean durable = true,并将

channel.queueDeclare(QUEUE_NAME, false, false, false, null);的第二个参数设置为durable。

即:channel.queueDeclare(QUEUE_NAME, durable, false, false, null);修改后的代码如下:

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

通过将队列声明的方法第二个参数设置为true来让消息持久化。

注意:之前我们声明的队列都是非持久化的,即第二个参数为false,RabbitMQ不允许对已有的队列使用不同参数来再次初始化。所以为了建立持久化队列,可以采用两种办法:

1.先删掉已经存在的队列,再次创建,创建时将其设置为持久化队列。

2.取巧的办法改一下队列名,重新运行即可。

比如我们这里,修改QUEUE_NAME常量改一个不存在的队列名并将第二个参数设置为true就可以了。记住要同时改生产者和消费者。在修改了队列声明之后,还需要做一步:修改生产者发送消息的代码:将消息设置为可持久化的,修改如下:

//发送消息,消息体格式为字节数组
channel.basicPublish("", QUEUE_NAME, MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes());

修改完成,此时即使重启RabbitMQ或者服务宕机,也能保证消息不丢失了。

说明:

一般来说做到这步已经够用了,但是这样的策略依然无法完全保证消息不丢失,比如消息缓存到队列中,而队列中的消息还没来得及持久化到硬盘,突然断电了。消息依然会丢,虽然概率很低。如果你对消息丢失的策略要求很高。请自行参考https://www.rabbitmq.com/confirms.html 本文不做过多说明。

6.公平转发(合理转发):

在上边的蓝色字体部分,我们说过,MQ的默认转发机制有时可能不是我们想要的。我们希望有多个消费者时,消息能优先发给空闲的消费者处理,这样不会造成消息过长的等待和消息的堆积。现在来讨论如何实现这样的转发策略,我们将之称为公平转发或者合理转发。

通过设置channel.basicQos(1),在上边代码示例中被注释掉的那行代码。可以设置一个消费者在同一时间点只处理一条消息。换句话说:在一个消费者处理完成消息并通过消息确认通知RabbitMQ它已经处理完上一条消息之前,RabbitMQ不会再次给这个队列发送消息。

注意:采用此策略时,如果你的所有消费者都有任务在处理中,那么其他未处理的消息将会堆积在队列中。队列是内存中的缓存,所以队列消息过多的堆积会导致内存占用的过大。所以请根据自己的业务场景设置合理的参数以及合理的消费者个数来避免消息的堆积占用过多内存。

此时再次按照蓝色字体部分来发送三条消息,会发现,一个队列当前有任务在处理时,又有新消息过来,RabbitMQ会把消息发给空闲的消费者去处理,不会堆积在有任务的消费者中等待处理了。

关于本节的完整实例代码请参考:

https://git.oschina.net/li_mingzhu/rabbitmq.git

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部