RabbitMQ实现消息确认机制
一、简介
在消息队列中经常有一个问题,凭什么确定你的消息已经被消费了呢?
我们分析一下消息发送到消费的过程:
- 消息生产者 - > rabbitmq服务器(消息发送失败)
- rabbitmq服务器自身故障导致消息丢失
- 消息消费者 - >rabbitmq服务(消费消息失败)
以上都有可能导致消息消费失败!
所以我们需要实现消息确认已被消费。
rabbitmq 的消息确认分为两部分:发送消息确认 和 消息接收确认。
二、环境准备
2.1 pom依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
2.2 配置文件
application.yml文件
server:
port: 8080
spring:
rabbitmq:
host: 192.168.103.10
# 发送者开启 confirm 确认机制
publisher-confirms: true
# 发送者开启 return 确认机制
publisher-returns: true
# 设置消费端手动 ack
listener:
simple:
acknowledge-mode: manual
# 是否支持重试
retry:
enabled: true
application.properties文件
spring.rabbitmq.host=127.0.0.1
spring.rabbitmq.port=5672
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest
# 发送者开启 confirm 确认机制
spring.rabbitmq.publisher-confirms=true
# 发送者开启 return 确认机制
spring.rabbitmq.publisher-returns=true
####################################################
# 设置消费端手动 ack
spring.rabbitmq.listener.simple.acknowledge-mode=manual
# 是否支持重试
spring.rabbitmq.listener.simple.retry.enabled=true
三、RabbitMQ配置类
- 创建队列
- 创建交换机
- 将队列绑定到交换机上
@Configuration
public class RabbitMQConfig {
/**
* 定义 Exchange 和 Queue
* 定义交换机 confirmTestExchange 和队列 confirm_test_queue ,并将队列绑定在交换机上。
* @return
*/
@Bean(name = "confirmTestQueue")
public Queue confirmTestQueue() {
return new Queue("confirm_test_queue", true, false, false);
}
@Bean(name = "confirmTestExchange")
public FanoutExchange confirmTestExchange() {
return new FanoutExchange("confirmTestExchange");
}
@Bean
public Binding confirmTestFanoutExchangeAndQueue(
@Qualifier("confirmTestExchange") FanoutExchange confirmTestExchange,
@Qualifier("confirmTestQueue") Queue confirmTestQueue) {
return BindingBuilder.bind(confirmTestQueue).to(confirmTestExchange);
}
}
四、消息发送确认与接收确认
4.1 消息发送确认
用来确认生产者 producer 将消息发送到 broker ,
broker 上的交换机 exchange 再投递给队列 queue的过程中,消息是否成功投递。
消息从 producer 到 rabbitmq broker有一个 confirmCallback 确认模式。
消息从 exchange 到 queue 投递失败有一个 returnCallback 退回模式。
我们可以利用这两个Callback来确保消的100%送达。
4.1.1 ConfirmCallback确认模式
消息只要被 rabbitmq broker 接收到就会触发 confirmCallback 回调 。
实现接口 ConfirmCallback ,重写其confirm()方法,方法内有三个参数correlationData、ack、cause。
• correlationData:对象内部只有一个 id 属性,用来表示当前消息的唯一性。
• ack:消息投递到broker 的状态,true表示成功。
• cause:表示投递失败的原因。
但消息被 broker 接收到只能表示已经到达 MQ服务器,并不能保证消息一定会被投递到目标 queue 里。所以接下来需要用到 returnCallback 。
package com.lsh.rabbitmqutil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.rabbit.support.CorrelationData;
import org.springframework.stereotype.Component;
/**
* @author :LiuShihao
* @date :Created in 2020/8/19 5:21 下午
* @desc :消息只要被 rabbitmq broker 接收到就会触发 confirmCallback 回调 。
* • correlationData:对象内部只有一个 id 属性,用来表示当前消息的唯一性。
* • ack:消息投递到broker 的状态,true表示成功。
* • cause:表示投递失败的原因。
*/
@Slf4j
@Component
public class ConfirmCallbackService implements RabbitTemplate.ConfirmCallback {
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
if (!ack) {
log.error("消息发送异常!");
} else {
log.info("生产者已经收到确认,ack="+ack+", cause="+cause);//ack=true, cause=null
}
}
}
4.1.2 ReturnCallback 退回模式
如果消息未能投递到目标队列 里将触发回调 returnCallback ,一旦向 queue 投递消息未成功,这里一般会记录下当前消息的详细投递数据,方便后续做重发或者补偿等操作。
package com.lsh.rabbitmqutil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
/**
* @author :LiuShihao
* @date :Created in 2020/8/19 5:28 下午
* @desc :如果消息未能投递到目标 queue 里将触发回调 returnCallback ,
* 一旦向 queue 投递消息未成功,这里一般会记录下当前消息的详细投递数据,方便后续做重发或者补偿等操作。
*/
@Slf4j
@Component
public class ReturnCallbackService implements RabbitTemplate.ReturnCallback {
@Override
public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
log.info("returnedMessage ===> replyCode="+replyCode+" ,replyText="+replyText+" ,exchange="+exchange+" ,routingKey="+routingKey);
}
}
4.2 消息接收确认 (监听消息)
要比消息发送确认简单一点,因为只有一个消息回执(ack)的过程。使用@RabbitHandler
注解标注的方法要增加 channel
(信道)、message
两个参数。
package com.lsh.rabbitmqutil;
import com.rabbitmq.client.Channel;
import lombok.extern.slf4j.Slf4j;
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.io.IOException;
/**
* @author :LiuShihao
* @date :Created in 2020/7/10 2:11 下午
* @desc :消息接收确认
* 消息接收确认要比消息发送确认简单一点,因为只有一个消息回执(ack)的过程。
* 使用@RabbitHandler注解标注的方法要增加 channel(信道)、message 两个参数。
* 消费消息有三种回执方法
*/
@Slf4j
@Component
public class ReceiverMessage1 {
@RabbitHandler
@RabbitListener(queues = "confirm_test_queue")
public void processHandler(String msg, Channel channel, Message message) throws IOException {
try {
log.info("消费者收到消息:{}", msg);
//TODO 具体业务
//basicAck:表示成功确认,使用此回执方法后,消息会被rabbitmq broker 删除。
//deliveryTag:表示消息投递序号,每次消费消息或者消息重新投递后,deliveryTag都会增加。手动消息确认模式下,我们可以对指定deliveryTag的消息进行ack、nack、reject等操作。
//multiple:是否批量确认,值为 true 则会一次性 ack所有小于当前消息 deliveryTag 的消息。
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
} catch (Exception e) {
if (message.getMessageProperties().getRedelivered()) {
log.error("消息已重复处理失败,拒绝再次接收...");
//basicReject:拒绝消息,与basicNack区别在于不能进行批量操作,其他用法很相似。
//deliveryTag:表示消息投递序号。
//requeue:值为 true 消息将重新入队列。
channel.basicReject(message.getMessageProperties().getDeliveryTag(), false); // 拒绝消息
} else {
log.error("消息即将再次返回队列处理...");
//basicNack :表示失败确认,一般在消费消息业务异常时用到此方法,可以将消息重新投递入队列。
//deliveryTag:表示消息投递序号。
//multiple:是否批量确认。
//requeue:值为 true 消息将重新入队列。
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);
}
}
}
}
五、 生产消息
package com.lsh.controller;
import com.lsh.rabbitmqutil.ConfirmCallbackService;
import com.lsh.rabbitmqutil.ReturnCallbackService;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* @author :LiuShihao
* @date :Created in 2020/8/20 9:14 上午
* @desc :
*/
@RestController
public class RabbitController {
@Autowired
RabbitTemplate rabbitTemplate;
@Autowired
ConfirmCallbackService confirmCallbackService;
@Autowired
ReturnCallbackService returnCallbackService;
@GetMapping("/sendrabbitmq")
public void sendRabbitMQ(Object msg){
/**
* 确保消息发送失败后可以重新返回到队列中
* 注意:yml需要配置 publisher-returns: true
*/
rabbitTemplate.setMandatory(true);
/**
* 消费者确认收到消息后,手动ack回执回调处理
*/
rabbitTemplate.setConfirmCallback(confirmCallbackService);
/**
* 消息投递到队列失败回调处理
*/
rabbitTemplate.setReturnCallback(returnCallbackService);
rabbitTemplate.convertAndSend("confirm_test_queue", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd HH:mm:ss")));
}
}
六、测试
6.1 发送消息测试一下消息确认机制是否生效
从执行结果上看发送者发消息后成功回调,消费端成功的消费了消息
6.2 将消息发送不存在队列,触发退回模式
将消息发送到不存在的队列
6.3 将消息抛出1/0异常,消息确认失败
当消息被消费时,假如处理业务发送异常,触发消息接收确认
总结
- Producer发送消息到 Broker 会触发ConfirmCallBack确认模式
- 如果Broker没有投入到正确的队列里 会触发ReturnCallBack退回模式(如果投到了就不会触发)
- ack消息接收确认
MQ面试题
1.消息无限投递怎么解决
处理完业务逻辑后确认消息, int a = 1 / 0 发生异常后将消息重新投入队列。
但是有个问题是,业务代码一旦出现 bug 99.9%的情况是不会自动修复,一条消息会被无限投递进队列,消费端无限执行,导致了死循环。
经过测试分析发现,当消息重新投递到消息队列时,这条消息不会回到队列尾部,仍是在队列头部。
消费者会立刻消费这条消息,业务处理再抛出异常,消息再重新入队,如此反复进行。导致消息队列处理出现阻塞,导致正常消息也无法运行。
而我们当时的解决方案是,先将消息进行应答,此时消息队列会删除该条消息,同时我们再次发送该消息到消息队列,异常消息就放在了消息队列尾部,这样既保证消息不会丢失,又保证了正常业务的进行。
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
// 重新发送消息到队尾
channel.basicPublish(message.getMessageProperties().getReceivedExchange(),
message.getMessageProperties().getReceivedRoutingKey(), MessageProperties.PERSISTENT_TEXT_PLAIN,
JSON.toJSONBytes(msg));
但这种方法并没有解决根本问题,错误消息还是会时不时报错,后面优化设置了消息重试次数,达到了重试上限以后,手动确认,队列删除此消息,并将消息持久化入MySQL并推送报警,进行人工处理和定时任务做补偿。
2.消息重复消费,如何保证消息的幂等性?
如何保证 MQ 的消费是幂等性,这个需要根据具体业务而定,可以借助MySQL、或者redis 将消息持久化
,通过再消息中的唯一性属性校验。