RabbitMQ系列P4—如何防止消息丢失

177 阅读8分钟

  本文已参与「新人创作礼」活动, 一起开启掘金创作之路。

前言

  RabbitMQ主要分为分为三个部分,ProducerBrokerConsumer。生产者生产完消息之后,将消息发送到Broker中,再根据路由规则将交换器和队列绑定,将消息保存到队列中,然后消费者再从队列中获取消息进行消费,示意图如下所示:

image.png

  使用MQ的优点是可以实现异步解耦流量削峰等,但引入MQ也会带来一些问题,其中之一就是消息丢失。因此,防止消息丢失是引入消息队列后首先要解决的问题。

消息丢失场景

  • 生产者:生产者生产完消息之后,在将消息发送到Broker过程中,由于网络波动、故障等因素,导致Broker没有收到消息,这样消息就丢失了

  • Broker:消息发送到Broker时,但是对消息没有进行持久化,当RabbitMQ宕机时,消息丢失;消息到了Exchange中,但是没有路由到对应的Queue中,若是不进行处理的话,消息也会丢失

  • 消费者:消费者消费消息时,若是使用autoAck,但此时若是程序抛出异常或者是系统突然宕机,这个时候RabbitMQ会认为消费端已经消费成功了,将这条消息删除,从而导致消息丢失

防止消息丢失

Producer

  为了确保消息从生产者成功发送到服务器中,RabbitMQ中引入了消息确认机制。当生产者发送消息到Broker,如果Broker收到消息,则会给生产者发送一条确认消息。生产者可以通过判断确认消息来确认消息是否成功发送到Broker,这种方式是消息投递可靠性的核心保障

  RabbitMQ提供了两种确认消息是否投递成功的机制:

  • AMQP协议提供的事务机制;
  • RabbitMQ提供的confirm模式

  详细的请参考之前的文章,RabbitMQ系列P2—消息确认机制

image.png

Broker

  Broker里发生消息丢失的场景有两种:

  • 若是消息由生产端成功发送到了Broker中,消费者尚未来得及消费掉这些消息,此时服务器突然宕机,如果没有配置持久化机制的话,那么这些消息就会丢失了。因此需要配置RabbitMQ的持久化,RabbitMQ的持久化分为三个部分:

    • 消息本身的持久化
    • Exchange的持久化
    • Queue的持久化

    这三者缺一不可,否则还是会发生消息丢失

  • 若是生产端发送消息到了服务器之后,由于ExchangeQueue之间的路由规则不匹配,导致消息没有进入到相应的队列中,此时消费者一直拿不到消息,而Exchange这边却认为消息已经成功发送,自动删除消息,这样也会导致消息的丢失。

防止RabbitMQ消息丢失的措施

消息本身的持久化

  在生产端,当调用channel的basicPublish(String exchange, String routingKey, boolean mandatory, boolean immediate, BasicProperties props, byte[] body)方法时,props参数中的deliveryMode属性可以将消息设置为持久化。BasicProperties的部分源码如下所示:

 public static class BasicProperties extends com.rabbitmq.client.impl.AMQBasicProperties {
        private String contentType;
        private String contentEncoding;
        private Map<String,Object> headers;
        private Integer deliveryMode;//消息是否持久化 2表示持久化 1表示非持久化
        private Integer priority;
        private String correlationId;
        private String replyTo;
        private String expiration;
        private String messageId;
        private Date timestamp;
        private String type;
        private String userId;
        private String appId;
        private String clusterId;
        ....
 }

  当然我们也可以使用MessageProperties类的静态方法来获取一个持久化消息的BasicProperties,如下所示:

         
         /** 

          Content-type "application/octet-stream", 

          deliveryMode 2 (persistent),

          priority zero 

           */
    public static final BasicProperties PERSISTENT_BASIC =
        new BasicProperties("application/octet-stream",
                            null,
                            null,
                            2,
                            0, null, null, null,
                            null, null, null, null,
                            null, null);

交换机的持久化

  在调用channelexchangeDeclare(String exchange, String type, boolean durable, boolean autoDelete, Map<String, Object> arguments)方法声明交换机时,当将durable参数持久化设置为trueautoDelte设置为false时,就会声明一个可持久化,非自动删除的交换机。若是不设置持久化,那么RabbitMQ重启之后交换机就会消失,生产端将不能向该交换机发送消息。

队列的持久化

  在调用channelqueueDeclare(String queue, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments)声明队列时,将durable设置为true,将autoDlete设置为false时,就会声明一个持久化、非自动删除的队列。若是不设置持久化,那么RabbitMQ重启之后队列上的消息将会丢失。

Return机制

  为了避免因为路由规则的不匹配导致的消息丢失现象,RabbitMQ提供了Return机制。

  在生产端调用channel.basicPublish(String exchange, String routingKey, boolean mandatory, BasicProperties props, byte[] body)方法时,设置mandatory参数为true时,且在生产端添加了returnListener。这样,当在Exchange根据路由规则匹配不到与之相应的队列Queue时,则RabbitMQ将会调用Basic.Return命令将消息返回给生产者。

image.png

Consumer

  一般情况下,Consumer需要一定时间才能处理完收到的消息,若是在消息尚未被处理结束之前,Consumer内部程序运行异常或者系统宕机,那么这条消息何去何从呢?一般分为两种情况:

  • Consumer在消费消息时,若是设置了自动确认,即autoAcktrue,则不管消费者是否真正地消费了这些消息,都会自动把发送出去的消息置为已确认,然后从内存或者磁盘中删除

  • 若是autoAckfalse,则RabbitMQ会等待消费者回复一个确认信号ack之后,然后再去删除这条消息;

  因此为了保证消息不会在消费端被丢失,在消费端调用basicConsume方法时,只要设置了auto-ackfalse,消费端就有足够的时间处理消息,不用担心处理消息过程中消费者进程挂掉后消息丢失的问题,因为RabbitMQ会一直等待,直到消费端调用ack()方法发出确认信号为止,RabbitMQ才会将这条消息打上标记,后期再删除这条消息

  当auto-ackfalse时,队列中的消息分为两个部分:

  • 等待投递给消费者的消息
  • 已经投递给消费者但尚未收到确认信号的消息。

  若是RabbitMQ一直没有收到消费端的确认信号,并且这条消息的消费端已经断开了连接,则RabbitMQ会将此条消息重新入队,等待投递给下一个消费者,有时候甚至是同一个消费者。当auto-ackfalse时,需要重写ConsumerhandleDelivery()方法,配合ack/nack方法来进行使用。下面是示例代码:

public class AckProducer {

    public static void main(String[] args) throws IOException, TimeoutException {
        Connection connection = ConnectionUtil.getConnection();
        Channel channel = connection.createChannel();
        String exchangeName="exchange_ack";
        String routingKey="ack.test";

        for (int i = 1; i <=5 ; i++) {
            String message="test for ack :"+i;
            channel.basicPublish(exchangeName, routingKey, false, null, message.getBytes());
            System.out.println(message);
        }

        channel.close();
        connection.close();
    }
}
public class AckConsumer {

    public static void main(String[] args) throws IOException, TimeoutException {
        Connection connection = ConnectionUtil.getConnection();
        Channel channel = connection.createChannel();
        String exchangeName = "exchange_ack";
        String type = "topic";
        String routingKey = "ack.#";
        String queueName = "queue_ack";

        //消息持久化 durable设为true  消息持久化
        channel.exchangeDeclare(exchangeName, type, true);

        //durable 设为true  autoDelete设为false 消息持久化
        channel.queueDeclare(queueName, true, false, false, null);

        channel.queueBind(queueName, exchangeName, routingKey);

        com.rabbitmq.client.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);

                //消息序号
                int num = Integer.valueOf(message.split(":")[1]);

                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                if (num ==2) {
                    System.out.println("消费者收到了消息:" + message + ",拒绝了这条消息");

                    try {
                        //模拟业务异常
                        int i=1/0;
                    } catch (Exception e) {
                        e.printStackTrace();
                        //将最后一个参数requeue设置为true时,则该消息将会重回队列
                        channel.basicNack(envelope.getDeliveryTag(), false, true);
                    }

                } else {
                    System.out.println("消费者收到了消息:" + message + ",消息了这条消息");
                    channel.basicAck(envelope.getDeliveryTag(), false);
                }
            }
        };

        //将auto-ack设为false
        channel.basicConsume(queueName, false, consumer);
    }
}

  启动生产者,控制台输出:

test for ack :1
test for ack :2
test for ack :3
test for ack :4
test for ack :5\

  启动消费者,控制台输出:

消费者收到了消息:test for ack :1,消息了这条消息
消费者收到了消息:test for ack :2,拒绝了这条消息
消费者收到了消息:test for ack :3,消息了这条消息
消费者收到了消息:test for ack :4,消息了这条消息
消费者收到了消息:test for ack :5,消息了这条消息
消费者收到了消息:test for ack :2,拒绝了这条消息
消费者收到了消息:test for ack :2,拒绝了这条消息
消费者收到了消息:test for ack :2,拒绝了这条消息
消费者收到了消息:test for ack :2,拒绝了这条消息
消费者收到了消息:test for ack :2,拒绝了这条消息
消费者收到了消息:test for ack :2,拒绝了这条消息
。。。。。。。。

  可以看到,由于nack时设置重回队列为true,编号为2的消息会被重新存入队列尾部,然后再发送给消息者,所以消息2会一直循环打印。使用http://localhost:15673 打开web管理后台,可以看到queue_ack的队列中unack的数量为1。

img

img

  重启消费者,控制台输出如下:

img

  可以看到消息2还是会被一直消费打印,此时控制台中unack数量为0Ready变成1

img

img

  因此,当使用basicNack将requeue设置为true时需要注意一点,若是因为某些原因导致消息一直无法被ack,则消息会被一直存入队列中,若是存在很多这样的消息,则队列中元素会越来越多,占用的内存越来越大,可能导致内存泄漏。所以,在实际应用中,在处理nack的消息时通常需要设置重试次数,达到一定重试次数后,记录下此消息,然后调用RabbitMQack删除此消息或者是将requeue设置为false,后面进行手动重试或者其他处理。

总结

image.png