【3】工作队列queues

在上一章中,我们编写了一个简单的程序从一个命名队列发送和接收消息。在本章我们将创建一个工作队列(work Queue,后面统称工作队列)用来给多个工作节点分发任务。
工作队列(又名任务队列)主要的思想是为了避免执行资源密集型的任务,因为那样我们不得不花时间等待其完成。通过工作队列我们可以适时的调度任务执行。我们可以把任务封装成一个消息发送到工作队列中。后台运行的工作进程可以从工作队列中取出任务并执行。
这个概念在web应用程序中是非常有用的,可以用来在一个短的http请求窗口中处理复杂的任务。

预备知识
上一节我们只是简单地发送一个包含“hello world”的消息。在本节我们将实现一个复杂一点的任务。但这个例子没有像调整图像大小或者呈现PDF文件这么复杂,我们只是通过使用 Thread.sleep()方法来让我们的程序看起来很忙。我们将以字符串中的点号的数量作为复杂度;每一个点表示进行一秒钟的工作(sleep 1秒);比如,内容为“hello…”的消息表示任务工作3秒。
我们对前一节的send.java代码进行简单修改,让其允许可以通过命令行输入发送的消息。这个程序将调度任务到工作队列,因此把这个程序命名为NewTask.java:

String message = getMessage(argv);

channel.basicPublish("", "hello", null, message.getBytes());
System.out.println(" [x] Sent '" + message + "'");

从命令行获取参数:

private static String getMessage(String[] strings){
    if (strings.length < 1)
        return "Hello World!";
    return joinStrings(strings, " ");
}

private static String joinStrings(String[] strings, String delimiter) {
    int length = strings.length;
    if (length == 0) return "";
    StringBuilder words = new StringBuilder(strings[0]);
    for (int i = 1; i < length; i++) {
        words.append(delimiter).append(strings[i]);
    }
    return words.toString();
}

同样上一节的Recv.java也需要修改一下:需要根据消息中点号个数来决定睡眠时间。由于它从工作队列中取出消息来当做任务执行,因此我们称其为worker.java:

while (true) {
    QueueingConsumer.Delivery delivery = consumer.nextDelivery();
    String message = new String(delivery.getBody());

    System.out.println(" [x] Received '" + message + "'");        
    doWork(message);
    System.out.println(" [x] Done");
}

根据点号个数来执行任务:

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

循环调度(Round-robin)
使用工作队列的一个好处就是使任务能够并行进行。如果我们想处理一个复杂的任务,可以像上述一样轻松地添加多个工作者(后面统称worker)到工作队列中。
在本例中,我们同时创建两个worker实例来运行;它们都将从队列中获取消息。下面我们来看看执行的结果。
你需要打开三个控制台。其中两个运行worker程序,它们充当消费者C1和C2:

shell1$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar
Worker
 [*] Waiting for messages. To exit press CTRL+C

shell2$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar
Worker
 [*] Waiting for messages. To exit press CTRL+C

第三个控制台则用来发布新的任务。一旦你启动了消费者就可以发布消息了:

shell3$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar
NewTask First message.
shell3$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar
NewTask Second message..
shell3$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar
NewTask Third message...
shell3$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar
NewTask Fourth message....
shell3$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar
NewTask Fifth message.....

首先我们看一下workers接收到了什么:

shell1$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar
Worker
 [*] Waiting for messages. To exit press CTRL+C
 [x] Received 'First message.'
 [x] Received 'Third message...'
 [x] Received 'Fifth message.....'

shell2$ java -cp .:commons-io-1.2.jar:commons-cli-1.1.jar:rabbitmq-client.jar
Worker
 [*] Waiting for messages. To exit press CTRL+C
 [x] Received 'Second message..'
 [x] Received 'Fourth message....'

默认情况下,RabbitMQ会按序地往每个消费者推送消息。也就是平均每个消费者都应获得相同数量的消息。这种分配消息方式叫做轮询(后面统称round-robin)。我们可以尝试一下创建3个或者更多的worker。

消息应答
完成一个任务可能只需要几秒钟。假如一个任务执行了很久,有可能你会想知道是否该任务执行一半就停掉了。在我们当前的代码中,一旦RabbitMQ向消费 者传递了一个消息它就会马上从内存中删除掉。在这种情况下,如果你kill掉一个正在执行的worker,那么你将丢失处理中的消息。不仅如此我们还会丢 失所有已经递送给该worker的未处理的消息。
假如我们不想丢失任何消息,也就是如果一个worker宕掉了,我们可以把消息传递给其他的worker执行。这时我们该怎么做呢?
为了确保消息不会丢失,RabbitMQ提供了消息应答机制(acknowledgments,后面统称ack)。假如一个消息在消费者端被接收并处理完成,那么消费者就会给RabbitMQ server发送一个ack来告诉其可以删除该消息。
相反情况,消费者没有发送ack应答,则RabbitMQ认为该消息没被处理完成进而把消息传递给其他worker。通过这种方式,即使worker宕掉也不会丢失消息。
RabbitMQ没有超时控制机制。只有在worker与RabbitMQ之间的连接断掉后,RabbitMQ才会重发消息。即使一个消息被处理了很长时间也没有返回ack,RabbitMQ也认为是正常的。
默认情况下ack机制是打开的。前面的例子中我们通过设置autoAck=true标志来关掉ack机制。一旦我们完成任务,我们就需要清除这个标志并发送ack。

QueueingConsumer consumer = new QueueingConsumer(channel);
boolean autoAck = false;
channel.basicConsume("hello", autoAck, consumer);

while (true) {
  QueueingConsumer.Delivery delivery = consumer.nextDelivery();
  //...      
  channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
}

通过该段代码我们可以知道,即使你通过CTRL+C终止了一个正在处理消息的worker,该worker所有未返回ack的消息都不会丢失而是被重新发送。


忘记发送ack
有一个常见的错误就是忘记basicAck调用。这虽然是一个看似简单的错误,后果且是严重的。RabbitMQ因收不到ack而不断地重发消息。最终RabbitMQ由于不能释放未返回ack的消息而逐渐把内存耗掉。
为了调试这种错误,我们可以使用命令rabbitmqctl来打印messages_unacknowledged字段:

$ sudo rabbitmqctl list_queues name messages_ready messages_unacknowledged
Listing queues ...
hello    0       0
...done.

消息持久性
我们已经学会在消费者宕掉后如何保证消息不会丢失。但是假如是RabbitMQ服务器宕机了呢?
当RabbitMQ server退出或者宕掉了,它就会忘掉所有的队列和消息,除非你告诉它不要这么做。为了保证RabbitMQ停掉后消息也不会丢失,我们需要做两件事:把队列和消息都标志为持久的。
首先我们需要确保RabbitMQ永远不会丢失我们的队列。为了实现这样,我们需要声明队列为持久的:

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

虽然这样设置是正确的,但在我们这个例子没起作用。因为我们已经定义了一个名为hello的非持久的队列。RabbitMQ不允许你重新定义具有不同参数的现有队列,如果这样做那么将返回一个错误。为了解决这个问题,我们重新声明一个名叫task_queue的队列:

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

在消费者和提供者双方都需要queueDeclare重新声明新队列。
通过上述的设置,我们可以确保即使RabbitMQ重启了,task_queue也不会丢失。现在我们可以通过设置MessageProperties为 值PERSISTENT_TEXT_PLAIN来标记消息为持久性。假如想了解更多Channel方法与MessageProperties你可以查看javadocs online.

import com.rabbitmq.client.MessageProperties;

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


关于消息的持久化
消息被标志为持久性并不能完全保证消息不会丢失。虽然它告诉RabbitMQ要将消息保存在磁盘上,但是不管如何肯定有一小段时间窗口RabbitMQ已 经接收了消息但没来得及保存。同时,RabbitMQ也不会为每个消息都执行fsync(2)操作--可能只是保存在缓存中而没有真正写入到磁盘中。这种 持久性保证并不是很强,但是已能够满足我们这个简单的例子的要求。如果你需要一个更加强大的持久性保证,那么你需要使用发布者确认机制

公平分配
你可能已经注意到,上述的任务调度结果并没有完全符合我们的期望。比如假如有两个workers,奇数的消息比较重而偶数的消息较轻,那么调度的结果就是一个worker非常的忙而另一个则几乎不做任何工作。RabbitMQ并不知道这种情况而继续均匀调度消息。
这是因为当消息进来队列后RabbitMQ就分发消息。RabbitMQ并没有关注相应消费者未返回ack的消息个数。它只是盲目的分发第n个消息到第n个消费者。
rmq-fair
为了解决上述的问题,我们可以使用参数 prefetchCount = 1 来调用basicQos方法。这个方法告诉RabbitMQ每次不能给消费者超过一个消息;换句话说,只有等到消费把先前的消息处理完并返回 ack,RabbitMQ才能分发新的消息。通过这种方式,RabbitMQ将能根据消费者负载情况来分发消息。

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


关于队列大小
如果所有的worker都在执行任务,那么队列就可能被填满。这时你需要是添加更多的workers呢还是执行其他的补救策略。

完整的代码:
NewTask.java

import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.Channel;
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");
    Connection connection = factory.newConnection();
    Channel channel = connection.createChannel();
    
    channel.queueDeclare(TASK_QUEUE_NAME, true, false, false, null);
    
    String message = getMessage(argv);
    
    channel.basicPublish( "", TASK_QUEUE_NAME, 
                MessageProperties.PERSISTENT_TEXT_PLAIN,
                message.getBytes());
    System.out.println(" [x] Sent '" + message + "'");
    
    channel.close();
    connection.close();
  }
    
  private static String getMessage(String[] strings){
    if (strings.length < 1)
      return "Hello World!";
    return joinStrings(strings, " ");
  }  
  
  private static String joinStrings(String[] strings, String delimiter) {
    int length = strings.length;
    if (length == 0) return "";
    StringBuilder words = new StringBuilder(strings[0]);
    for (int i = 1; i < length; i++) {
      words.append(delimiter).append(strings[i]);
    }
    return words.toString();
  }
}

Worker.java:

import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.QueueingConsumer;
  
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");
    Connection connection = factory.newConnection();
    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);
    
    QueueingConsumer consumer = new QueueingConsumer(channel);
    channel.basicConsume(TASK_QUEUE_NAME, false, consumer);
    
    while (true) {
      QueueingConsumer.Delivery delivery = consumer.nextDelivery();
      String message = new String(delivery.getBody());
      
      System.out.println(" [x] Received '" + message + "'");
      doWork(message);
      System.out.println(" [x] Done");

      channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
    }         
  }
  
  private static void doWork(String task) throws InterruptedException {
    for (char ch: task.toCharArray()) {
      if (ch == '.') Thread.sleep(1000);
    }
  }
}


你可能感兴趣的:(【3】工作队列queues)