RabbitMQ系列6:可靠性投递:完整分析消息丢失,幂等,顺序性问题

要保证消息是可靠性的,主要是三个方面:不能丢失,不能重复(幂等),必要的时候要求顺序不能乱。所以我们完整得分析RabbitMQ如何保证可靠性的。

1.分析MQ模型中存在可靠性问题的环节

RabbitMQ的模型如下所示,其中存在可靠性问题的部分有:
在这里插入图片描述
RabbitMQ收发消息的时候,有几个主要环节:
①消息从生产者发送到Broker,生产者把消息发到Broker之后,怎么知道自己的消息是否被接收。
②消息从Exchange路由到Queue,Exchange是一个绑定列表,职责是分发消息。如果找不到队列或者找不到正确的对垒,怎么处理。
③消息在Queue中存储,队列有自己的数据库Mnesia,用来存储消息,如果没有被消费会一直存在,如何保证消息在队列中稳定地存储呢?
④消费者订阅Queue并消费消息,队列是FIFO的,被消费之后删库才投递下一条,Broker如何知道消费者已经接收了消息呢?
下面就从四个环节来梳理都采取了什么可靠性策略

2. 消息发送到RabbitMQ服务器

发送过程中什么情况会出现发送失败?
可能因为网络连接或者Broker的问题,例如设备故障等导致消息发送失败,生产者不能确定Broker有没有正确接收。这就需要给生产者发送消息的接口一个应答。
RabbitMQ提供了两种服务端确认机制。第一种是事务模式,一种是Confirm确认模式。

2.1 事务模式

事务模式与数据库的事务模式类似的commit方式,如果出现异常崩溃或者其他原因抛出异常,同样可以通过rollback来回滚事务。这几个方法是在channel里。使用的例子:

public class TransactionProducer {
    private final static String QUEUE_NAME = "ORIGIN_QUEUE";

    public static void main(String[] args) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setUri(ResourceUtil.getKey("rabbitmq.uri"));

        // 建立连接
        Connection conn = factory.newConnection();
        // 创建消息通道
        Channel channel = conn.createChannel();

        String msg = "Hello world, Rabbit MQ";
        // 声明队列(默认交换机AMQP default,Direct)
        // String queue, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments
        channel.queueDeclare(QUEUE_NAME, false, false, false, null);

        try {
            channel.txSelect();
            // 发送消息
            // String exchange, String routingKey, BasicProperties props, byte[] body
            channel.basicPublish("", QUEUE_NAME, null, (msg).getBytes());
            channel.txCommit();
     
            int i =1/0;
            channel.basicPublish("", QUEUE_NAME, null, (msg).getBytes());
            channel.txCommit();
         
            // int i =1/0;
            channel.txCommit();
            System.out.println("消息发送成功");
        } catch (Exception e) {
            channel.txRollback();
            System.out.println("消息已经回滚");
        }

        channel.close();
        conn.close();
    }
}

这种方式在数据库中很有用,但是由于其本身是阻塞的,因此会严重影响mq的效率。所以采用Confirm确认模式更合适。

2.2 confirm确认模式

确认模式有三种:普通确认模式,批量确认模式和异步确认模式。
普通确认模式就是生产者通过调用channel.confirmSelect()方法将信道设置为confirm模式,然后发送消息。一旦消息被投递到交换机之后,RabbitMQ就会发送一个ack给生产者。如果网络错误,就会抛出异常,如果交换机不存在,会抛出404错误。

普通确认模式这个也是一条一确认,效率仍然不高,所以可以使用批量确认的模式,一次发送一批,因此效率更高。但是仍然存在两个问题:多少算一批是最合适的?第二个问题,如果发了1000条,有1条没确认,那么要全部重发,这无疑带来严重的混乱。
比较好的方式是异步确认模式,一边发送一边确认。异步确认模式需要添加一个ConfirmListener,并且用一个SortedSet来维护一个批次中没有被确认的消息,使用的例子:

public class AsyncConfirmProducer {
    private final static String QUEUE_NAME = "ORIGIN_QUEUE";

    public static void main(String[] args) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setUri(ResourceUtil.getKey("rabbitmq.uri"));

        // 建立连接
        Connection conn = factory.newConnection();
        // 创建消息通道
        Channel channel = conn.createChannel();

        String msg = "Hello world, Rabbit MQ, Async Confirm";
        // 声明队列(默认交换机AMQP default,Direct)
        // String queue, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments
        channel.queueDeclare(QUEUE_NAME, false, false, false, null);

        // 用来维护未确认消息的deliveryTag
        final SortedSet<Long> confirmSet = Collections.synchronizedSortedSet(new TreeSet<Long>());

        // 这里不会打印所有响应的ACK;ACK可能有多个,有可能一次确认多条,也有可能一次确认一条
        // 异步监听确认和未确认的消息
        // 如果要重复运行,先停掉之前的生产者,清空队列
        channel.addConfirmListener(new ConfirmListener() {
            public void handleNack(long deliveryTag, boolean multiple) throws IOException {
                System.out.println("Broker未确认消息,标识:" + deliveryTag);
                if (multiple) {
                    // headSet表示后面参数之前的所有元素,全部删除
                    confirmSet.headSet(deliveryTag + 1L).clear();
                } else {
                    confirmSet.remove(deliveryTag);
                }
                // 这里添加重发的方法
            }
            public void handleAck(long deliveryTag, boolean multiple) throws IOException {
                // 如果true表示批量执行了deliveryTag这个值以前(小于deliveryTag的)的所有消息,如果为false的话表示单条确认
                System.out.println(String.format("Broker已确认消息,标识:%d,多个消息:%b", deliveryTag, multiple));
                if (multiple) {
                    // headSet表示后面参数之前的所有元素,全部删除
                    confirmSet.headSet(deliveryTag + 1L).clear();
                } else {
                    // 只移除一个元素
                    confirmSet.remove(deliveryTag);
                }
                System.out.println("未确认的消息:"+confirmSet);
            }
        });

        // 开启发送方确认模式
        channel.confirmSelect();
        for (int i = 0; i < 10; i++) {
            long nextSeqNo = channel.getNextPublishSeqNo();
            // 发送消息
            // String exchange, String routingKey, BasicProperties props, byte[] body
            channel.basicPublish("", QUEUE_NAME, null, (msg +"-"+ i).getBytes());
            confirmSet.add(nextSeqNo);
        }
        System.out.println("所有消息:"+confirmSet);

        // 这里注释掉的原因是如果先关闭了,可能收不到后面的ACK
        //channel.close();
        //conn.close();
    }
}

在SpringBoot中,RabbitTemplate对Channel进行了封装

 rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() {
            public void confirm(CorrelationData correlationData, boolean ack, String cause) {
                if (!ack) {
                    System.out.println("发送消息失败:" + cause);
                    throw new RuntimeException("发送异常:" + cause);
                }
            }
        });

3. 消息从交换机路由到队列

第二个环节就是消息从交换机路由到队列,什么情况情况下,消息会无法路由到正确的队列?可能因为routingkey错误,或者队列不存在。
RabbitMQ有两种方式处理无法路由的消息,一种是让服务端重发生产者,一种是让交换机路由到另一个备份的交换机。

3.1.消息回发

增加一个监听器处理return事件

public class ReturnListenerProducer {
    public static void main(String[] args) throws Exception{
        ConnectionFactory factory = new ConnectionFactory();
        factory.setUri(ResourceUtil.getKey("rabbitmq.uri"));

        Connection connection = factory.newConnection();

        Channel channel = connection.createChannel();

        channel.addReturnListener(new ReturnListener() {
            public void handleReturn(int replyCode,
                                     String replyText,
                                     String exchange,
                                     String routingKey,
                                     AMQP.BasicProperties properties,
                                     byte[] body)
                    throws IOException {
                System.out.println("=========监听器收到了无法路由,被返回的消息============");
                System.out.println("replyText:"+replyText);
                System.out.println("exchange:"+exchange);
                System.out.println("routingKey:"+routingKey);
                System.out.println("message:"+new String(body));
            }
        });

        AMQP.BasicProperties properties = new AMQP.BasicProperties.Builder().deliveryMode(2).
                contentEncoding("UTF-8").build();
 
        // 发送到了默认的交换机上,由于没有任何队列使用这个关键字跟交换机绑定,所以会被退回
        // 第三个参数是设置的mandatory,如果mandatory是false,消息也会被直接丢弃
        channel.basicPublish("","gpdirect",true, properties,"只为更好的你".getBytes());

        TimeUnit.SECONDS.sleep(10);

        channel.close();
        connection.close();
    }
}

SpringBoot的处理方式:使用mandatory参数和ReturnCallback:

public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
        RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
        rabbitTemplate.setMandatory(true);
        rabbitTemplate.setReturnCallback(new RabbitTemplate.ReturnCallback(){
            public void returnedMessage(Message message,
                                        int replyCode,
                                        String replyText,
                                        String exchange,
                                        String routingKey){
                System.out.println("回发的消息:");
                System.out.println("replyCode: "+replyCode);
                System.out.println("replyText: "+replyText);
                System.out.println("exchange: "+exchange);
                System.out.println("routingKey: "+routingKey);
            }
        });

        rabbitTemplate.setChannelTransacted(true);

        rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() {
            public void confirm(CorrelationData correlationData, boolean ack, String cause) {
                if (!ack) {
                    System.out.println("发送消息失败:" + cause);
                    throw new RuntimeException("发送异常:" + cause);
                }
            }
        });
        
        return rabbitTemplate;
    }

3.2. 消息路由到备份交换机的方式

在创建交换机的时候,从属性中指定备份交换机

//         在声明交换机的时候指定备份交换机
        Map<String,Object> arguments = new HashMap<String,Object>();
        arguments.put("alternate-exchange","ALTERNATE_EXCHANGE");
        channel.exchangeDeclare("TEST_EXCHANGE","topic", false, false, false, arguments);

队列可以指定死信队列,交换机也可以指定备份交换机。

4.消息在队列中存储

如果发生系统宕机,重启可能导致内存消息消失,这就要把消息本身和元数据(队列,交换机,绑定)都保存到磁盘。
解决方案:

4.1 队列持久化

在这里设置

  channel.queueDeclare(QUEUE_NAME, false, false, false, null);

我们看一下queueDeclare的定义:

    Queue.DeclareOk queueDeclare(String queue, boolean durable, boolean exclusive, boolean autoDelete,
                                 Map<String, Object> arguments) throws IOException;

durable:false时没有持久化的对垒,保存在内存中,重启后队列和消息都消失。
autoDelete:没有消费者连接的时候,自动删除
exclusive:排他性队列,特点是只对首次声明它的连接可见,并在连接断开的时候自动删除。

4.2 交换机持久化

这个主要基于SpringBoot添加RabbitTemplate的配置来实现:

  @Bean("gpexchange")
    public DirectExchange exchange() {
        return new DirectExchange("GP_RELIABLE_RECEIVE_EXCHANGE", true, false, new HashMap<>());
    }

4.3 消息持久化

重点是.deliveryMode(2) 这个设置来持久化的。

public class TTLProducer {

    public static void main(String[] args) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setUri(ResourceUtil.getKey("rabbitmq.uri"));

        // 建立连接
        Connection conn = factory.newConnection();
        // 创建消息通道
        Channel channel = conn.createChannel();

        String msg = "Hello world, Rabbit MQ, DLX MSG";

        // 通过队列属性设置消息过期时间
        Map<String, Object> argss = new HashMap<String, Object>();
        argss.put("x-message-ttl",6000);

        // 声明队列(默认交换机AMQP default,Direct)
        // String queue, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments
        channel.queueDeclare("TEST_TTL_QUEUE", false, false, false, argss);

        // 对每条消息设置过期时间
        AMQP.BasicProperties properties = new AMQP.BasicProperties.Builder()
                .deliveryMode(2) // 持久化消息
                .contentEncoding("UTF-8")
                .expiration("10000") // TTL
                .build();

        // 此处两种方式设置消息过期时间的方式都使用了,将以较小的数值为准

        // 发送消息
        channel.basicPublish("", "TEST_TTL_QUEUE", properties, msg.getBytes());

        channel.close();
        conn.close();
    }
}

4.4 集群

为了提高MQ服务的可用性,集群是最有效的方式,这里暂时不分析。

5. 消息投递到消费者

如果消费者收到消息后没得及处理就发生异常,会导致④失败。服务端应以某种方式得知消费者对消息的接收情况,并决定是否重新投递。
RabbitMQ提供了消费者的消息确认机制,消费者可以自动或者手动地发送ACK给服务端。
如果没有ACK又该如何处理呢?
没有收到ACK的消息,消费者断开连接后,mq会将其发送给其他消费者。如果没有消费者,就等消费者重启后重发。

消费者如何给Broker应答呢?两种方式,一种是自动ACK,一种是手动ACK。默认是自动应答,可以配置。

那消费者如何调用ACK,或者怎么获得Channel的参数呢?
在SpringBoot中通过下面的代码:

public class SecondConsumer {
    @RabbitHandler
    public void process(String msgContent,Channel channel, Message message) throws IOException {
        System.out.println("Second Queue received msg : " + msgContent );

        channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
    }
}

如果消费出了问题,确实不能发送ACK了怎么办?有拒绝消息的指令,而且还可以让消息重新入队给其他消费者处理。
如果消息无法处理或者消费失败,也有两种拒绝的方式,单条和批量。

6.消费者回调

从生产者到Broker,交换机到队列,队列本身,队列到消费者,都有响应的方法知道msg是否正常流转。但是服务端收到ack或者nack之后,生产者知道吗?根据经验是不知道的。但是如果为了保证一致性,生产者必须知道消费者有没有成功消费,怎么办?
这个是需要从业务层面来进行,两种方式:
(1)回调,消费者收到消息,处理完毕后,调用生产者的API。
(2)消费者发送一条响应消息给生产者。

7.补偿机制

如果生产者的API没有被调用,也没有收到消费者的响应消息,该如何做?
这时候可以稍微等一下,可能是消费者处理时间太长或者网络超时,超时之后还没有得到响应的消息才确定为失败,消费失败以后重发消息。
但是问题又来了,谁来发,多久发一次,一共发几次,发一模一样的消息吗?消费者如何进行幂等呢?

7.1 谁来发

实际的发送方是业务人员,对于异步操作,发完工作就结束了,所以肯定不是业务发进行重发的。
此时可以创建一个定时任务,找到这些中间状态的记录,查出来之后构建为MQ,重新发送,这种方式用的最多。
也可以单独设计一张消息表,将中间状态的消息异步登记起来,找机会发送,这种方式有点鸡肋,我遇到过的系统中没有见过这么做的。
其实还有一种情况就是定时执行一直不行之后还是要给上层返回错误的,例如笔者在做一个结算系统中就遇到这种情况,如果服务不可用,再重试也无用,此时就需要人员重做结算,此时将失败信息记录起来,然后重新结算。

7.2 多久发一次

这个可以由业务根据情况灵活设置,没有固定值,可以按照恒定间隔执行,也可以设置衰减期,例如先一分钟一次,之后2分钟,再5分钟等等。
这个可以在定时任务中设置

7.3 一共重发几次

这个也可以设置,如果服务不可用而大量发送,会产生大量无效数据导致MQ消息堆积,一般设置为3~5次就够了。
这个要在消息表里记录次数来实现,发一次就加1.

7.4 重发什么内容

肯定不能发一模一样的消息,不然消费端因为无法区分而导致幂等性问题,至少应该个加个时间戳或者id之类的来区分吧。
不过RabbitMQ除了同一批次的消息有个DeliveryTag外,没有这种完整的防重复设置,也不知道什么才是重复的消息,这需要消费端来处理。
可以参考 https://www.cnblogs.com/ybyn/p/13691058.html,使用异常队列或者死信队列来做,通常更多的是通过业务端来控制。

8.幂等性

消费端为了避免重复消息处理,必须做一定的措施,首先来看消息出现重复的原因:
1.生产者重复发,比如在开启了confirm模式但是未收到确认,导致生产者重复发送。
2.消息发送给消费者时,由于消费者未发送确认指令等,导致mq重发。
3.生产者代码或者网络等出现问题。
对于重复发送的消息,可以对每一条消息生成一个唯一的业务ID,通过日志或者消息落库来做重复控制。
例如在微信支付中,如果业务要素(付款人ID,商户ID,交易类型,金额,交易地点,交易时间,订单ID等)一致,可能就是同一笔交易,这时候可以通过给出提示等防重,例如:
在这里插入图片描述
一般在消费端会通过redis缓存,数据库等多级方式来实现幂等性,后面文章专门处理梳理该问题。

9 最终一致性

如果确实是消费者宕机或者服务有问题无法消费,该如何做呢?此时一直等待或者一直重发是无意义的。
这时候需要业务来做的,例如通过双方的对账系统来发现问题订单,然后通过平账等方式处理,必要的时候,需要客户介入来处理。

10. 消息的顺序性

消息的顺序性指的是消费者消费消息的顺序跟生产者生产消息是一致的。
在RabbitMQ中,一个队列有多个消费者时无法保证每个消费者的顺序的,只有一对一的时候才可以。RabbitMQ的机制比较暴力:拆分多个 queue,每个 queue 对应一个 consumer(消费者),就是多一些 queue 而已,确实是麻烦点;或者就一个 queue 但是对应一个 consumer,然后这个 consumer 内部用内存队列做排队,然后分发给底层不同的 worker 来处理。

.

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

纵横千里,捭阖四方

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值