Springboot + Rabbitmq 用了消息确认机制,感觉掉坑里了

开发 前端
最近部门号召大伙多组织一些技术分享会,说是要活跃公司的技术氛围,但早就看穿一切的我知道,这 T M 就是为了刷KPI。不过,话说回来这的确是件好事,与其开那些没味的扯皮会,多做技术交流还是很有助于个人成长的。

本文转载自微信公众号「程序员内点事」,作者程序员内点事。转载本文请联系程序员内点事公众号。

最近部门号召大伙多组织一些技术分享会,说是要活跃公司的技术氛围,但早就看穿一切的我知道,这 T M 就是为了刷KPI。不过,话说回来这的确是件好事,与其开那些没味的扯皮会,多做技术交流还是很有助于个人成长的。

[[331829]]

 

这次我分享的是 springboot + rabbitmq 如何实现消息确认机制,以及在实际开发中的一点踩坑经验,其实整体的内容比较简单,有时候事情就是这么神奇,越是简单的东西就越容易出错。

可以看到使用了 RabbitMQ 以后,我们的业务链路明显变长了,虽然做到了系统间的解耦,但可能造成消息丢失的场景也增加了。例如:

  • 消息生产者 - > rabbitmq服务器(消息发送失败)
  • rabbitmq服务器自身故障导致消息丢失
  • 消息消费者 - > rabbitmq服务(消费消息失败)

所以说能不使用中间件就尽量不要用,如果为了用而用只会徒增烦恼。开启消息确认机制以后,尽管很大程度上保证了消息的准确送达,但由于频繁的确认交互,rabbitmq 整体效率变低,吞吐量下降严重,不是非常重要的消息真心不建议你用消息确认机制。

 

下边我们先来实现springboot + rabbitmq消息确认机制,再对遇到的问题做具体分析。

一、准备环境

1、引入 rabbitmq 依赖包

  1. <dependency> 
  2.     <groupId>org.springframework.boot</groupId> 
  3.     <artifactId>spring-boot-starter-amqp</artifactId> 
  4. </dependency> 

2、修改 application.properties 配置

配置中需要开启 发送端和 消费端 的消息确认。

  1. spring.rabbitmq.host=127.0.0.1 
  2. spring.rabbitmq.port=5672 
  3. spring.rabbitmq.username=guest 
  4. spring.rabbitmq.password=guest 
  5.  
  6. # 发送者开启 confirm 确认机制 
  7. spring.rabbitmq.publisher-confirms=true 
  8. # 发送者开启 return 确认机制 
  9. spring.rabbitmq.publisher-returns=true 
  10. #################################################### 
  11. # 设置消费端手动 ack 
  12. spring.rabbitmq.listener.simple.acknowledge-mode=manual 
  13. # 是否支持重试 
  14. spring.rabbitmq.listener.simple.retry.enabled=true 

3、定义 Exchange 和 Queue

定义交换机 confirmTestExchange 和队列 confirm_test_queue ,并将队列绑定在交换机上。

  1. @Configuration 
  2. public class QueueConfig { 
  3.  
  4.     @Bean(name = "confirmTestQueue"
  5.     public Queue confirmTestQueue() { 
  6.         return new Queue("confirm_test_queue"truefalsefalse); 
  7.     } 
  8.  
  9.     @Bean(name = "confirmTestExchange"
  10.     public FanoutExchange confirmTestExchange() { 
  11.         return new FanoutExchange("confirmTestExchange"); 
  12.     } 
  13.  
  14.     @Bean 
  15.     public Binding confirmTestFanoutExchangeAndQueue( 
  16.             @Qualifier("confirmTestExchange") FanoutExchange confirmTestExchange, 
  17.             @Qualifier("confirmTestQueue") Queue confirmTestQueue) { 
  18.         return BindingBuilder.bind(confirmTestQueue).to(confirmTestExchange); 
  19.     } 

rabbitmq 的消息确认分为两部分:发送消息确认 和 消息接收确认。

在这里插入图片描述

 

二、消息发送确认

发送消息确认:用来确认生产者 producer 将消息发送到 broker ,broker 上的交换机 exchange 再投递给队列 queue的过程中,消息是否成功投递。

消息从 producer 到 rabbitmq broker有一个 confirmCallback 确认模式。

消息从 exchange 到 queue 投递失败有一个 returnCallback 退回模式。

我们可以利用这两个Callback来确保消的100%送达。

1、 ConfirmCallback确认模式

消息只要被 rabbitmq broker 接收到就会触发 confirmCallback 回调 。

  1. @Slf4j 
  2. @Component 
  3. public class ConfirmCallbackService implements RabbitTemplate.ConfirmCallback { 
  4.      
  5.     @Override 
  6.     public void confirm(CorrelationData correlationData, boolean ack, String cause) { 
  7.  
  8.         if (!ack) { 
  9.             log.error("消息发送异常!"); 
  10.         } else { 
  11.             log.info("发送者爸爸已经收到确认,correlationData={} ,ack={}, cause={}", correlationData.getId(), ack, cause); 
  12.         } 
  13.     } 

实现接口 ConfirmCallback ,重写其confirm()方法,方法内有三个参数correlationData、ack、cause。

  • correlationData:对象内部只有一个 id 属性,用来表示当前消息的唯一性。
  • ack:消息投递到broker 的状态,true表示成功。
  • cause:表示投递失败的原因。

但消息被 broker 接收到只能表示已经到达 MQ服务器,并不能保证消息一定会被投递到目标 queue 里。所以接下来需要用到 returnCallback 。

2、 ReturnCallback 退回模式

如果消息未能投递到目标 queue 里将触发回调 returnCallback ,一旦向 queue 投递消息未成功,这里一般会记录下当前消息的详细投递数据,方便后续做重发或者补偿等操作。

  1. @Slf4j 
  2. @Component 
  3. public class ReturnCallbackService implements RabbitTemplate.ReturnCallback { 
  4.  
  5.     @Override 
  6.     public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) { 
  7.         log.info("returnedMessage ===> replyCode={} ,replyText={} ,exchange={} ,routingKey={}", replyCode, replyText, exchange, routingKey); 
  8.     } 

实现接口ReturnCallback,重写 returnedMessage() 方法,方法有五个参数message(消息体)、replyCode(响应code)、replyText(响应内容)、exchange(交换机)、routingKey(队列)。

下边是具体的消息发送,在rabbitTemplate中设置 Confirm 和 Return 回调,我们通过setDeliveryMode()对消息做持久化处理,为了后续测试创建一个 CorrelationData对象,添加一个id 为10000000000。

  1. @Autowired 
  2.     private RabbitTemplate rabbitTemplate; 
  3.  
  4.     @Autowired 
  5.     private ConfirmCallbackService confirmCallbackService; 
  6.  
  7.     @Autowired 
  8.     private ReturnCallbackService returnCallbackService; 
  9.  
  10.     public void sendMessage(String exchange, String routingKey, Object msg) { 
  11.  
  12.         /** 
  13.          * 确保消息发送失败后可以重新返回到队列中 
  14.          * 注意:yml需要配置 publisher-returnstrue 
  15.          */ 
  16.         rabbitTemplate.setMandatory(true); 
  17.  
  18.         /** 
  19.          * 消费者确认收到消息后,手动ack回执回调处理 
  20.          */ 
  21.         rabbitTemplate.setConfirmCallback(confirmCallbackService); 
  22.  
  23.         /** 
  24.          * 消息投递到队列失败回调处理 
  25.          */ 
  26.         rabbitTemplate.setReturnCallback(returnCallbackService); 
  27.  
  28.         /** 
  29.          * 发送消息 
  30.          */ 
  31.         rabbitTemplate.convertAndSend(exchange, routingKey, msg, 
  32.                 message -> { 
  33.                     message.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); 
  34.                     return message; 
  35.                 }, 
  36.                 new CorrelationData(UUID.randomUUID().toString())); 
  37.     } 

三、消息接收确认

消息接收确认要比消息发送确认简单一点,因为只有一个消息回执(ack)的过程。使用@RabbitHandler注解标注的方法要增加 channel(信道)、message 两个参数。

  1. @Slf4j 
  2. @Component 
  3. @RabbitListener(queues = "confirm_test_queue"
  4. public class ReceiverMessage1 { 
  5.      
  6.     @RabbitHandler 
  7.     public void processHandler(String msg, Channel channel, Message message) throws IOException { 
  8.  
  9.         try { 
  10.             log.info("小富收到消息:{}", msg); 
  11.  
  12.             //TODO 具体业务 
  13.              
  14.             channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); 
  15.  
  16.         }  catch (Exception e) { 
  17.              
  18.             if (message.getMessageProperties().getRedelivered()) { 
  19.                  
  20.                 log.error("消息已重复处理失败,拒绝再次接收..."); 
  21.                  
  22.                 channel.basicReject(message.getMessageProperties().getDeliveryTag(), false); // 拒绝消息 
  23.             } else { 
  24.                  
  25.                 log.error("消息即将再次返回队列处理..."); 
  26.                  
  27.                 channel.basicNack(message.getMessageProperties().getDeliveryTag(), falsetrue);  
  28.             } 
  29.         } 
  30.     } 

消费消息有三种回执方法,我们来分析一下每种方法的含义。

1、basicAck

basicAck:表示成功确认,使用此回执方法后,消息会被rabbitmq broker 删除。

  1. void basicAck(long deliveryTag, boolean multiple)  

deliveryTag:表示消息投递序号,每次消费消息或者消息重新投递后,deliveryTag都会增加。手动消息确认模式下,我们可以对指定deliveryTag的消息进行ack、nack、reject等操作。

multiple:是否批量确认,值为 true 则会一次性 ack所有小于当前消息 deliveryTag 的消息。

举个栗子: 假设我先发送三条消息deliveryTag分别是5、6、7,可它们都没有被确认,当我发第四条消息此时deliveryTag为8,multiple设置为 true,会将5、6、7、8的消息全部进行确认。

2、basicNack

basicNack :表示失败确认,一般在消费消息业务异常时用到此方法,可以将消息重新投递入队列。

  1. void basicNack(long deliveryTag, boolean multiple, boolean requeue) 

deliveryTag:表示消息投递序号。

multiple:是否批量确认。

requeue:值为 true 消息将重新入队列。

3、basicReject

basicReject:拒绝消息,与basicNack区别在于不能进行批量操作,其他用法很相似。

  1. void basicReject(long deliveryTag, boolean requeue) 

deliveryTag:表示消息投递序号。

requeue:值为 true 消息将重新入队列。

四、测试

发送消息测试一下消息确认机制是否生效,从执行结果上看发送者发消息后成功回调,消费端成功的消费了消息。

用抓包工具Wireshark 观察一下rabbitmq amqp协议交互的变化,也多了 ack 的过程。

 

五、踩坑日志

1、别忘确认消息

这是一个非常没技术含量的坑,但却是非常容易犯错的地方。

开启消息确认机制,消费消息别忘了channel.basicAck,否则消息会一直存在,导致重复消费。

 

2、消息无限投递

在我最开始接触消息确认机制的时候,消费端代码就像下边这样写的,思路很简单:处理完业务逻辑后确认消息, int a = 1 / 0 发生异常后将消息重新投入队列。

  1. @RabbitHandler 
  2.     public void processHandler(String msg, Channel channel, Message message) throws IOException { 
  3.  
  4.         try { 
  5.             log.info("消费者 2 号收到:{}", msg); 
  6.  
  7.             int a = 1 / 0; 
  8.  
  9.             channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); 
  10.  
  11.         } catch (Exception e) { 
  12.  
  13.             channel.basicNack(message.getMessageProperties().getDeliveryTag(), falsetrue); 
  14.         } 
  15.     } 

但是有个问题是,业务代码一旦出现 bug 99.9%的情况是不会自动修复,一条消息会被无限投递进队列,消费端无限执行,导致了死循环。

在这里插入图片描述

 

本地的CPU被瞬间打满了,大家可以想象一下当时在生产环境导致服务死机,我是有多慌。

而且rabbitmq management 只有一条未被确认的消息。

在这里插入图片描述

 

经过测试分析发现,当消息重新投递到消息队列时,这条消息不会回到队列尾部,仍是在队列头部。

消费者会立刻消费这条消息,业务处理再抛出异常,消息再重新入队,如此反复进行。导致消息队列处理出现阻塞,导致正常消息也无法运行。

而我们当时的解决方案是,先将消息进行应答,此时消息队列会删除该条消息,同时我们再次发送该消息到消息队列,异常消息就放在了消息队列尾部,这样既保证消息不会丢失,又保证了正常业务的进行。

  1. channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); 
  2. // 重新发送消息到队尾 
  3. channel.basicPublish(message.getMessageProperties().getReceivedExchange(), 
  4.                     message.getMessageProperties().getReceivedRoutingKey(), MessageProperties.PERSISTENT_TEXT_PLAIN, 
  5.                     JSON.toJSONBytes(msg)); 

但这种方法并没有解决根本问题,错误消息还是会时不时报错,后面优化设置了消息重试次数,达到了重试上限以后,手动确认,队列删除此消息,并将消息持久化入MySQL并推送报警,进行人工处理和定时任务做补偿。

3、重复消费

 

如何保证 MQ 的消费是幂等性,这个需要根据具体业务而定,可以借助MySQL、或者redis将消息持久化,通过再消息中的唯一性属性校验。

 

责任编辑:武晓燕 来源: 程序员内点事
相关推荐

2023-03-10 08:27:07

for循环项目线性结构

2023-06-01 08:54:08

RabbitMQ确认机制生产端

2020-09-14 11:50:21

SpringBootRabbitMQJava

2021-09-07 10:38:37

RabbitMQ 高可用消费

2022-07-26 00:00:00

MQ消息中间件

2023-03-06 08:16:04

SpringRabbitMQ

2021-07-19 09:42:45

Spring Boot@ValueJava

2011-05-31 11:55:00

Android 消息机制

2010-09-29 09:10:32

云计算

2018-06-01 09:11:23

2023-09-07 10:31:27

2023-12-04 09:23:49

分布式消息

2020-10-14 08:36:10

RabbitMQ消息

2022-07-26 20:00:35

场景RabbitMQMQ

2022-08-02 11:27:25

RabbitMQ消息路由

2023-05-17 08:16:04

RabbitMQ消息传递

2021-12-06 15:02:37

RabbitMQ系统消息

2013-04-11 12:40:16

Android消息机制

2016-03-02 09:34:03

runtime消息ios开发

2021-11-15 14:02:27

RPCSpringBootRabbitMQ
点赞
收藏

51CTO技术栈公众号