生产者发消息给mq,mq将消息持久化到磁盘上了,mq再告诉生产者我已经把消息持久化到磁盘上了,这时才能保证消息是没有丢失,稳稳地保存在了磁盘上。
mq告诉生产者,我已经保存到磁盘了,这一步就叫发布确认。
生产者将信道设置成 confirm 模式,一旦信道进入 confirm 模式,所有在该信道上面发布的消息都将会被指派一个唯一的 ID (从 1 开始),一旦消息被投递到所有匹配的队列之后,broker 就会发送一个确认给生产者 (包含消息的唯一 ID),这就使得生产者知道消息已经正确到达目的队列了,如果消息和队列是可持久化的,那么确认消息会在将消息写入磁盘之后发出,broker 回传给生产者的确认消息中 delivery-tag 域包含了确认消息的序列号,此外 broker 也可以设置 basic.ack 的 multiple 域,表示到这个序列号之前的所有消息都已经得到了处理。
confirm 模式最大的好处在于它是异步的,一旦发布一条消息,生产者应用程序就可以在等信道返回确认的同时继续发送下一条消息,当消息最终得到确认之后,生产者应用便可以通过回调方法来处理该确认消息,如果 RabbitMQ 因为自身内部错误导致消息丢失,就会发送一条 nack 消息, 生产者应用程序同样可以在回调方法中处理该 nack 消息。
发布确认的策略
开启发布确认的方法:发布确认默认是没有开启的,如果要开启,需要调用方法 confirmSelect,每当你要想使用发布确认,都需要在 channel 上调用该方法。
开启发布确认的方法:发布确认默认是没有开启的,如果要开启,需要调用方法 confirmSelect,每当你要想使用发布确认,都需要在 channel 上调用该方法。
//开启发布确认
channel.confirmSelect();
发布确认的三个方式:
首先我们讲单个确认发布
发一条确认一条,不确认就不发布。
这是一种简单的确认方式,它是一种同步确认发布的方式,也就是发布一个消息之后只有它被确认发布,后续的消息才能继续发布,waitForConfirmsOrDie(long)
这个方法只有在消息被确认的时候才返回,如果在指定时间范围内这个消息没有被确认那么它将抛出异常。
这种确认方式有一个最大的缺点就是:发布速度特别的慢,因为如果没有确认发布的消息就会阻塞所有后续消息的发布,这种方式最多提供每秒不超过数百条发布消息的吞吐量。当然对于某些应用程序来说这可能已经足够了
代码:
// 单个确认
public static void Individually() throws Exception {
Channel channel = MqConnectUtil.getChannel();
channel.queueDeclare(MqConnectUtil.QUEUE_NAME, true, false, false, null);
// 开启发布确认
channel.confirmSelect();
// 开始时间
long begin = System.currentTimeMillis();
// 批量发消息
for (int i = 0; i < MESSAGE_COUNT; i++) {
channel.basicPublish("", MqConnectUtil.QUEUE_NAME, null, String.valueOf(i).getBytes());
// 单个消息就马上进行发布确认
if (!channel.waitForConfirms()) {
log.debug("消息发送失败");
}
}
long end = System.currentTimeMillis();
log.debug("单个确认发布耗时:" + (end - begin) + "ms");
}
测试:
16:07:48.360 [main] DEBUG confirm - 单个确认发布耗时:8094ms
上面那种方式非常慢,与单个等待确认消息相比,先发布一批消息然后一起确认可以极大地提高吞吐量。
缺点:当发生故障导致发布出现问题时,不知道是哪个消息出问题了,我们必须将整个批处理保存在内存中,以记录重要的信息而后重新发布消息。
当然这种方案仍然是同步的,也一样阻塞消息的发布
// 批量确认
public static void batch() throws Exception {
// 重新声明队列,防止报错
String queueName = UUID.randomUUID().toString();
Channel channel = MqConnectUtil.getChannel();
channel.queueDeclare(MqConnectUtil.QUEUE_NAME, true, false, false, null);
// 开启发布确认
channel.confirmSelect();
// 开始时间
long begin = System.currentTimeMillis();
// 批量确认消息大小
int batchSize = 100;
// 批量发消息
for (int i = 0; i < MESSAGE_COUNT; i++) {
channel.basicPublish("", queueName, null, String.valueOf(i).getBytes());
// 批量进行发布确认
if ((i + 1) % batchSize == 0) {
if (!channel.waitForConfirms()) {
log.debug("消息发送失败");
}
}
}
long end = System.currentTimeMillis();
log.debug("批量确认发布耗时:" + (end - begin) + "ms");
}
结果:
16:19:20.486 [main] DEBUG confirm - 批量确认发布耗时:95ms
异步确认虽然编程逻辑比上两个要复杂,但是性价比最高,无论是可靠性还是效率都没得说, 它是利用回调函数来达到消息可靠性传递的,这个中间件也是通过函数回调来保证是否投递成功, 下面就让我们来详细讲解异步确认是怎么实现的
如图,生产者产生消息,发送到信道中,比如批量发送了100条消息到信道中,这些消息就类似于一个map,key就是消息标号,value就是消息内容。
如果消息成功的发送到broker中,如果broker成功将消息发给mq并持久化,就告诉生产者,我收到你多少号的消息了。这样就能定位具体接收到的消息。
如果消息成功发送到broker中,但是持久化失败,就告诉生产者,我收到你多少号的消息,但是确认失败(持久化失败)了。
代码:
// 异步发布确认
public static void Async() throws Exception {
Channel channel = MqConnectUtil.getChannel();
channel.queueDeclare(MqConnectUtil.QUEUE_NAME, true, false, false, null);
// 开启发布确认
channel.confirmSelect();
// 开始时间
long begin = System.currentTimeMillis();
// 准备消息的监听器,监听哪些消息成功了,哪些消息失败了
// 参数一:确认成功的回调函数
// 参数二:确认失败的回调函数
// deliveryTag:消息的标记
// multiple: 是否为批量确认
channel.addConfirmListener((deliveryTag, multiple) -> {},
(deliveryTag, multiple) -> {log.debug("未确认的消息:{}", deliveryTag);});
// 批量发消息
for (int i = 0; i < MESSAGE_COUNT; i++) {
channel.basicPublish("", UUID.randomUUID().toString(), null, String.valueOf(i).getBytes());
}
long end = System.currentTimeMillis();
log.debug("异步确认发布耗时:" + (end - begin) + "ms");
}
结果:
17:29:14.681 [main] DEBUG confirm - 异步确认发布耗时:64ms
最好的解决方案就是把未确认的消息放到一个基于内存的能被发布线程访问的队列, 比如说用 ConcurrentSkipListMap在 confirm callbacks 与发布线程之间进行消息的传递。
注意:上述代码中,消息的监听器是一个单独的线程,与发布消息的线程不是同一个线程。
获取到未确认的消息:
// 异步发布确认
public static void Async() throws Exception {
Channel channel = MqConnectUtil.getChannel();
channel.queueDeclare(MqConnectUtil.QUEUE_NAME, true, false, false, null);
// 开启发布确认
channel.confirmSelect();
/**
* 1. 将序号与消息进行关联
* 2. 批量删除条目,只要给到序号
* 3. 支持高并发
*/
ConcurrentSkipListMap<Long, Integer> map = new ConcurrentSkipListMap<>();
// 准备消息的监听器,监听哪些消息成功了,哪些消息失败了
// 参数一:确认成功的回调函数
// 参数二:确认失败的回调函数
// deliveryTag:消息的标记
// multiple: 是否为批量确认
channel.addConfirmListener(
(deliveryTag, multiple) -> {
if (multiple) { // 批量
// 2. 删除已经确认的消息,剩下就是未确认的消息
ConcurrentNavigableMap<Long, Integer> confirmed = map.headMap(deliveryTag);
confirmed.clear();
} else {
map.remove(deliveryTag);
}
},
(deliveryTag, multiple) -> {
// 3.打印一下未确认的消息
log.debug("未确认的消息tag:"+ map.get(deliveryTag) + "未确认的消息tag:{}", deliveryTag);}
);
// 开始时间
long begin = System.currentTimeMillis();
// 批量发消息
for (int i = 0; i < MESSAGE_COUNT; i++) {
channel.basicPublish("", UUID.randomUUID().toString(), null, String.valueOf(i).getBytes());
// 1.记录已经发送的消息
map.put(channel.getNextPublishSeqNo(), i);
}
long end = System.currentTimeMillis();
log.debug("异步确认发布耗时:" + (end - begin) + "ms");
}
17:52:25.663 [main] DEBUG confirm - 单个确认发布耗时:9365ms
17:52:26.378 [main] DEBUG confirm - 批量确认发布耗时:163ms
17:52:26.506 [main] DEBUG confirm - 异步确认发布耗时:85ms
单独发布消息:同步等待确认,简单,但吞吐量非常有限。
批量发布消息:批量同步等待确认,简单,合理的吞吐量,一旦出现问题但很难推断出是哪条消息出现了问题。
异步处理:最佳性能和资源使用,在出现错误的情况下可以很好地控制,但是实现起来稍微难些
在生产环境中由于一些不明原因,导致 RabbitMQ 重启,在 RabbitMQ 重启期间生产者消息投递失败, 导致消息丢失,需要手动处理和恢复。于是,我们开始思考,如何才能进行 RabbitMQ 的消息可靠投递呢?
在springboot的rabbitmq的配置文件中添加:
spring.rabbitmq.publisher-confirm-type=correlated
NONE 值:是禁用发布确认模式,是默认值。
CORRELATED 值:是发布消息成功到交换器后会触发回调方法。
SIMPLE 值:经测试有两种效果
@Configuration
public class ConfirmConfig {
public static final String CONFIRM_EXCHANGE_NAME = "confirm.exchange";
public static final String CONFIRM_QUEUE_NAME = "confirm.queue";
//声明业务 Exchange
@Bean("confirmExchange")
public DirectExchange confirmExchange() {
return new DirectExchange(CONFIRM_EXCHANGE_NAME);
}
// 声明确认队列
@Bean("confirmQueue")
public Queue confirmQueue() {
return QueueBuilder.durable(CONFIRM_QUEUE_NAME).build();
}
// 声明确认队列绑定关系
@Bean
public Binding queueBinding(@Qualifier("confirmQueue") Queue queue,
@Qualifier("confirmExchange") DirectExchange exchange) {
return BindingBuilder.bind(queue).to(exchange).with("key1");
}
}
RabbitTemplate.ConfirmCallback
:RabbitTemplate中的一个内部接口:
@FunctionalInterface
public interface ConfirmCallback {
void confirm(@Nullable CorrelationData var1, boolean var2, @Nullable String var3);
}
让生产者感知消息是否发送成功,成功发送和失败发送都做一些事情:
@Component
@Slf4j
public class MyCallback implements RabbitTemplate.ConfirmCallback {
@Autowired
private RabbitTemplate rabbitTemplate;
//依赖注入 rabbitTemplate 之后再设置它的回调对象
// 此注解会在其他注解执行完成后再执行,所以rabbitTemplate先注入,再执行此初始化方法
@PostConstruct
public void init() {
// 设置rabbitTemplate的ConfirmCallBack为我们重写后的类
rabbitTemplate.setConfirmCallback(this);
}
/**
* 交换机不管是否收到消息的一个回调方法
*
* @param correlationData 消息相关数据
* @param ack 交换机是否收到消息
* @param cause 未收到消息的原因
*/
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
String id = correlationData != null ? correlationData.getId() : "";
if (ack) {
log.info("交换机已经收到 id 为:{}的消息", id);
} else {
log.info("交换机还未收到 id 为:{}消息,原因:{}", id, cause);
}
}
}
@RabbitListener(queues = ConfirmConfig.CONFIRM_QUEUE_NAME)
public void receiveMsg(Message message) {
String msg = new String(message.getBody());
log.info("接受到队列 confirm.queue 消息:{}", msg);
}
@GetMapping("sendConfirmMsg/{message}")
public void sendConfirmMsg(@PathVariable String message) {
CorrelationData correlationData = new CorrelationData("1");
rabbitTemplate.convertAndSend(ConfirmConfig.CONFIRM_EXCHANGE_NAME + "123", "key1", message, correlationData);
log.info("当前时间:{},发送消息给确认队列C:{}", new Date(), message);
}
ConfirmController : 当前时间:Sun Mar 06 22:30:24 CST 2022,发送消息给确认队列C:你好1
DelayedLetterQueueConsumer : 接受到队列 confirm.queue 消息:你好1
和我看视频学习到的老师的结果并不一样,老师的结果:
可以看到老师的结果在ack为true的时候,打印了接收日志,而我的并没有,这里我并没有找到原因,我暂时理解为版本不一致,希望有知道的同学告诉我一下。
修改发送的交换机:
rabbitTemplate.convertAndSend(ConfirmConfig.CONFIRM_EXCHANGE_NAME + "223", "key1", message, correlationData);
结果:可以看到进入了消息回调的方法,并报了没有收到消息的原因
ConfirmController : 当前时间:Sun Mar 06 22:34:27 CST 2022,发送消息给确认队列C:你好1
CachingConnectionFactory : Channel shutdown: channel error; protocol method: #method<channel.close>(reply-code=404, reply-text=NOT_FOUND - no exchange 'confirm.exchange123' in vhost '/', class-id=60, method-id=40)
MyCallback : 交换机还未收到 id 为:1消息,原因:channel error; protocol method: #method<channel.close>(reply-code=404, reply-text=NOT_FOUND - no exchange 'confirm.exchange123' in vhost '/', class-id=60, method-id=40)
rabbitTemplate.convertAndSend(ConfirmConfig.CONFIRM_EXCHANGE_NAME+ "123", "key1", message, correlationData);
结果:可以看到confirm的ack为true,所以交换机接收到了消息,但是没有返回
ConfirmController : 当前时间:Sun Mar 06 22:41:24 CST 2022,发送消息给确认队列C:你好1
RabbitTemplate : Returned message but no callback available
在仅开启了生产者确认机制的情况下,交换机接收到消息后,会直接给消息生产者发送确认消息,如果发现该消息不可路由,那么消息会被直接丢弃,此时生产者是不知道消息被丢弃这个事件的。
那么如何让无法被路由的消息帮我想办法处理一下?最起码通知我一声,我好自己处理啊。通过设置 mandatory 参数可以在当消息传递过程中不可达目的地时将消息返回给生产者。
打开回退:
spring.rabbitmq.publisher-returns=true
将MyCallback类中继承RabbitTemplate.ReturnCallback,并重写returnedMessage方法,并注入到rabbitmqTemplate中:
// 确认消息是否从交换机成功到达队列中,失败将会执行,成功则不执行
@Override
public void returnedMessage(Message message, int replayCode, String replayText, String exchange, String routingKey) {
log.debug("消息{},被交换机{}退回,退回原因:{},路由key:", new String(message.getBody()), exchange, replayText, routingKey);
}
结果:
ConfirmController : 当前时间:Sun Mar 06 22:56:59 CST 2022,发送消息给确认队列C:你好1
MyCallback : 消息你好1,被交换机confirm.exchange退回,退回原因:NO_ROUTE,路由key:
有了 mandatory 参数和回退消息,我们获得了对无法投递消息的感知能力,在生产者的消息无法被投递时发现并处理。但有时候,我们并不知道该如何处理这些无法路由的消息,最多打个日志,然后触发报警,再来手动处理。而通过日志来处理这些无法路由的消息是很不优雅的做法,特别是当生产者所在的服务有多台机器的时候,手动复制日志会更加麻烦而且容易出错。而且设置 mandatory 参数会增加生产者的复杂性,需要添加处理这些被退回的消息的逻辑。如果既不想丢失消息,又不想增加生产者的复杂性,该怎么做呢?
前面在设置死信队列的文章中,我们提到,可以为队列设置死信交换机来存储那些处理失败的消息,可是这些不可路由消息根本没有机会进入到队列,因此无法使用死信队列来保存消息。 在 RabbitMQ 中,有一种备份交换机的机制存在,可以很好的应对这个问题。
备份交换机可以理解为 RabbitMQ 中交换机的 “备胎”,当我们为某一个交换机声明一个对应的备份交换机时,就是为它创建一个备胎,当交换机接收到一条不可路由消息时,将会把这条消息转发到备份交换机中,由备份交换机来进行转发和处理,通常备份交换机的类型为 Fanout ,这样就能把所有消息都投递到与其绑定的队列中,然后我们在备份交换机下绑定一个队列,这样所有那些原交换机无法被路由的消息,就会都进入这个队列了。当然,我们还可以建立一个报警队列,用独立的消费者来进行监测和报警。
代码编写:
// 备份交换机
@Bean("backUpExchange")
public FanoutExchange backUpExchange() {
return new FanoutExchange(BACKUP_EXCHANGE_NAME);
}
@Bean("backUpQueue")
public Queue backUpQueue() {
return QueueBuilder.durable(BACKUP_QUEUE_NAME).build();
}
// 报警队列
@Bean("warningQueue")
public Queue warningQueue() {
return QueueBuilder.durable(WARNING_QUEUE_NAME).build();
}
// 备份队列和交换机绑定关系
@Bean
public Binding backUpBinding(@Qualifier("backUpQueue") Queue queue,
@Qualifier("backUpExchange") FanoutExchange exchange) {
return BindingBuilder.bind(queue).to(exchange); // 扇出类型不需要路由key,因为是广播给每一个队列
}
// 报警队列和交换机绑定关系
@Bean
public Binding warningBinding(@Qualifier("warningQueue") Queue queue,
@Qualifier("backUpExchange") FanoutExchange exchange) {
return BindingBuilder.bind(queue).to(exchange); // 扇出类型不需要路由key,因为是广播给每一个队列
}
将确认交换机无法确认消息的时候,将消息转发给备份交换机:
//声明业务 Exchange
@Bean("confirmExchange")
public Exchange confirmExchange() {
// 将确认交换机绑定备份交换机
return ExchangeBuilder.directExchange(CONFIRM_EXCHANGE_NAME).durable(true)
.withArgument("alternate-exchange", BACKUP_EXCHANGE_NAME).build();
}
@RabbitListener(queues = ConfirmConfig.BACKUP_QUEUE_NAME)
public void backUpMsg(Message message) {
String msg = new String(message.getBody());
log.info("备份消息:{}", msg);
}
@RabbitListener(queues = ConfirmConfig.WARNING_QUEUE_NAME)
public void warningMsg(Message message) {
String msg = new String(message.getBody());
log.info("报警消息:{}", msg);
}
备份队列和报警队列都接收到了消息
ConfirmController : 当前时间:Sun Mar 06 23:12:37 CST 2022,发送消息给确认队列C:你好1
DelayedLetterQueueConsumer : 备份消息:你好1
DelayedLetterQueueConsumer : 报警消息:你好1
mandatory参数与备份交换机可以一起使用的时候,如果两者同时开启,消息何去何从?谁的优先级高?
经过上面显示的答案是备份交换机优先级高。
即不会执行重写的ReturnCallback的内容,而被备份交换机处理。