消息中间件RabbitMQ(四):高级特性

RabbitMQ高级

持久化

持久化可以提高RabbitMQ的可靠性,以防在异常情况(重启、宕机)下的数据丢失,RabbitMQ的持久化分为三个部分:交换器的持久化、队列的持久化和消息的持久化。

交换器的持久化
交换器的持久化是通过声明交换器时将durable参数设置为true实现的,如果交换器不设置持久化,RabbitMQ服务重启之后,交换器会丢失,不过消息不会丢失。

队列的持久化
队列的持久化是通过声明队列时将durable参数设置为true实现的,如果队列不设置持久化,那么RabbitMQ服务重启之后,队列和队列里的信息都会丢失。

消息的持久化
队列的持久化能保证其本身不会因为异常情况丢失,但是并不能保证内部所存储的消息不会丢失。要确保消息不会丢失,需要将消息设置为持久化,消息的持久化通过设置BasecProperties中的deliveryMode为2即可实现。

单单设置队列的持久化,服务重启后消息会丢失。单单设置消息的持久化,重启之后队列丢失,继而消息也丢失。所以队列的持久化和消息的持久化一般要同时设置。

注意:
当将所有消息都设置为持久化时,会严重影响RabbitMQ性能,写入磁盘的速度比写入内存要慢得多。对于可靠性不那么高的消息可以不采用持久化处理以提高整体的吞吐量,选择是否要将消息持久化时,需要在可靠性和吞吐量做一个权衡。

那么这里有一个问题,将交换器、队列、消息都设置了持久化之后就能百分百保证数据不丢失了嘛?答案是否定的。

首先消费者如果在订阅队列时将autoAck参数设置为true,那么当消费者接收到消息还没来得及处理就宕机了,这样也算消息丢失。这种情况只需要将autoAck设置为false,手动确认即可。

其次在消息存入RabbitMQ队列之后,还需要一段小段时间才能写入磁盘中。RabbitMQ不会为每条消息都进行同步存盘的处理,可能暂时仅仅保存到操作系统缓存之中而不是直接保存到物理磁盘中。这时如果RabbitMQ服务出现异常,消息还没来得及存盘,消息就会丢失。这个问题可以使用RabbitMQ服务主从节点来解决。

生产者确认

使用RabbitMQ时,可以通过消息持久化来解决因为服务器异常而导致的消息丢失。但我们还有一个问题,那就是当生产者将消息发送出去以后,消息到底有没有正确地到达服务器呢?如果不进行特殊配置,默认情况下发送消息的操作是不会返回任何消息给生产者的,也就是默认情况下生产者不知道消息有没有正确的到达服务器,如果消息在到达服务器之前就已经丢失,那么持久化操作也无法解决这个问题,因为消息根本没到达服务器。那么如何解决这个问题呢?

针对这个问题,RabbitMQ提供了两种解决方式。

  • 通过事务机制实现
  • 通过发送方确认(publisher confirm)机制实现

事务机制

RabbitMQ客户端中与事务机制相关的有三个方法。

	//用于将当前通道设置成事务模式
    Tx.SelectOk txSelect() throws IOException;
    
    //用于提交事务
    Tx.CommitOk txCommit() throws IOException;
    
    //用于事务回滚
    Tx.RollbackOk txRollback() throws IOException;

在通过channel.txSelect方法开启事务之后,我们就可以发送消息给RabbitMQ了,如果事务提交成功,则消息一定到达了RabbitMQ中,如果在事务提交执行之前由于RabbitMQ出现异常,这时我们便可以将其捕获,进而通过channel.txRollback方法来实现事务回滚。
如下例:

    public static void main(String[] args) throws IOException, TimeoutException, NoSuchAlgorithmException, KeyManagementException, URISyntaxException {
        ConnectionFactory connectionFactory=new ConnectionFactory();
        connectionFactory.setUri("amqp://admin:[email protected]:5672");
        Connection connection=connectionFactory.newConnection();
        Channel channel = connection.createChannel();
        channel.exchangeDeclare("TXexchange","direct",false,true,null);
        channel.queueDeclare("TXqueue",false,false,true,null);
        channel.queueBind("TXqueue","TXexchange","TXkey");
        try {
        	//开启事务模式
            channel.txSelect();
            //发送消息
            channel.basicPublish("TXexchange", "TXkey",
                    com.rabbitmq.client.MessageProperties.PERSISTENT_TEXT_PLAIN
                    , "hello world".getBytes());
            //制造异常
            int result=1/0;
            //提交事务
            channel.txCommit();
        }catch (Exception e){
            e.printStackTrace();
            //出现异常回滚
            channel.txRollback();
        }

    }

事务机制的步骤

  • (1)客户端发送txSelect,将通道设置为事务模式
  • (2)RabbitMQ服务器回复Tx.SelectOk,确认已将通道设置为事务模式
  • (3)发送完消息后,客户端发送commit提交事务
  • (4)RabbitMQ服务器回复Tx.CommitOk,确认事务提交成功。
  • (5)如果在事务提交之前出现了异常,我们手动执行txRollback回滚,RabbitMQ服务器回复Tx.RollbackOk表示回滚成功。

事务机制确实能够解决生产者和RabbitMQ之间的消息确认的问题,只有消息成功被RabbitMQ接收,事务才能提交成功,否则便可在捕获异常时进行事务回滚,然后可以进行重发等操作。但是事务机制比较消耗性能,为此,RabbitMQ提供了一个改进方案,即发送方确认机制。

发送方确认机制

生产者将通道设置为confirm模式,一旦通道进入confirm模式,所有在该通道上面发布的消息都会被指派一个唯一的ID(从1开始),一旦消息被投递到所有匹配的队列后,RabbitMQ就会发送一个确认(ack)给生产者(包含消息的唯一ID),这就使得生产者知道消息已经正确到达队列。如果消息和队列是可持久化的,那么确认消息会在消息写入磁盘后发出。RabbitMQ回传给生产者的确认消息中的deliveryTag包含了确认消息的序号,此外RabbitMQ也可以设置channel.basicAck方法中的multiple参数,表示到这个序号之前的所有消息都已经得到了处理。

事务机制在一条消息发送之后会使发送端阻塞,以等待RabbitMQ的回应,之后才能继续发送下一条消息。相比之下,发送方确认机制好处在于是异步的,一旦发布一条消息,生产者就可以在等通道返回确认的同时继续发送其他消息,如果RabbitMQ 因为自身内部错误导致消息丢失,就会发送一条nack命令,生产者可以在回调函数中处理该nack命令。

生产者通过调用channel.confirmSelect方法将通道设置为confirm模式,之后RabbitMQ会返回Confirm.Select-OK命令表示同意生产者将当前通道设置为confirm模式。所有被发送的后续消息都被ack或者nack一次,不会出现一条消息既被ack又被nack的情况,但是RabbitMQ也并没有对消息做confirm的快慢做任何保证。

      public static void main(String[] args) throws IOException, TimeoutException, NoSuchAlgorithmException, KeyManagementException, URISyntaxException {
        ConnectionFactory connectionFactory=new ConnectionFactory();
        connectionFactory.setUri("amqp://admin:[email protected]:5672");
        Connection connection=connectionFactory.newConnection();
        Channel channel = connection.createChannel();
        channel.exchangeDeclare("Confirmexchange","direct",false,true,null);
        channel.queueDeclare("Confirmqueue",false,false,true,null);
        channel.queueBind("Confirmqueue","Confirmexchange","key");
        try {
            //将通道设置为生产者确认模式
            channel.confirmSelect();
            for (int i=0;i<5;i++) {
            	//发送消息
                channel.basicPublish("Confirmexchange", "key",
                        com.rabbitmq.client.MessageProperties.PERSISTENT_TEXT_PLAIN
                        , "hello world".getBytes());
                //判断消息是否发送失败
                if (!channel.waitForConfirms()) {
                    System.out.println("send message fail");
                } else {
                    System.out.println("消息发送成功");
                }
            }
        }catch (Exception e){
            e.printStackTrace();
        }
    }

消息中间件RabbitMQ(四):高级特性_第1张图片

channel.waitForConfirms是确认消息是否成功发送的方法。在RabbitMQ客户端有四个同类型的方法。如果通道没有开启confirm模式,则调用这四个方法都会报IllegalStateException异常。

//等待所有消息都收到了ack或nack确认则返回真
boolean waitForConfirms() throws InterruptedException;

//在指定超时时间内等待所有消息都收到了ack或nack确认则返回真,如果等待RabbitMQ服务回应超时则抛出TimeOut异常
boolean waitForConfirms(long timeout) throws InterruptedException, TimeoutException;

//两个waitForConfirmsOrDie方法在接收到返回的nack之后会抛出IOException
void waitForConfirmsOrDie() throws IOException, InterruptedException;


void waitForConfirmsOrDie(long timeout) throws IOException, InterruptedException, TimeoutException;

业务代码可以根据这几个方法的不同特性来灵活的运用来保障消息的可靠发送。

事务机制和confirm机制是互斥的,不能共存,两者同时开启RabbitMQ会报错。

事务机制和confirm机制确保的是消息能够正确的发送至RabbitMQ的交换器,如果此交换器没有匹配的队列,那么消息也会丢失,所以在使用的时候要确保涉及的交换器能够有匹配的队列。

confirm机制的优势在于并不一定需要同步确认,我们上面的那个例子是同步确认的,每发送一条消息,就调用waitForConfirms进行确认。接下来我们改进一下使用方法。

批量confirm方法

批量confirm方法,每发送一批消息后,调用channel.waitForConfirms方法,等待服务器的确认返回,客户端需要定期或者定量来调用channel.waitForConfirms来等待RabbitMQ的确认返回,相对于前面的每发送一条确认一条,批量极大的提升了confirm的效率,但是也有相应的问题,比如当出现返回nack或者超时的情况时,客户端就需要将这一批的数据全部重发,因为我们无法确认是哪些消息出了问题,这会带来明显的重复消息数量,并且当消息经常丢失时,批量confirm的性能应该是更为低下。

如果我们要使用批量confirm,最好是将每一批消息放入缓存中,如果发送成功了,则清除缓存,如果失败,则将缓存中的那批数据全部重发。

    public static void main(String[] args) throws IOException, TimeoutException, NoSuchAlgorithmException, KeyManagementException, URISyntaxException {
        ConnectionFactory connectionFactory=new ConnectionFactory();
        connectionFactory.setUri("amqp://admin:[email protected]:5672");
        Connection connection=connectionFactory.newConnection();
        Channel channel = connection.createChannel();
        channel.exchangeDeclare("BatchConfirmexchange","direct",false,true,null);
        channel.queueDeclare("BatchConfirmqueue",false,false,true,null);
        channel.queueBind("BatchConfirmqueue","BatchConfirmexchange","key");
        try {
            channel.confirmSelect();
            for (int i=0;i<5;i++) {
                channel.basicPublish("BatchConfirmexchange", "key",
                        com.rabbitmq.client.MessageProperties.PERSISTENT_TEXT_PLAIN
                        , "hello world".getBytes());
            }
            //全部发送完之后再批量confirm
            if (!channel.waitForConfirms()) {
                System.out.println("send message fail");
            } else {
                System.out.println("消息发送成功");
            }
        }catch (Exception e){
            e.printStackTrace();
        }
    }

异步confirm

异步confirm则能很好的解决上述的一些问题,异步confirm的编程实现最为复杂,在客户端channel接口提供的addConfirmListener方法可以添加ConfirmListener这个监听器接口,这个接口中包含两个方法:handleAck和handleNack,分别用来处理RabbitMQ回传的ack和nack。在这两个方法中都包含有一个参数deliveryTag(标记消息的唯一有序序号)。我们可以创建一个集合,每发送一条消息则往集合中添加一条,如果收到ack则在handleAck方法中移除,收到nack则重新发送。

 public static void main(String[] args) throws IOException, TimeoutException, NoSuchAlgorithmException, KeyManagementException, URISyntaxException {
        ConnectionFactory connectionFactory=new ConnectionFactory();
        connectionFactory.setUri("amqp://admin:[email protected]:5672");
        Connection connection=connectionFactory.newConnection();
        Channel channel = connection.createChannel();
        channel.exchangeDeclare("SyncConfirmexchange","direct",false,true,null);
        channel.queueDeclare("SyncConfirmqueue",false,false,true,null);
        channel.queueBind("SyncConfirmqueue","SyncConfirmexchange","key");
        channel.confirmSelect();
        for (int i=0;i<5;i++) {
            channel.basicPublish("SyncConfirmexchange", "key",
                    com.rabbitmq.client.MessageProperties.PERSISTENT_TEXT_PLAIN
                    , "hello world".getBytes());
        }
        //异步监听确认和未确认的消息
        channel.addConfirmListener(new ConfirmListener() {
            @Override
            public void handleNack(long deliveryTag, boolean multiple) throws IOException {
                System.out.println("未确认消息,标识:" + deliveryTag);
            }
            @Override
            public void handleAck(long deliveryTag, boolean multiple) throws IOException {
                System.out.println(String.format("已确认消息,标识:%d,多个消息:%b", deliveryTag, multiple));
            }
        });

    }

消息分发

当队列拥有多个消费者时,队列收到的消息将以轮询的方式发送给消费者,每条消息只会发送给订阅列表里的一个消费者。

但是很多时候轮询机制并不能很好的满足需求。比如有的服务器性能好,有的性能差,还按照轮询的方式的话,就会造成整体应用吞吐量的下降。我们可以使用channel.basicQos(int prefetchCount)这个方法来限制通道上的消费者所能保持的最大未确认消息的数量。

例如:在订阅队列之前,消费者调用channel.basicQos(5)方法,之后订阅队列进行消费。RabbitMQ会保存一个消费者的列表,每发送一条消息都会为对应的消费者计数,如果达到了所设定的上限,那么RabbitMQ就不会向这个消费者再发送消息。消费者确认一条消息后,RabbitMQ就会将计数减一。
channel.basicQos对于拉模式的消费方式无效。

void basicQos(int prefetchSize, int prefetchCount, boolean global) throws IOException;

void basicQos(int prefetchCount, boolean global) throws IOException;

void basicQos(int prefetchCount) throws IOException;

该方法有三个重载方法,prefetchCount设置为0时代表没有上限。prefetchSize参数表示消费者所能接收未确认消息的总体大小的上限,单位为B,设置为0则表示没有上限。

一个通道能同时消费多个队列,当设置了prefetchCount>0时,这个信道需要和各个队列协调以确保发送的消息没有超过所限定的prefetchCount值,这样会使RabbitMQ的性能降低,尤其是当这些队列分散在集群中的多个broker节点的时候,RabbitMQ为了提升性能,定义了global这个参数,该参数为是否限制整个通道。当global为false时,每个个消费者需要遵从prefetchCount限定值。当global为true时,整个通道上需要遵从prefetchCount限定值,例如开启了global为true,设置了basicQos为10,那么整个通道上所有的消费者合起来所能接收未确认消息的数量为10。

如果在订阅消息之前,即设置了global为true,又设置了false,会怎么样呢?

channel.basicQos(3,false);
channel.basicQos(5,true);

RabbitMQ会保证两者都会生效,按我们上面设置的代码为例,当前有两个队列,queue1有十条消息,分别为1-10.queue2也有十条消息,分别为11-20.有两个消费者分别消费这两个队列。

但是这里设置了每个消费者最多只能收到三个未确认的消息,并且整个信道多只能收到五个未确认的消息,所以两个消费者最多也只能收到五个未确认的消息。如果consumer1收到了未确认消息1,2,3,那么consumer2最多也就只能收到11,12.

消息传输保障

一般消息中间件的消息传输保障分为三个层级。

  • At most once:最多一次。消息可能会丢失,但是绝不会重复传输
  • At least once:最少一次。消息绝不会丢失,但可能会重复传输
  • Exactly once:刚好一次。每条消息肯定会被传输一次且仅传输一次

RabbitMQ支持其中的最多一次和最少一次。
最少一次的的实现需要考虑以下几方面的内容:
(1)生产者需要开启事务机制或者publisher confirm机制,以确保消息可以可靠地传输到RabbitMQ中。
(2)生产者需要配合使用mandatory参数或者备份交换器来确保消息能够从交换器路由到队列中,进而能保存下来而不会被丢弃。
(3)消息和队列都需要进行持久化处理,以确保RabbitMQ服务器遇到异常情况时不会造成消息丢失。
(4)消费者需要将autoAck设置为false,通过手动确认地方式去确认已经消费地消息,避免在消费端造成消息丢失。

最多一次就无需考虑什么,生产者发送,消费者消费,但是可能会造成消息地丢失。

恰好一次是RabbitMQ目前无法保障的。考虑以下几种情况,消费者在消费完一条消息之后向RabbitMQ发送确认ack消息,此时由于网络原因或者其他原因造成RabbitMQ没有收到ACK命令,那么RabbitMQ就不会删除这条消息,在重新建立连接后,消费者会再次消费到这条消息,造成了重复消费。

解决重复消费
如何解决重复消费的情况呢?

造成消息重复的绝大部分原因就是因为网络不可达的问题,只要通过网络交换数据,就无法避免这个问题,所以我们需要换一种思路来解决,如果消费端收到了重复消息,应该怎么解决?

第一种解决办法就是消费端保证处理消息的业务逻辑保持幂等性,也就是说无论来多少条重复的消息,最终的结果都是一样的。例如拿到消息后做插入操作,那么我们可以做一个唯一主键,插入的时候就会造成主键冲突。或者是Redis的set操作,也是不论做多少次都无所谓的。

第二种是准备一个第三方存储,来做消费记录。
以redis为例,给消息分配一个全局id,
只要消费过该消息,将以K-V形式写入redis。那消费者开始消费前,先去redis中查询有没消费记录即可。

或者可以保证每条消息都有唯一编号且保证消息处理成功与去重表的日志同时出现。原理就是利用一张日志表来记录已经处理成功的消息的 ID,如果新到的消息 ID 已经在日志表中,那么就不再处理这条消息。

RabbitMQ 不保证消息不重复,如果你的业务需要保证严格的不重复消息,需要你自己在业务端去重

消息顺序性

消息的顺序性是指消费者消费到的消息和发送者发布的消息的顺序是一致的。

例如消息1、2、3发送到一个队列,分别为创建,修改,删除的消息,多个消费者消费该队列,这时候是不能保证哪个消费者先消费的,如果顺序错了就会造成数据库数据错乱。如下图

消息中间件RabbitMQ(四):高级特性_第2张图片

我们的解决方案是拆分多个queue,每个queue对应一个consumer,然后由一个consumer来处理一系列操作。解决思路就是一个队列对应一个consumer,做专门的事情
消息中间件RabbitMQ(四):高级特性_第3张图片

但其实RabbitMQ保障消息的顺序性有很大的局限性。例如:

如果生产者发送的消息设置了不同的超时时间,并且也设置了死信队列,整体上相当于一个延迟队列。那么消费者在消费这个延迟队列时,消费的顺序可能会与生产者发送消息的顺序不一致,这个需要调整超时时间可以解决。

如果一个队列有1、2、3、4四条消息,有consumer1和consumer2两个消费者订阅了这个队列,队列中的消息轮询发送到了各个消费者,consumer1收到1消息后,不想处理调用了拒绝方法,并且设置requeue为true,那么这条消息就会被重新存放队列中等待下次消费,这样也造成了顺序错乱。还有各种乱七八糟的情况

如果要保障消息的绝对顺序性,最好还是在业务代码中进行处理。

消息堆积怎么处理?

消费堆积从技术框架的本身去解决是不够的,消息堆积主要原因:
(1).消费者的速度大大慢于生产者的速度,速度不匹配从引起的堆积。
(2).消费者实例IO阻塞严重或者挂机
(3).消费者故障期间消息的堆积。
单从增加消费者数是远远不够。之所以要处理消息堆积,是为了防止消息堆积所引起MQ的异常,所以在所有MQ的业务场景,消息如果是重要的,不容丢弃时,需要有备选方案,可以采用数据转移,增加中间缓冲技术。

这里有个处理消息堆积的实例:
https://blog.csdn.net/xlgen157387/article/details/86606325

你可能感兴趣的:(RabbitMQ)