思维导图
一、分析数据丢失的原因
分析RabbitMQ消息丢失的情况,不妨先看看一条消息从生产者发送到消费者消费的过程:
可以看出,一条消息整个过程要经历两次的网络传输:从生产者发送到RabbitMQ服务器,从RabbitMQ服务器发送到消费者。
在消费者未消费前存储在队列(Queue)中。
所以可以知道,有三个场景下是会发生消息丢失的:
- 存储在队列中,如果队列没有对消息持久化,RabbitMQ服务器宕机重启会丢失数据。
- 生产者发送消息到RabbitMQ服务器过程中,RabbitMQ服务器如果宕机停止服务,消息会丢失。
- 消费者从RabbitMQ服务器获取队列中存储的数据消费,但是消费者程序出错或者宕机而没有正确消费,导致数据丢失。
针对以上三种场景,RabbitMQ提供了三种解决的方式,分别是消息持久化,confirm机制,ACK事务机制。
二、消息持久化
RabbitMQ是支持消息持久化的,消息持久化需要设置:Exchange为持久化和Queue持久化,这样当消息发送到RabbitMQ服务器时,消息就会持久化。
首先看Exchange交换机的类图:
看这个类图其实是要说明上一篇文章介绍的四种交换机都是AbstractExchange抽象类的子类,所以根据java的特性,创建子类的实例会先调用父类的构造器,父类也就是AbstractExchange的构造器是怎么样的呢?
从上面的注释可以看到durable参数表示是否持久化。默认是持久化(true)。创建持久化的Exchange可以这样写:
1 | java复制代码 @Bean |
接着是Queue队列,我们先看看Queue的构造器是怎么样的:
也是通过durable参数设置是否持久化,默认是true。所以创建时可以不指定:
1 | java复制代码 @Bean |
这就完成了消息持久化的设置,接下来启动项目,发送几条消息,我们可以看到:
怎么证明是已经持久化了呢,实际上可以找到对应的文件:
找到对应磁盘中的目录:
消息持久化可以防止消息在RabbitMQ Server中不会因为宕机重启而丢失。
三、消息确认机制
3.1 confirm机制
在生产者发送到RabbitMQ Server时有可能因为网络问题导致投递失败,从而丢失数据。我们可以使用confirm模式防止数据丢失。工作流程是怎么样的呢,看以下图解:
从上图中可以看到是通过两个回调函数**confirm()、returnedMessage()**进行通知。
一条消息从生产者发送到RabbitMQ,首先会发送到Exchange,对应回调函数confirm()。第二步从Exchange路由分配到Queue中,对应回调函数则是returnedMessage()。
代码怎么实现呢,请看演示:
首先在application.yml配置文件中加上如下配置:
1 | yml复制代码spring: |
有个小细节,publisher-returns和mandatory如果都设置的话,优先级是以mandatory优先。可以看源码:
接着我们需要定义回调方法:
1 | java复制代码@Component |
我这里就简单地打印回调方法返回的消息,在实际项目中,可以把返回的消息存储到日志表中,使用定时任务进行进一步的处理。
我这里是使用RabbitTemplate进行发送,所以在Service层的RabbitTemplate需要设置一下:
1 | java复制代码@Service |
大功告成!接下来我们进行测试,发送一条消息,我们可以控制台:
假设发送一条信息没有路由匹配到队列,可以看到如下信息:
这就是confirm模式。它的作用是为了保障生产者投递消息到RabbitMQ不会出现消息丢失。
3.2 事务机制(ACK)
最开始的那张图已经讲过,消费者从队列中获取到消息后,会直接确认签收,假设消费者宕机或者程序出现异常,数据没有正常消费,这种情况就会出现数据丢失。
所以关键在于把自动签收改成手动签收,正常消费则返回确认签收,如果出现异常,则返回拒绝签收重回队列。
代码怎么实现呢,请看演示:
首先在消费者的application.yml文件中设置事务提交为manual手动模式:
1 | yml复制代码spring: |
然后编写消费者的监听器:
1 | java复制代码@Component |
解释一下上面的代码,如果没有异常,则手动确认回复RabbitMQ服务端basicAck(消费成功)。
如果抛出某些可以重回队列的异常,我们就回复basicNack并且设置重回队列。
如果是抛出不可重回队列的异常,就回复basicNack并且设置从RabbitMQ的队列中删除。
接下来进行测试,发送一条普通的消息”hello”:
解释一下ack返回的三个方法的意思。
①成功确认
1 | java复制代码void basicAck(long deliveryTag, boolean multiple) throws IOException; |
消费者成功处理后调用此方法对消息进行确认。
- deliveryTag:该消息的index
- multiple:是否批量.。true:将一次性ack所有小于deliveryTag的消息。
②失败确认
1 | java复制代码void basicNack(long deliveryTag, boolean multiple, boolean requeue) throws IOException; |
- deliveryTag:该消息的index。
- multiple:是否批量。true:将一次性拒绝所有小于deliveryTag的消息。
- requeue:被拒绝的是否重新入队列。
③失败确认
1 | java复制代码void basicReject(long deliveryTag, boolean requeue) throws IOException; |
- deliveryTag:该消息的index。
- requeue:被拒绝的是否重新入队列。
basicNack()和basicReject()的区别在于:basicNack()可以批量拒绝,basicReject()一次只能拒接一条消息。
四、遇到的坑
4.1 启用nack机制后,导致的死循环
上面的代码我故意写了一个bug。测试发送一条”bad”,然后会抛出重回队列的异常。这就有个问题:重回队列后消费者又消费,消费抛出异常又重回队列,就造成了死循环。
那怎么避免这种情况呢?
既然nack会造成死循环的话,我提供的一个思路是不使用basicNack(),把抛出异常的消息落库到一张表中,记录抛出的异常,消息体,消息Id。通过定时任务去处理。
如果你有什么好的解决方案,也可以留言讨论~
4.2 double ack
有的时候比较粗心,不小心开启了自动Ack模式,又手动回复了Ack。那就会报这个错误:
1 | java复制代码消费者RabbitDemoConsumer从RabbitMQ服务端消费消息:java技术爱好者 |
出现这个错误,可以检查一下yml文件是否添加了以下配置:
1 | yml复制代码spring: |
如果上面这个配置已经添加了,还是报错,有可能你使用@Configuration配置了SimpleRabbitListenerContainerFactory,根据SpringBoot的特性,代码优于配置,代码的配置覆盖了yml的配置,并且忘记设置手动manual模式:
1 | java复制代码@Bean |
如果你还是有报错,那可能是写错地方了,写在生产者的项目了。以上的配置应该配置在消费者的项目。因为ack模式是针对消费者而言的。我就是写错了,写在生产者,折腾了几个小时,泪目~
4.3 性能问题
其实手动ACK相对于自动ACK肯定是会慢很多,我在网上查了一些资料,性能相差大概有10倍。所以一般在实际应用中不太建议开手动ACK模式。不过也不是绝对不可以开,具体情况具体分析,看并发量,还有数据的重要性等等。
所以在实际项目中还需要权衡一下并发量和数据的重要性,再决定具体的方案。
4.4 启用手动ack模式,如果没有及时回复,会造成队列异常
如果开启了手动ACK模式,但是由于代码有bug的原因,没有回复RabbitMQ服务端,那么这条消息就会放到Unacked状态的消息堆里,只有等到消费者的连接断开才会转到Ready消息。如果消费者一直没有断开连接,那Unacked的消息就会越来越多,占用内存就越来越大,最后就会出现异常。
这个问题,我没法用我的电脑演示,我的电脑太卡了。
五、总结
通过上面的学习后,总结了RabbitMQ防止数据丢失有三种方式:
- 消息持久化
- 生产者消息确认机制(confirm模式)
- 消费者消息确认模式(ack模式)
上面所有例子的代码都上传github了:
如果你觉得这篇文章对你有用,点个赞吧~
你的点赞是我创作的最大动力~
想第一时间看到我更新的文章,可以微信搜索公众号「java技术爱好者
」,拒绝做一条咸鱼,我是一个努力让大家记住的程序员。我们下期再见!!!
能力有限,如果有什么错误或者不当之处,请大家批评指正,一起学习交流!
本文转载自: 掘金