SpringBoot整合消息中间件 RabbitMQ 第 8 篇 —— 补偿机制(重试机制)、解决幂等性问题、手动确认消息
补偿机制(重试机制)
RabbitMQ 队列服务器,对消息默认有个补偿机制。即当消费者没有成功消费消息时,会缓存消息,并且不断的把消息重新发给消费者,直到消息被消费掉(默认情况下会一直发,除非我们修改配置)。
RabbitMQ 底层是使用 Spring AOP 进行拦截,如果消费者的 @RabbitListener 函数报错,会自动触发重试机制。
我们来演示一下。下载原来的代码:代码在百度网盘连接: 提取码:xnjd
在原来代码的基础上,我们再创建一个测试的模块。代码结构如图:
bootstrap.yml 配置如下:
spring: rabbitmq: #主机名 host: 127.0.0.1 #端口号 port: 5672 #账号 username: guest #密码 password: guest #虚拟主机,这里配置的是我们的测试主机 virtual-host: /test_host # 自定义配置信息 queueConfig: # 邮件队列名 emailQueue: emailQueue # 交换机名称 directExchangeName: directExchangeName # info 路由 infoRoute: infoRoute server: port: 8080 # 将SpringBoot项目作为单实例部署调试时,不需要注册到注册中心 eureka: client: fetch-registry: false register-with-eureka: false
QueueConfig 配置类:
package com.study.config; import com.rabbitmq.client.BuiltinExchangeType; import com.rabbitmq.client.Channel; import org.springframework.amqp.rabbit.connection.CachingConnectionFactory; import org.springframework.amqp.rabbit.connection.Connection; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * @author biandan * @description * @signature 让天下没有难写的代码 * @create 2021-04-05 上午 12:39 */ @Configuration public class QueueConfig { @Value("${spring.rabbitmq.host}") private String host; @Value("${spring.rabbitmq.port}") private Integer port; @Value("${spring.rabbitmq.username}") private String username; @Value("${spring.rabbitmq.password}") private String password; @Value("${spring.rabbitmq.virtual-host}") private String virtualHost; //邮件队列名 @Value("${queueConfig.emailQueue}") private String emailQueue; //交换机名称 @Value("${queueConfig.directExchangeName}") private String directExchangeName; //info 路由 @Value("${queueConfig.infoRoute}") private String infoRoute; /** * 封装连接类 * * @return */ @Bean public CachingConnectionFactory connectionFactory() { CachingConnectionFactory connectionFactory = new CachingConnectionFactory(); connectionFactory.setHost(host); connectionFactory.setPort(port); connectionFactory.setUsername(username); connectionFactory.setPassword(password); connectionFactory.setVirtualHost(virtualHost); return connectionFactory; } /** * 动态的创建队列(这里仅创建配置文件里的一个) * * @return * @throws Exception */ @Bean public String getQueueName() throws Exception { //获取连接 Connection connection = connectionFactory().createConnection(); //创建通道。true表示有事务功能 Channel channel = connection.createChannel(true); //创建队列 channel.queueDeclare(emailQueue, true, false, false, null);//创建邮件队列 //创建交换机 channel.exchangeDeclare(directExchangeName, BuiltinExchangeType.DIRECT, true, false, null); //为邮件队列绑定 info 路由 channel.queueBind(emailQueue, directExchangeName, infoRoute); //关闭通道 channel.close(); //关闭连接 connection.close(); return ""; } }
消息生产者类:现在间隔1分钟才生产一条消息
package com.study.producer; import org.springframework.amqp.core.AmqpTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.text.SimpleDateFormat; import java.util.Date; /** * @author biandan * @description 消息生产者(扇形交换机) * @signature 让天下没有难写的代码 * @create 2021-04-04 下午 10:49 */ @Component public class DirectProducer { private SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); //交换机名称 @Value("${queueConfig.directExchangeName}") private String directExchangeName; //info 路由 @Value("${queueConfig.infoRoute}") private String infoRoute; /** * 注入 AMQP 消息模板 */ @Autowired private AmqpTemplate template; /** * 每隔1分钟产生一条消息 */ @Scheduled(fixedRate = 1000 * 60 ) public void sendMsg() { String infoMsg = "直连交换机消息生产者 info:" + SDF.format(new Date()); System.out.println(infoMsg); //发送消息(往路由发消息,而不是往队列发消息) template.convertAndSend(directExchangeName, infoRoute, infoMsg); } }
消费者代码:注意,消费者模拟异常情况
package com.study.consumer; import org.springframework.amqp.rabbit.annotation.RabbitHandler; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.stereotype.Component; import java.util.Date; /** * @author biandan * @description 邮件消费者 * @signature 让天下没有难写的代码 * @create 2021-04-04 下午 11:39 */ @Component @RabbitListener(queues = "${queueConfig.emailQueue}") public class Consumer_Email { @RabbitHandler public void receiveMsg(String msg) { System.out.println(new Date()+" 邮件消费者_消费掉的消息:" + msg); //模拟异常情况的出现 int k = 5 / 0; } }
启动类:
package com.study; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.netflix.eureka.EnableEurekaClient; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling //启用任务调度 @EnableEurekaClient public class RabbitMQTestApplication { public static void main(String[] args) { SpringApplication.run(RabbitMQTestApplication.class, args); } }
运行程序:会看到消费者一直收到消息,并且报错了。
去到 RabbitMQ 管理后台查看:一直有一条消息未被消费掉。这就是触发了 RabbitMQ 的重试机制。
OK,我们修改 RabbitMQ 默认的重试机制。我们修改 bootstrap.yml 的配置,最多重试5次,每次间隔3秒钟,如图:注意必须有单位,如秒:s,毫秒:ms
# 修改 RabbitMQ 的重试机制 listener: simple: retry: # 开启消费者重试机制 enabled: true #最大重试次数 max-attempts: 5 # 重试间距(单位:秒) initial-interval: 2s # 重试最大间隔 max-interval: 1200s # 时间间隔的乘子,下一次间隔的时间=间隔时间 × 乘子,但最大不超过重试最大间隔 multiplier: 1
OK,重启服务,查看控制台:间隔2秒执行一次,一直执行5次。
再次查看 RabbitMQ 后台:发现 RabbitMQ 已经放弃了未被成功消费的消息。
思考:如何选择重试机制?
分两种情况:
1、消费者从 RabbitMQ 服务器获取到消息后,需要调用第三方接口或者其它微服务接口,但接口暂时无法调通,这种情况需要重试。
2、消费者从 RabbitMQ 服务器获取到消息后,程序出现bug或抛出异常(如上述例子),这种情况需要修改bug并升级服务即可解决,这种情况下不需要重试。但是为了不让消息丢失,我们需要采取措施解决:记录日志并将消息保存,然后通过定时任务扫描日志文件重新发送消息,或者人工处理的方式进行消息补偿。
思考:RabbitMQ 重试机制会引起什么问题?答:幂等性问题。
幂等性:在编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。
根据我们上述的例子,生产者的一条消息被消费者重复消费了5次。这5次获取到的消息都是同一条消息,也就是实际性的内容并没有完全改变。这种情况就叫做。
一般什么情况下容易造成 MQ 重试机制?1、网络延迟。2、消费者出现异常。3、消费者延迟消费等,都可能造成 MQ 触发重试机制。
思考:如何解决 RabbitMQ 幂等性问题?
1、使用全局唯一的消息ID,消费者进行判断是否重复,解决幂等性问题。
2、可以使用业务唯一的ID进行判断,比如订单号、流水号等。
代码里如何操作,来解决幂等性问题?
我们需要改造消息生产者,完整代码如下:增加 org.springframework.amqp.core.Message 包
package com.study.producer; import org.springframework.amqp.core.AmqpTemplate; import org.springframework.amqp.core.Message; import org.springframework.amqp.core.MessageBuilder; import org.springframework.amqp.core.MessageProperties; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.text.SimpleDateFormat; import java.util.Date; import java.util.UUID; /** * @author biandan * @description 消息生产者(扇形交换机) * @signature 让天下没有难写的代码 * @create 2021-04-04 下午 10:49 */ @Component public class DirectProducer { private SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); //交换机名称 @Value("${queueConfig.directExchangeName}") private String directExchangeName; //info 路由 @Value("${queueConfig.infoRoute}") private String infoRoute; /** * 注入 AMQP 消息模板 */ @Autowired private AmqpTemplate template; /** * 每隔1分钟产生一条消息 */ @Scheduled(fixedRate = 1000 * 60 ) public void sendMsg() { String infoMsg = "直连交换机消息生产者 info:" + SDF.format(new Date()); System.out.println(infoMsg); Message message = MessageBuilder.withBody(infoMsg.getBytes()) .setContentType(MessageProperties.CONTENT_TYPE_JSON) //json格式 .setContentEncoding("utf-8") //utf-8编码方式 .setMessageId(UUID.randomUUID() + "")//使用 UUID 作为消息ID,UUID保证全局唯一 .build(); //发送消息(往路由发消息,而不是往队列发消息) template.convertAndSend(directExchangeName, infoRoute, message); } }
消费者一端做处理,代码如下(需要把 @RabbitListener 标注到方法上,否则报错):
package com.study.consumer; import org.springframework.amqp.core.Message; import org.springframework.amqp.rabbit.annotation.RabbitHandler; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.Date; import java.util.List; /** * @author biandan * @description 邮件消费者 * @signature 让天下没有难写的代码 * @create 2021-04-04 下午 11:39 */ @Component public class Consumer_Email { //定义消息ID集合,实际项目中可以从 Redis 进行读写 private List<String> msgIdList = new ArrayList<>(); @RabbitListener(queues = "${queueConfig.emailQueue}")//需要把 @RabbitListener 标注到方法上 public void receiveMsg(Message message) { String messageId = message.getMessageProperties().getMessageId();//获取消息ID System.out.println("消费者获取到的消息 messageId="+messageId); if(msgIdList.contains(messageId)){ System.out.println("消息ID重复"); }else { msgIdList.add(messageId);//添加到消息列表 } try{ String msg = new String(message.getBody(),"utf-8"); System.out.println(new Date()+" 邮件消费者_消费掉的消息:" + msg); }catch (Exception e){ e.printStackTrace(); } //模拟异常情况的出现 int k = 5 / 0; System.out.println("k="+k); } }
如果没有把 @RabbitListener 标注到方法上,会报下面的错误:
org.springframework.amqp.rabbit.listener.exception.ListenerExecutionFailedException: Listener threw exception at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.wrapToListenerExecutionFailedExceptionIfNeeded(AbstractMessageListenerContainer.java:1537) ~[spring-rabbit-2.0.11.RELEASE.jar:2.0.11.RELEASE] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doInvokeListener(AbstractMessageListenerContainer.java:1448) ~[spring-rabbit-2.0.11.RELEASE.jar:2.0.11.RELEASE] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.actualInvokeListener(AbstractMessageListenerContainer.java:1368) ~[spring-rabbit-2.0.11.RELEASE.jar:2.0.11.RELEASE] at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_91] at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_91] at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_91] at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_91] at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:343) ~[spring-aop-5.0.12.RELEASE.jar:5.0.12.RELEASE] at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:197) ~[spring-aop-5.0.12.RELEASE.jar:5.0.12.RELEASE] at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) ~[spring-aop-5.0.12.RELEASE.jar:5.0.12.RELEASE] at org.springframework.retry.interceptor.RetryOperationsInterceptor$1.doWithRetry(RetryOperationsInterceptor.java:91) ~[spring-retry-1.2.3.RELEASE.jar:na] at org.springframework.retry.support.RetryTemplate.doExecute(RetryTemplate.java:287) [spring-retry-1.2.3.RELEASE.jar:na] at org.springframework.retry.support.RetryTemplate.execute(RetryTemplate.java:180) [spring-retry-1.2.3.RELEASE.jar:na] at org.springframework.retry.interceptor.RetryOperationsInterceptor.invoke(RetryOperationsInterceptor.java:115) ~[spring-retry-1.2.3.RELEASE.jar:na] at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:185) ~[spring-aop-5.0.12.RELEASE.jar:5.0.12.RELEASE] at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:212) ~[spring-aop-5.0.12.RELEASE.jar:5.0.12.RELEASE] at org.springframework.amqp.rabbit.listener.$Proxy84.invokeListener(Unknown Source) ~[na:2.0.11.RELEASE] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.invokeListener(AbstractMessageListenerContainer.java:1355) ~[spring-rabbit-2.0.11.RELEASE.jar:2.0.11.RELEASE] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.executeListener(AbstractMessageListenerContainer.java:1334) ~[spring-rabbit-2.0.11.RELEASE.jar:2.0.11.RELEASE] at org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer.doReceiveAndExecute(SimpleMessageListenerContainer.java:817) ~[spring-rabbit-2.0.11.RELEASE.jar:2.0.11.RELEASE] at org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer.receiveAndExecute(SimpleMessageListenerContainer.java:801) ~[spring-rabbit-2.0.11.RELEASE.jar:2.0.11.RELEASE] at org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer.access$700(SimpleMessageListenerContainer.java:77) ~[spring-rabbit-2.0.11.RELEASE.jar:2.0.11.RELEASE] at org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer$AsyncMessageProcessingConsumer.run(SimpleMessageListenerContainer.java:1042) ~[spring-rabbit-2.0.11.RELEASE.jar:2.0.11.RELEASE] at java.lang.Thread.run(Thread.java:745) ~[na:1.8.0_91] Caused by: org.springframework.amqp.AmqpException: No method found for class [B at org.springframework.amqp.rabbit.listener.adapter.DelegatingInvocableHandler.getHandlerForPayload(DelegatingInvocableHandler.java:147) ~[spring-rabbit-2.0.11.RELEASE.jar:2.0.11.RELEASE] at org.springframework.amqp.rabbit.listener.adapter.DelegatingInvocableHandler.getMethodNameFor(DelegatingInvocableHandler.java:250) ~[spring-rabbit-2.0.11.RELEASE.jar:2.0.11.RELEASE] at org.springframework.amqp.rabbit.listener.adapter.HandlerAdapter.getMethodAsString(HandlerAdapter.java:70) ~[spring-rabbit-2.0.11.RELEASE.jar:2.0.11.RELEASE] at org.springframework.amqp.rabbit.listener.adapter.MessagingMessageListenerAdapter.invokeHandler(MessagingMessageListenerAdapter.java:196) ~[spring-rabbit-2.0.11.RELEASE.jar:2.0.11.RELEASE] at org.springframework.amqp.rabbit.listener.adapter.MessagingMessageListenerAdapter.onMessage(MessagingMessageListenerAdapter.java:126) ~[spring-rabbit-2.0.11.RELEASE.jar:2.0.11.RELEASE] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doInvokeListener(AbstractMessageListenerContainer.java:1445) ~[spring-rabbit-2.0.11.RELEASE.jar:2.0.11.RELEASE] ... 22 common frames omitted
后来网上寻求答案,才知道为啥报错:
rabbitTemplate.convertAndSend 在发送消息时,会有消息头部信息,如图:
而 amqp-client 发送的消息默认是不带消息头的
OK,重启服务:看到控制台输出5次消息ID,4次是重复的。实际开发中,我们需要对重复的消息ID做处理。这里没有做处理,这是演示。
RabbitMQ 如何手动确认消息?
之前演示的例子默认都是自动应答模式,即消费者消费掉消息后,RabbitMQ 服务器就把该条消息删掉。
接下来我们演示手动应答模式。
# manual=启手动应答模式,auto=自动应答模式 acknowledge-mode: manual
接下来,修改消费者端代码:
package com.study.consumer; import com.rabbitmq.client.Channel; import org.springframework.amqp.core.Message; import org.springframework.amqp.rabbit.annotation.RabbitHandler; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.amqp.support.AmqpHeaders; import org.springframework.messaging.handler.annotation.Headers; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Map; /** * @author biandan * @description 邮件消费者 * @signature 让天下没有难写的代码 * @create 2021-04-04 下午 11:39 */ @Component public class Consumer_Email { //定义消息ID集合,实际项目中可以从 Redis 进行读写 private List<String> msgIdList = new ArrayList<>(); @RabbitListener(queues = "${queueConfig.emailQueue}")//需要把 @RabbitListener 标注到方法上 public void receiveMsg(Message message, @Headers Map<String, Object> headers, Channel channel) { String messageId = message.getMessageProperties().getMessageId();//获取消息ID System.out.println("消费者获取到的消息 messageId="+messageId); if(msgIdList.contains(messageId)){ System.out.println("消息ID重复"); }else { msgIdList.add(messageId);//添加到消息列表 } try{ String msg = new String(message.getBody(),"utf-8"); System.out.println(new Date()+" 邮件消费者_消费掉的消息:" + msg); //手动ack应答 Long deliveryTag = (Long) headers.get(AmqpHeaders.DELIVERY_TAG); //手动签收 channel.basicAck(deliveryTag, false); }catch (Exception e){ e.printStackTrace(); } //模拟异常情况的出现 //int k = 5 / 0; //System.out.println("k="+k); } }
重启服务,在消费者端的手动 ack 代码打上断点:
这时去 RabbitMQ 管理后台查看消息:
说明:在消费者端没有手动签收消息前,RabbitMQ 服务器不会删除掉消息。
OK,这一篇博客讲解到这。完整代码: 提取码:jxn2