四、RabbitMQ 消息可靠投递

423 阅读4分钟

  RabbitMQ的发布确认机制,可以很好的保证生产者消息不丢失。在实际开发过程中,比如消息发送方重启服务、RabbitMQ 服务重启或者 RabbitMQ 集群不可用等情况发生时,都会导致消息投递失败、消息丢失。那么我们怎么才能保证 RabbitMQ 消息的可靠投递呢?这里就要说到消息发布确认策略

一、RabbitMQ Client 中关于发布确认相关API

发布确认默认是没有开启的,如果要开启需要调用方法 confirmSelect。每当你要想使用发布确认,都必须在Channel 上调用该方法。

1. 单个发布确认消息

这是一种简单的同步确认发布的方式,也就是发布一个消息之后只有当这个消息被确认发布之后,后续的消息才能继续发布。这种确认方式有一个最大的缺点就是:发布速度特别的慢。

1.1 消息生产者

public class SingleAckProducer {

    private static String exchangeName = "exchange.prod.ack";

    public static void main(String[] args) throws Exception {
        Channel channel = RabbitChannelUtil.getChannel();

        channel.exchangeDeclare(exchangeName, BuiltinExchangeType.TOPIC, false, false, null);
        String body = "singe ack message";
        String ackRoutingKey = "rk.prod.ack";

        // 开启发布确认
        channel.confirmSelect();
        for (int i = 1; i <= 10; i++) {
            String msg = i + ".".concat(body);
            channel.basicPublish(exchangeName, ackRoutingKey, null, msg.getBytes(StandardCharsets.UTF_8));

            // 消息服务器返回 false 或者 超时未返回,生产者可以重发消息
            boolean ackResult = channel.waitForConfirms();
            if (ackResult) {
                System.out.println("消息:" + body + ",发送成功!!");
            }
        }
    }

}

1.2 消息消费者

public class SingleAckConsumer {
    public static String queueName = "prod.ack.queue";
    private static String exchangeName = "exchange.prod.ack";

    public static void main(String[] args) throws Exception {

        Channel channel = RabbitChannelUtil.getChannel();
        channel.exchangeDeclare(exchangeName, BuiltinExchangeType.TOPIC);

        channel.queueDeclare(queueName, false, false, true, null);

        String ackRoutingKey = "rk.prod.ack";
        channel.queueBind(queueName, exchangeName, ackRoutingKey);

        System.out.println("SingleAckConsumer 等待接收消息......");
        DefaultConsumer consumer = new DefaultConsumer(channel) {
            @Override
            public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                System.out.println("SingleAckConsumer 接受到消息 body : " + new String(body));
            }
        };
        channel.basicConsume(queueName, true, consumer);
    }
}

启动运行 SingleAckProducerSingleAckConsumer 后,可以看出它确实是同步确认的。

image.png

2. 批量发布确认消息

这种发布确认方式也是一种同步确认机制,只不过它是一批一批的消息并确认,弥补了单个消息确认的速度慢的问题。但是它也有自己的不足之处:当消息发布出现问题时,是无法确认哪个消息有问题,只能针对整个批次的消息进行补偿处理

/**
 * 批量消息发布确认
 */
public class BatchAckProducer {

    private static String exchangeName = "exchange.prod.ack";

    public static void main(String[] args) throws Exception {
        Channel channel = RabbitChannelUtil.getChannel();

        channel.exchangeDeclare(exchangeName, BuiltinExchangeType.TOPIC, false, false, null);
        String body = "singe ack message";
        String ackRoutingKey = "rk.prod.ack";

        channel.confirmSelect();  // 开启发布确认

        int batchAckSize = 10;    // 每次批量确认10个消息
        int notBatchAckSize = 0;  // 为确认消息个数

        for (int i = 1; i <= 30; i++) {
            String msg = i + ".".concat(body);

            channel.basicPublish(exchangeName, ackRoutingKey, null, msg.getBytes(StandardCharsets.UTF_8));
            notBatchAckSize++;

            // 批量确认逻辑
            if (notBatchAckSize == batchAckSize) {

                channel.waitForConfirms();
                notBatchAckSize = 0;
            }

            // 为了保证还有剩余消息没有确认,再次确认一下子
            if (notBatchAckSize > 0) {
                channel.waitForConfirms();
            }
        }
    }
}

3. 异步发布确认消息

异步发布确认的整体思路 掘金-RabbitMQ 异步发布确认.drawio.png

/**
 * 异步发布确认
 */
public class AsyncAckProducer {
    private static String exchangeName = "exchange.prod.ack";

    public static void main(String[] args) throws Exception {
        Channel channel = RabbitChannelUtil.getChannel();

        channel.exchangeDeclare(exchangeName, BuiltinExchangeType.TOPIC, false, false, null);
        String body = "singe ack message";
        String ackRoutingKey = "rk.prod.ack";

        channel.confirmSelect();  // 开启发布确认

        /**
         * 定义一个安全有序的高性能哈希表,目的:
         *  1.轻松的将序号和消息绑定
         *  2.支持根据序号批量的删除消息条目
         *  3.支持并发访问
         */
        ConcurrentSkipListMap<Long, String> ackMap = new ConcurrentSkipListMap<>();

        ConfirmCallback ackCallback = new ConfirmCallback() {
            /**
             * deliveryTag : 消息标识号
             * multiple : true 表示可以确认小于等于当前序列号的消息;false 表示只能确认当前序列号的消息
             */
            @Override
            public void handle(long deliveryTag, boolean multiple) {

                System.out.println("消息回调,deliveryTag : " + deliveryTag + ", multiple : " + multiple);
                if (multiple) {
                    // 清除当前比当前序列号小的所有消息
                    ConcurrentNavigableMap<Long, String> confirmMap = ackMap.headMap(deliveryTag, true);
                    confirmMap.clear();
                    return;
                }

                // 只清除当前序列号的对应的消息
                ackMap.remove(deliveryTag);
            }
        };

        ConfirmCallback nackCallback = new ConfirmCallback() {
            @Override
            public void handle(long deliveryTag, boolean multiple) {
                String msg = ackMap.get(deliveryTag);
                System.out.println("消息 : " + msg + " 未被确认,消息 tag : " + deliveryTag);
            }
        };

        /**
         * 添加一个异步确认下消息监听器
         * 1.确认收到消息的回调,ackCallback
         * 2.未收到消息的回调,nackCallback
         */
        channel.addConfirmListener(ackCallback, nackCallback);
        for (int i = 1; i <= 30; i++) {
            String msg = i + ".".concat(body);

            // 获取下一个消息的序列号,其实就是 deliveryTag ,用来将消息和序列号绑定
            long nextPublishSeqNo = channel.getNextPublishSeqNo();
            ackMap.put(nextPublishSeqNo, msg);

            // 发布消息
            channel.basicPublish(exchangeName, ackRoutingKey, null, msg.getBytes(StandardCharsets.UTF_8));
        }
    }
}

3.1 异步发布确认消息

  • 未确认的消息落库,异步定时任务定时重新投递。
  • 可以把未确认的消息放到一个基于内存的能被发布线程访问的队列,比如说用 ConcurrentLinkedQueue 这个队列在 confirm callbacks 与发布线程之间进行消息的传递。

4. 关于rabbitmq.client 中API的总结

  • 单个消息发布确认批量消息发布确认 这种思想在实际开发中并不常用,因为它是同步操作,在性能方面很难保证。
  • 异步发布确认要保证消息的可靠投递,对未确认的消息进行落库,异步定时任务定时重新投递。

二、SpringBoot + RabbitMQ 实现发布确认

发布确认方案如下,这种处理方式的目的就是确保消息可靠投递掘金-SpringBoot发布确认.drawio.png

1. 配置 rabbitmq 连接信息

@Configuration
public class RabbitMqConfig {

    @Resource
    private ExchangeCallback callback;

    @Value("${spring.rabbitmq.addresses}")
    private String address;
    @Value("${spring.rabbitmq.username}")
    private String username;
    @Value("${spring.rabbitmq.password}")
    private String password;

    public static final String exchange = "exchange.prod.ack";
    public static final String queue = "prod.ack.queue";
    public static final String routingKey = "rk.prod.ack";

    // 生产者发送消息连接
    @Bean("connectionFactory")
    public ConnectionFactory connectionFactory() {
        CachingConnectionFactory connectionFactory = new CachingConnectionFactory();
        connectionFactory.setAddresses(address);
        connectionFactory.setShuffleAddresses(true);
        connectionFactory.setUsername(username);
        connectionFactory.setPassword(password);

        // 发布消息成功到交换机后触发回调
        connectionFactory.setPublisherConfirmType(CachingConnectionFactory.ConfirmType.CORRELATED);
        return connectionFactory;
    }

    @Bean(name = "rabbitAdmin")
    public RabbitAdmin rabbitAdmin() {
        //需要传入
        RabbitAdmin rabbitAdmin = new RabbitAdmin(connectionFactory());
        rabbitAdmin.setAutoStartup(true);
        return rabbitAdmin;
    }

    // 生产者发送消息配置
    @Bean(name = "rabbitTemplate")
    public AmqpTemplate rabbitTemplate(@Qualifier("connectionFactory") ConnectionFactory connectionFactory) {
        RabbitTemplate template = new RabbitTemplate(connectionFactory);
        template.setExchange(exchange);
        template.setUsePublisherConnection(true);
        template.setMessageConverter(new Jackson2JsonMessageConverter());
        // 注入回调
        template.setConfirmCallback(callback);
        return template;
    }

    // 声明队列
    @Bean(name = "ackQueue")
    public Queue ackQueue(@Qualifier("rabbitAdmin") RabbitAdmin rabbitAdmin) {
        Queue queue = new Queue(RabbitMqConfig.queue, true);
        rabbitAdmin.declareQueue(queue);
        return queue;
    }

    // 交换机配置
    @Bean(name = "ackExchange")
    TopicExchange ackExchange() {
        return ExchangeBuilder.topicExchange(exchange).durable(true).build();
    }

    // 绑定RoutingKey
    @Bean
    public Binding attRestDayCalculateQueueBinding(@Qualifier("ackQueue") Queue queue, @Qualifier("ackExchange") TopicExchange exchange) {
        return BindingBuilder.bind(queue).to(exchange).with(routingKey);
    }


    // 消费者连接工厂配置
    @Bean("rabbitListenerFactory")
    public RabbitListenerContainerFactory<?> rabbitListenerContainerFactory(@Qualifier("connectionFactory") ConnectionFactory connectionFactory) {
        SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
        factory.setMessageConverter(new Jackson2JsonMessageConverter());
        // 消费者自动确认消息
        factory.setAcknowledgeMode(AcknowledgeMode.AUTO);
        factory.setConnectionFactory(connectionFactory);
        factory.setPrefetchCount(1);
        return factory;
    }
}
  • spring boot 项目中我建议用 @Configuration 来配置 queueexchangeroutingKey 的绑定关系,避免使用手动在 rabbitmq 后台创建绑定关系,这种方式容易出错,而且不方便其他同事查看
  • 如果在消息发送方配置队列绑定关系,启动时会报如下错误
2022-07-27 22:18:53.663  WARN 4088 --- [ntContainer#0-1] o.s.a.r.l.BlockingQueueConsumer          : Failed to declare queue: prod.ack.queue
2022-07-27 22:18:53.665  WARN 4088 --- [ntContainer#0-1] o.s.a.r.l.BlockingQueueConsumer          : Queue declaration failed; retries left=2

org.springframework.amqp.rabbit.listener.BlockingQueueConsumer$DeclarationException: Failed to declare queue(s):[prod.ack.queue]
   at org.springframework.amqp.rabbit.listener.BlockingQueueConsumer.attemptPassiveDeclarations(BlockingQueueConsumer.java:700) ~[spring-rabbit-2.2.11.RELEASE.jar:2.2.11.RELEASE]
   at org.springframework.amqp.rabbit.listener.BlockingQueueConsumer.passiveDeclarations(BlockingQueueConsumer.java:584) ~[spring-rabbit-2.2.11.RELEASE.jar:2.2.11.RELEASE]
   at org.springframework.amqp.rabbit.listener.BlockingQueueConsumer.start(BlockingQueueConsumer.java:571) ~[spring-rabbit-2.2.11.RELEASE.jar:2.2.11.RELEASE]
   at org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer$AsyncMessageProcessingConsumer.initialize(SimpleMessageListenerContainer.java:1355) ~[spring-rabbit-2.2.11.RELEASE.jar:2.2.11.RELEASE]
   at org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer$AsyncMessageProcessingConsumer.run(SimpleMessageListenerContainer.java:1200) ~[spring-rabbit-2.2.11.RELEASE.jar:2.2.11.RELEASE]
   at java.lang.Thread.run(Thread.java:745) ~[?:1.8.0_111]
Caused by: java.io.IOException
   at com.rabbitmq.client.impl.AMQChannel.wrap(AMQChannel.java:129) ~[amqp-client-5.7.3.jar:5.7.3]
   at com.rabbitmq.client.impl.AMQChannel.wrap(AMQChannel.java:125) ~[amqp-client-5.7.3.jar:5.7.3]
   at com.rabbitmq.client.impl.AMQChannel.exnWrappingRpc(AMQChannel.java:147) ~[amqp-client-5.7.3.jar:5.7.3]
   at com.rabbitmq.client.impl.ChannelN.queueDeclarePassive(ChannelN.java:1012) ~[amqp-client-5.7.3.jar:5.7.3]
   at com.rabbitmq.client.impl.ChannelN.queueDeclarePassive(ChannelN.java:52) ~[amqp-client-5.7.3.jar:5.7.3]
   at org.springframework.amqp.rabbit.connection.PublisherCallbackChannelImpl.queueDeclarePassive(PublisherCallbackChannelImpl.java:355) ~[spring-rabbit-2.2.11.RELEASE.jar:2.2.11.RELEASE]
   at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[?:1.8.0_111]
   at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[?:1.8.0_111]
   at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[?:1.8.0_111]
   at java.lang.reflect.Method.invoke(Method.java:498) ~[?:1.8.0_111]
   at org.springframework.amqp.rabbit.connection.CachingConnectionFactory$CachedChannelInvocationHandler.invoke(CachingConnectionFactory.java:1184) ~[spring-rabbit-2.2.11.RELEASE.jar:2.2.11.RELEASE]
   at com.sun.proxy.$Proxy82.queueDeclarePassive(Unknown Source) ~[?:?]
   at org.springframework.amqp.rabbit.listener.BlockingQueueConsumer.attemptPassiveDeclarations(BlockingQueueConsumer.java:679) ~[spring-rabbit-2.2.11.RELEASE.jar:2.2.11.RELEASE]
   ... 5 more
Caused by: com.rabbitmq.client.ShutdownSignalException: channel error; protocol method: #method<channel.close>(reply-code=404, reply-text=NOT_FOUND - no queue 'prod.ack.queue' in vhost '/', class-id=50, method-id=10)
   at com.rabbitmq.utility.ValueOrException.getValue(ValueOrException.java:66) ~[amqp-client-5.7.3.jar:5.7.3]
   at com.rabbitmq.utility.BlockingValueOrException.uninterruptibleGetValue(BlockingValueOrException.java:36) ~[amqp-client-5.7.3.jar:5.7.3]
   at com.rabbitmq.client.impl.AMQChannel$BlockingRpcContinuation.getReply(AMQChannel.java:502) ~[amqp-client-5.7.3.jar:5.7.3]
   at com.rabbitmq.client.impl.AMQChannel.privateRpc(AMQChannel.java:293) ~[amqp-client-5.7.3.jar:5.7.3]
   at com.rabbitmq.client.impl.AMQChannel.exnWrappingRpc(AMQChannel.java:141) ~[amqp-client-5.7.3.jar:5.7.3]
   at com.rabbitmq.client.impl.ChannelN.queueDeclarePassive(ChannelN.java:1012) ~[amqp-client-5.7.3.jar:5.7.3]
   at com.rabbitmq.client.impl.ChannelN.queueDeclarePassive(ChannelN.java:52) ~[amqp-client-5.7.3.jar:5.7.3]
   at org.springframework.amqp.rabbit.connection.PublisherCallbackChannelImpl.queueDeclarePassive(PublisherCallbackChannelImpl.java:355) ~[spring-rabbit-2.2.11.RELEASE.jar:2.2.11.RELEASE]
   at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[?:1.8.0_111]
   at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[?:1.8.0_111]
   at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[?:1.8.0_111]
   at java.lang.reflect.Method.invoke(Method.java:498) ~[?:1.8.0_111]
   at org.springframework.amqp.rabbit.connection.CachingConnectionFactory$CachedChannelInvocationHandler.invoke(CachingConnectionFactory.java:1184) ~[spring-rabbit-2.2.11.RELEASE.jar:2.2.11.RELEASE]
   at com.sun.proxy.$Proxy82.queueDeclarePassive(Unknown Source) ~[?:?]
   at org.springframework.amqp.rabbit.listener.BlockingQueueConsumer.attemptPassiveDeclarations(BlockingQueueConsumer.java:679) ~[spring-rabbit-2.2.11.RELEASE.jar:2.2.11.RELEASE]
   ... 5 more
Caused by: com.rabbitmq.client.ShutdownSignalException: channel error; protocol method: #method<channel.close>(reply-code=404, reply-text=NOT_FOUND - no queue 'prod.ack.queue' in vhost '/', class-id=50, method-id=10)
   at com.rabbitmq.client.impl.ChannelN.asyncShutdown(ChannelN.java:522) ~[amqp-client-5.7.3.jar:5.7.3]
   at com.rabbitmq.client.impl.ChannelN.processAsync(ChannelN.java:346) ~[amqp-client-5.7.3.jar:5.7.3]
   at com.rabbitmq.client.impl.AMQChannel.handleCompleteInboundCommand(AMQChannel.java:182) ~[amqp-client-5.7.3.jar:5.7.3]
   at com.rabbitmq.client.impl.AMQChannel.handleFrame(AMQChannel.java:114) ~[amqp-client-5.7.3.jar:5.7.3]
   at com.rabbitmq.client.impl.AMQConnection.readFrame(AMQConnection.java:672) ~[amqp-client-5.7.3.jar:5.7.3]
   at com.rabbitmq.client.impl.AMQConnection.access$300(AMQConnection.java:48) ~[amqp-client-5.7.3.jar:5.7.3]
   at com.rabbitmq.client.impl.AMQConnection$MainLoop.run(AMQConnection.java:599) ~[amqp-client-5.7.3.jar:5.7.3]
   ... 1 more

当出现这个异常时,请用如下方式进行配置,原因分析参考文章:Failed to declare queue

@Bean(name = "rabbitAdmin")
public RabbitAdmin rabbitAdmin() {
    //需要传入
    RabbitAdmin rabbitAdmin = new RabbitAdmin(connectionFactory());
    rabbitAdmin.setAutoStartup(true);
    return rabbitAdmin;
}

// 声明队列
@Bean(name = "ackQueue")
public Queue ackQueue(@Qualifier("rabbitAdmin") RabbitAdmin rabbitAdmin) {
    Queue queue = new Queue(RabbitMqConfig.queue, true);
    rabbitAdmin.declareQueue(queue);
    return queue;
}

2. 交换机回调方法

@Slf4j
@Component
public class ExchangeCallback implements RabbitTemplate.ConfirmCallback {

    /**
     * 交换机不论是否收到消息都会回调该方法
     * @param correlationData 消息数据对象
     * @param ack             交换机是否收到消息,true :交换机已收到消息 ;false :交换机未收到消息
     * @param cause           未收到消息的原因
     */
    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        String id = null != correlationData ? correlationData.getId() : "null";
        if (ack) {
            log.info("交换机已收到消息, id : {}", id);
            return;
        }
        log.info("交换机未收到消息, id : {} , 原因 : {}", id, cause);
    }
}

3. 消息生产者

@Slf4j
@RestController
@RequestMapping("/ack")
public class Producer {


    @Resource
    private RabbitTemplate rabbitTemplate;

    @PostMapping("/send")
    public void send() {

        CorrelationData data = new CorrelationData("1");
        Map<String, String> firstMap = new HashMap<>();
        firstMap.put("1", "first msg");

        rabbitTemplate.convertAndSend(RabbitMqConfig.exchange, RabbitMqConfig.routingKey, firstMap, data);

        CorrelationData data2 = new CorrelationData("2");
        Map<String, String> secondMap = new HashMap<>();
        secondMap.put("2", "second msg");
        rabbitTemplate.convertAndSend(RabbitMqConfig.exchange, RabbitMqConfig.routingKey + ".1", secondMap, data2);

        log.info("=========消息发送完成==========");
    }

}

4. 消息消费者

@Slf4j
@Component
public class Consumer {

    @RabbitHandler
    @RabbitListener(queues = RabbitMqConfig.queue, containerFactory = "rabbitListenerFactory")
    public void receiveMessage(Message message) {
        String msgBody = new String(message.getBody(), StandardCharsets.UTF_8);
        log.info("接收到队列 prod.ack.queue 的消息 : {}", msgBody);
    }
}

5. 启动类

@SpringBootApplication(scanBasePackages = {"com.sff.delay.example"}, exclude = {RabbitAutoConfiguration.class})
@EnableRabbit
public class RabbtimqDelayQueueApplication {

    public static void main(String[] args) {
        SpringApplication.run(RabbtimqDelayQueueApplication.class, args);
    }
}

6. 运行结果

2022-07-28 22:47:05.613  INFO 1694 --- [tory.publisher1] c.s.d.e.s.a.ExchangeCallback             : 交换机已收到消息, id : 1
2022-07-28 22:47:05.636  INFO 1694 --- [nio-8080-exec-1] c.s.d.e.s.a.Producer                     : =========消息发送完成==========
2022-07-28 22:47:05.648  INFO 1694 --- [ntContainer#0-1] c.s.d.e.s.a.Consumer                     : 接收到队列 prod.ack.queue 的消息 : {"1":"first msg"}
2022-07-28 22:47:05.665  INFO 1694 --- [tory.publisher1] c.s.d.e.s.a.ExchangeCallback             : 交换机已收到消息, id : 2

可以看到id为 1 和 2 的消息都到达了 exchange,但是由于id是 2 的消息绑定的 routingkey= rk.prod.ack.1 ,所以导致消费者无法接受到消息,但是也触发了交换机的回调方法。

四、写在最后