RabbitMQ 持久化 可靠性投递 可靠性消费(2)
默认情况下,exchange、queue、message 等数据都是存储在内存中的,这意味着如果 RabbitMQ 重启、关闭、宕机时所有的信息都将丢失。
RabbitMQ 提供了持久化来解决这个问题,持久化后,如果 RabbitMQ 发送 重启、关闭、宕机,下次起到时 RabbitMQ 会从硬盘中恢复exchange、queue、message 等数据。
持久化
RabbitMQ 持久化包含3个部分:
- exchange 持久化,在声明时指定 durable 为 true
- queue 持久化,在声明时指定 durable 为 true
- message 持久化,在投递时指定 delivery_mode=2(1是非持久化)
queue 的持久化能保证本身的元数据不会因异常而丢失,但是不能保证内部的 message 不会丢失。要确保 message 不丢失,还需要将 message 也持久化
如果 exchange 和 queue 都是持久化的,那么它们之间的 binding 也是持久化的。
如果 exchange 和 queue 两者之间有一个持久化,一个非持久化,就不允许建立绑定。
注意:一旦确定了 exchange 和 queue 的 durable,就不能修改了。如果非要修改,唯一的办法就是删除原来的 exchange 或 queue 后,重现创建
拓展
如果将所有的消息都进行持久化操作,这样会严重影响 RabbitMQ 的性能。写入磁盘的速度可比写入内存的速度要慢很多。所以需要在可靠性和吞吐量之间做权衡。
将 exchange、queue 和 message 都进行持久化操作后,也不能保证消息一定不会丢失,消息存入RabbitMQ 之后,还需要一段时间才能存入硬盘。RabbitMQ 并不会为每条消息都进行同步存盘,如果在这段时间,服务器宕机或者重启,消息还没来得及保存到磁盘当中,就会丢失。对于这种情况,可以引入 RabbitMQ 镜像队列机制。
代码实现
通过代码实现 RabbitMQ 持久化
原生的实现方式
原生的 RabbitMQ 客户端需要完成三个步骤。
第一步,设置交换器的持久化
// 三个参数分别为 交换器名、交换器类型、是否持久化
channel.exchangeDeclare(EXCHANGE_NAME, "topic", true);
第二步,设置队列的持久化
// 参数1 queue :队列名
// 参数2 durable :是否持久化
// 参数3 exclusive :仅创建者可以使用的私有队列,断开后自动删除
// 参数4 autoDelete : 当所有消费客户端连接断开后,是否自动删除队列
// 参数5 arguments
channel.queueDeclare(QUEUE_NAME, true, false, false, null);
第三步,设置消息的持久化
// 参数1 exchange :交换器
// 参数2 routingKey : 路由键
// 参数3 props : 消息的其他参数,其中 MessageProperties.PERSISTENT_TEXT_PLAIN 表示持久化
// 参数4 body : 消息体
channel.basicPublish("", queue_name, MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes());
RabbitMQ的工作模式消息的可靠性投递与消费
RabbitMQ的工作模式
RabbitMQ的工作模式,根据它的工作模式,一条消息从生产者发出,到消费者消费,需要经历以下4个步骤:
生产者将消息发送给RabbitMQ的Exchange交换机;
Exchange交换机根据Routing key将消息路由到指定的Queue队列;
消息在Queue中暂存,等待消费者消费消息;
消费者从Queue中取出消息消费。
通过这种工作模式,很好地做到了两个系统之间的解耦,并且整个过程是一个异步的过程,producer发送消息后就可以继续处理自己业务逻辑,不需要同步等待consumer的消费结果。
但任何一项技术的引入,除了带来它自身的优点之外,必然也会带来其他的一些缺点。MQ消息中间件虽然可以做到系统之间的解耦以及异步通信,但可能会存在消息丢失的风险。
该工作模式存在的问题——消息可能丢失
什么是消息丢失呢?简单来说,就是producer发送了一条消息出去,但由于某种原因(比如RabbitMQ宕机了),导致consumer没有消费到这条消息,最终导致producer与consumer两个系统的数据与期望结果不一致。
1.保证producer发送消息到RabbitMQ broker的可靠性
我们知道,producer发送消息到broker的过程中,丢失消息的原因是producer发送完消息之后,就主观认为消息发送成功了,即使RabbitMQ发生故障导致没有接收到消息,producer也是无法知道的。所以,要保证producer发出去的消息100%被broker接收成功,我们需要让producer发送消息后知道一个结果,然后根据这个结果再做相应的处理。
RabbitMQ提供了两种方式来达到这一目的:一种是Transaction(事务)模式,一种是Confirm(确认)模式。
channel 的 Transaction模式
Transaction模式类似于我们操作数据库的操作,首先开启一个事务,然后执行sql,最后根据sql执行情况进行commit或者rollback。
RabbitMQ 中与事务机制有关的方法有三个
- txSelect:用于将当前 channel 设置成 transaction 模式
- txCommit:用于提交事务
- txRollback:用于回滚事务
在通过 txSelect 开启事务之后,我们便可以发布消息给broker代理服务器了,如果txCommit提交成功了,则消息一定到达了broker了,如果在 txCommit 执行之前 broker 异常崩溃或者由于其他原因抛出异常,这个时候我们便可以捕获异常通过 txRollback 回滚事务了
事务确实能够解决 producer 与 broker 之间消息确认的问题,只有消息成功被 broker 接收,事务提交才能成功,否则我们便可以在捕获异常进行事务回滚操作,同时进行消息重发,但是使用事务机制的话会降低RabbitMQ的性能。
try{
channel.txSelect();
channel.basicPublish("exchangeName","routingKey",false,null,message.getBytes())\
int i = 1/0; // 模拟broker 发生故障导致异常
channel.txCommit();
}catch(Exception e){
channel.txRollback();
}
Transaction模式虽然可以保证消息从producer到broker的可靠性投递,但它的缺点也很明显,它是阻塞的,只有当一条消息被成功发送到RabbitMQ之后,才能继续发送下一条消息,这种模式会大幅度降低RabbitMQ的性能,不推荐使用。
channel 的 Confirm模式(推荐)
RabbitMQ 提供了一个更好的方案,使用 channel 信道的 confirm 模式。
生产者通过调用 channel 的 confirmSelect 方法将 channel 设置为 confirm 模式.该模式下,所有在该信道上发布的消息都会被分派一个唯一的ID(从1开始),当消息被路由到所有匹配的Queue队列后,broker 就会发送一个(包含消息的唯一 ID 的)确认(ack)给producer发送端( 如果是持久化的消息,那么这个确认(ack)会在RabbitMQ将这条消息写入磁盘之后发出),这样producer就可以知道消息已经成功到达Queue队列了。 如果 RabbitMQ 因为自身内部错误导致消息丢失,就会发送一条不确认(nack)消息,nack消息中也会包含producer发布的消息唯一ID,producer接收到nack的消息之后,可以针对发布失败的消息做相应处理,比如重新发布等。
broker回传给发送端的确认消息中 deliver-tag 域包含了确认消息的ID,此外 broker 也可以设置 basic.ack 的 multiple 域,表示到这个ID之前的所有消息都已经得到了处理
confirm模式最大的好处在于他是异步的,生产者可以在等信道返回的同时继续发送下一条消息。
“当消息被投递到所有匹配的队列后,broker 就会发送一个(包含消息的唯一 ID 的)确认给发送端”,万一发送确认后, rabbitMq 崩溃了,消息队列中的消息就都没了,这时候发送端还以为消息还在队列中。
为了防止这种情况的发送,rabbitMq 需要对队列和消息进行持久化。
当消息和队列开启持久化之后,确认信息会等到消息写入磁盘之后再发出
Confirm 的三种使用方式
-
普通确认:每发送一条消息后,调用channel.waitForConfirms()方法,同步等待服务器端confirm。实际上是一种串行confirm了。
channel.confirmSelect(); channel.basicPublish("exchangeName","routingKey",false,null,message.getBytes()); if(channel.waitForConfirms()){ // 消息发送成功 }else{ // 消息确认识别,进行消息重发 }
这种方式实际上是一种同步等待的方式,只有当一条消息被确认之后,才能发送下一条消息,所以,实际使用中不推荐这种方式。
-
批量确认:每发送一批消息后,调用channel.waitForConfirms()方法,同步等待服务器端confirm
channel.confirmSelect(); channel.basicPublish("exchange.Name","routingKey",false,null,message1.getBytes()); channel.basicPublish("exchange.Name","routingKey",false,null,message2.getBytes()); channel.basicPublish("exchange.Name","routingKey",false,null,message3.getBytes()); channel.waitForConfirmsOrDie();
waitForConfirmsOrDie()方法会等最后一条消息被确认或者得到nack时才会结束,这种方式虽然可以做到多条消息并行发送,不用互相等待,但最后确认的时候还是通过同步等待的方式完成的,所以也会造成程序的阻塞,并且当有任意一条消息未确认就会抛出异常,实际使用中不推荐这种方式。
-
异步确认:为channel添加一个监听器,rabbitmq 会回调这个方法,示例代码如下
channel.confirmSelect(); for(int i = 0;i < 10; i++){ String msg = "confirmMessage:" + (i + 1); channel.basicPublish("exhangeName","confirm",null,msg.getBytes(StandardCharsets.UTF_8)); } // 异步监听确认和未确认的消息 // 添加一个确认监听 channel.addConfirmListener(new ConfirmListener() { //消息失败处理 @Override public void handleNack(long deliveryTag, boolean multiple) throws IOException { //deliveryTag;唯一消息标签 //multiple:是否批量 System.err.println("-------no ack!-----------"); } //消息成功处理 @Override public void handleAck(long deliveryTag, boolean multiple) throws IOException { System.err.println("-------ack!-----------"); } });
异步确认的方式效率很高,多条消息既可以同时发送,不需要互相等待,又不用同步等待确认结果,只需异步监听确认结果即可,所以,实际使用中推荐使用这种方式。
2. 保证Exchange路由消息到Queue的可靠性
上面分析了,当producer发送消息时,由于小明手抖,导致消息的Routing key是一个不存在的key,从而变相丢失的情况,要如何提前规避掉呢?有两种方式:ReturnListener和使用备胎Exchange交换机。
(1)ReturnListener
ReturnListener是一个监听器,作用于Channel信道上,当producer发送一条消息给RabbitMQ后,如果由于Routing key不存在导致消息不可成功到达Queue队列,RabbitMQ就会将这条消息发送回producer的ReturnListener,在ReturnListener的handleReturn方法中,producer可以针对退回的消息做处理。
要使用ReturnListener,在发送消息时要注意,在basicPublish的方法中有一个mandatory的入参,只有将该参数值设置为true才可以正常使用ReturnListener,否则,当Routing key不存在时,消息会被自动丢弃。核心代码如下:
producer运行上述代码之后,就会打印出ReturnListener中的信息,此时,producer可以针对这条消息做业务处理,比如发送提醒信息给相关人员处理,或者更新状态等。但要注意,这里最好不要重发ReturnListener中的消息,因为导致消息被回退的原因就是消息不可达,如果在ReturnListener中重发这条消息的话,那么就有可能进入一个死循环,重发->退回->重发->退回......
(2)备胎Exchange交换机
除了使用ReturnListener,我们还可以使用备胎交换机的方式来解决Routing key不存在导致消息不可达的问题。所谓备胎交换机,是指当producer发送消息的Routing key不存在导致消息不可达时,自动将这条消息转发到另一个提前指定好的交换机上,这台交换机就是备胎交换机。备胎交换机也有自己绑定的Queue队列,当备胎交换机接到消息后,会将消息路由到自己匹配的Queue队列中,然后由订阅了这些Queue队列的消费者消费。
在开发时,如果要使用备胎交换机,也要在发送消息时,将mandatory参数值设置为true,否则,消息就会由于不可达而被RabbitMQ自动丢弃。核心代码如下:
然后我们运行该程序,然后可以在RabbitMQ控制台看到,消息被成功路由到了备胎交换机绑定的Queue队列:
然后我们开启一个消费者消费该Queue,也可以正常消费到这条原本不可达的消息
3. 保证Queue消息存储的可靠性
消息到达Queue队列之后,在消费者消费之前,RabbitMQ宕机也会导致消息的丢失,所以,为了解决这种问题,我们需要将消息设置成持久化的消息。持久化消息会写入RabbitMQ的磁盘中,RabbitMQ宕机重启后,会恢复磁盘中的持久化消息。
但消息是存储于Queue队列中的,所以,只把消息持久化也不行,也要把Queue也设置成持久化的队列,这样,RabbitMQ宕机重启之后,Queue才不会丢失,否则,即使消息是持久化的,但Queue不是持久化的,那么RabbitMQ重启之后,Queue都不存在了,那么消息也就无处存放,也就相当于没了。
4. 保证consumer消费消息的可靠性
consumer消费消息时,有一个ack机制,即向RabbitMQ发送一条ack指令,表示消息已经被成功消费,RabbitMQ收到ack指令后,会将消息从本地删除。默认情况下,consumer消费消息是自动ack机制,即消息只要到达consumer,就会向RabbitMQ发送ack,不管consumer是否消费成功。所以,为了保证producer与consumer数据的一致性,我们要使用手动ack的方式确认消息消费成功,即在消息消费完成后,通过代码显式调用发送ack。
channel.basicConsume 中 autoAck 设置为false。
首先,我们一起看下实现手动ack的核心代码:
consumer向RabbitMQ发送ack时有三种形式:
(1)reject:表示拒收消息。发送拒收消息时,需要设置一个 requeue 的参数,表示拒收之后,这条消息是否重新回到RabbitMQ的Queue之后,设置为true表示是,false表示否(消息会被删除)。若 requeue 设置为 true,那么消息回归原Queue之后,会被消费者重新消费,这样就会出现死循环,消费->拒绝->回Queue->消费->拒绝->回Queue......所以,一般设置为false。如果设置为true,那么最好限定消费次数,比如同一条消息消费5次之后就直接丢掉。
(2)nack:一般consumer消费消息出现异常时,需要发送nack给MQ,MQ接收到nack指令后,会根据发送nack时设置的requeue参数值来判断是否删除消息,如果requeue为true,那么消息会重新放入Queue队列中,如果requeue为false,消息就会被直接删掉。当requeue设置为true时,为了防止死循环性质的消费,最好限定消费次数,比如同一条消息消费5次之后就直接丢掉。
(3)ack:当consumer成功把消息消费掉后,需要发送ack给MQ,MQ接收到ack指令后,就会把消费成功的消息直接删掉。
补偿机制
经过上面这几个步骤的改造优化,我们的应用程序已经能够保证99.99%场景下消息的可靠性投递与消费了,但由于某些不可控因素,也并不能保证100%的消息可靠性,只有producer明确知道了consumer消费成功了,才能100%保证两边数据的一致性。
因为MQ是异步处理,所以producer是无法通过RabbitMQ知道consumer是否消费成功了,所以,如果要保证两边数据100%一致,consumer在消费完成之后,要给producer发送一条消息通知producer自己消费成功了。
但producer不能一直在那干等着,如果consumer过了1小时还没有发送消息给producer,那么很可能是consumer消费失败了,所以,producer与consumer之间要根据业务场景定义一个反馈超时时间,并在producer后台定义一个定时任务,定时扫描超过指定时间未接收到consumer确认的消息,然后重发消息。重发消息的逻辑中,最好定义一个重发最大次数,比如重发3次后还是不行的话,那可能就是consumer有bug或者发生故障了,就停止重发,等待问题查明再解决。
既然producer可能会重发消息,所以,consumer端一定要做幂等控制(就是已经消费成功的消息不再次消费),要做到幂等控制,消息体中就需要有一个唯一的标识,consumer可以根据这个唯一标识来判断自己是否已经成功消费了这条消息。
要保证 RabbitMQ 的消息可靠性投递,需要做到以下几点
- 消息发送端开启 channel 的 confirm 模式
- 消息发送端异步接收 RabbitMQ 响应
- RabbitMQ 对队列和消息进行持久化
- 消息发送端建立消息投递失败的补偿机制