大家好!如果你已经读过我之前的文章Spring Boot + RabbitMQ:轻松掌握五种基本工作模式,并且成功地让RabbitMQ在你的项目落实,那么恭喜你,你已经踏上了消息队列的快车道!但是,当你以为自己已经是消息队列的小能手时,是否想过,这其实只是冰山一角呢?
现在,让我们一起踏上一段新的旅程——《Spring Boot + RabbitMQ:进阶探索之高级话题》。在这篇文章中,我们将深入探讨一些更复杂也更有趣的话题,比如如何处理消息的持久化、事务管理以及死信队列等。准备好迎接挑战了吗?别担心,我会在这里陪伴你,确保你不仅能跟上步伐,还能享受到学习的乐趣!
消息的可靠性
消息的持久化:保障消息不丢失的关键
在开始讨论具体的实现细节之前,我们先来探讨一下为什么首先需要介绍消息持久化。设想一个场景:当消息成功发送至队列,消费者正准备消费时,系统突然遭遇意外停机或重启。重启后,你可能会发现消息、队列甚至交换机都不见了——这显然是不可接受的情况。因此,确保消息即使在服务器重启后也能被恢复,成为了构建可靠消息传递系统的一个基本要求。消息持久化正是解决这一问题的核心机制。
持久化不仅仅是防止因意外停机导致的数据丢失那么简单,它还是确保消息可靠传输的必要条件。即便系统能够持续运行而不重启,也无法完全排除由于软件错误、硬件故障或其他外部因素导致的服务中断。尤其是在引入了第三方组件的情况下,系统的稳定性会受到更多不确定因素的影响。近年来,即便是大型企业也频繁发生生产环境下的事故,这进一步凸显了实施有效持久化策略的重要性。
在上一篇文章中,我们已经掌握了如何通过RabbitMQ管理界面和命令行工具来创建队列和交换机。然而,在这一过程中,有几个重要的配置选项对于实现消息持久化至关重要。现在,让我们回过头来看看这些配置选项,并深入了解它们是如何工作的。
在创建队列和交换机的过程中,有一个关键的参数可能没有引起大家足够的注意——那就是持久化(Durable)选项。
什么是持久化?
简单来说,持久化意味着数据会被保存到磁盘上,这样即使在服务器重启后,数据也不会丢失。对于交换机而言,设置为持久化表示该交换机的信息会被存储下来,确保即使在RabbitMQ服务重启后,交换机仍然存在,不会因为重启而消失。为了验证持久化的效果,我们可以使用订阅发布模式
来进行测试。
在这个实验中,我们将重点放在交换机的持久化特性上,暂时忽略队列和消息的持久化设置。我们的目标是验证交换机在服务器重启后的持久性。
大家在创建好交换机之后,进行绑定队列,然后在交换机发送几条信息
此时我们来尝试重启rabbitmq看看这些消息还是否存在?大家也可以先猜一下。
重启之后大家可以惊讶的发现,咦?我的交换机呢?我的队列还在,消息也还在,绑定关系没了,成了默认的交换机了。当然了,大家也可以通过后台发送的方式来测试。
rabbitTemplate.convertAndSend( "test_persistence","", "test 011");
rabbitTemplate.convertAndSend( "test_persistence","", "test 012");
rabbitTemplate.convertAndSend( "test_persistence","", "test 013");
交换机:
- 当你将交换机设置为持久化(
durable
)时,RabbitMQ 会将交换机的信息存储到磁盘上。这样,即使服务器重启或发生其他中断,交换机仍然会存在。 - 如果交换机设置为非持久化,它将仅存在于内存中。在这种情况下,服务器重启或中断会导致交换机丢失。
队列:
- 持久化队列(
durable
)的数据会被存储到磁盘上。因此,即使服务器重启或中断,队列及其内部的消息仍然会存在。 - 非持久化队列(
non-durable
)的数据仅存储在内存中。如果服务器重启或中断,队列及其内部的消息将会丢失。
队列
消息
此时有小伙伴们想知道怎么在后端来设置持久化呢?这就来了。
发信息
rabbitTemplate.convertAndSend("test_persistence", "", "test 011",(message)-> {
MessageProperties properties = message.getMessageProperties();
properties.setDeliveryMode(MessageDeliveryMode.NON_PERSISTENT); //设置为非持久化,默认持久化
return message;
});
MessagePostProcessor
是 Spring AMQP 提供的一个接口,用于在消息发送之前对消息进行额外的处理。通过实现MessagePostProcessor
接口,您可以在消息被序列化并发送到 RabbitMQ 之前修改消息的属性或内容。这在许多场景下都非常有用,例如设置消息的持久化模式、添加消息头、修改消息体等。
交换机和队列
@RabbitListener(bindings =
@QueueBinding(
value = @Queue(value = "queue_work_4",durable = "true"), // durable用来标记是否持久化,默认是true
exchange = @Exchange(name = "test_work_1", type = ExchangeTypes.FANOUT,durable = "true") // durable同样的
))
总结
这意味着,如果我们把某一项设置为非持久化,那么在重启之后该设置就会失效。
交换机、队列、消费者的确认机制
交换机、队列的确认机制
在上面我们已经解决了消息在重启后不易丢失的问题。接下来解决在生产者发送消息到交换机、队列过程中不丢失。举一个例子:小时候我们约邻居家小孩出去玩会说:”小明,明天去广场玩“。小明会回答:”好的“。在这个场景中小明的回答更像是一种针对你的应答,要不然你也不知道小明明天到底去不去广场玩。回到我们的案例中来就是生产者端把消息发送到交换机,交换机要告诉生产者”我已经收到你发的信息了“。交换机把消息转交给队列时,队列也需要应答交换机”我已经收到你发的信息了“。接下来我们就来解决这一问题吧。
配置
rabbitmq:
host: ${rabbitmq.host}
password: xx
username: xx
virtual-host: /
# 交换机的确认
publisher-confirm-type: CORRELATED
# 队列的确认
publisher-returns: true
@Configuration
public class RabbitConfig {
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
// 启用发布确认
rabbitTemplate.setMandatory(true); // 设置为true,表示如果消息无法路由到任何队列,则返回给生产者
// 交换机
rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
if (ack) {
System.out.println("Message successfully sent!");
} else {
System.out.println("Message lost!");
}
});
// 队列
rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
System.out.println("Returned message: " + message);
});
return rabbitTemplate;
}
}
此时我们启动项目,当我们向一个没有的交换机发送消息,在测试类中发送信息,查看打印的信息
rabbitTemplate.convertAndSend( "test_persistence_134","", "test 012",new CorrelationData("message_id" + System.currentTimeMillis()));
在RabbitMQ中,
CorrelationData
是一个用于关联发送消息和接收确认(或返回)的类。它主要用于在发布确认和发布返回场景中提供额外的信息,以便在回调函数中识别和处理特定的消息。
此时我们在测试一个队列的问题,我们使用一个路由交换机,随便起一个路由键来测试
rabbitTemplate.convertAndSend( "news_exchange","gossipfasdfa", "test 012",new CorrelationData("message_id" + System.currentTimeMillis()));
看过我们上一篇的应该知道我们是存在 news_exchange交换机的,但是是没有 gossipfasdfa 路由键
由此我们就可以根据这些配置来确保生产者发送消息时交换机和队列能够确认。如果无法确认我们就可以根据日志分析出哪里出现了问题,进行优化整改。
本文使用的是springboot 2.7.5 版本
消费者确认机制
当消费者消费完消息之后,通知上游自己已经确认收到消息了。废话不多说,直接上代码。
rabbitmq:
host: ${rabbitmq.host}
password: hutu
username: hutu
virtual-host: /
publisher-confirm-type: CORRELATED
publisher-returns: true
# 更改为手动模式
listener:
simple:
acknowledge-mode: manual
acknowledge-mode 有三种模式
自动确认 (AUTO)
: 当消费者从队列中接收到消息后,RabbitMQ 会立即认为该消息已经被成功处理,并将其从队列中移除。这种模式下,如果消费者接收到消息但在处理过程中崩溃或发生错误,那么这条消息将会丢失,因为 RabbitMQ 认为它已经被正确处理了。手动确认 (MANUAL)
: 在这种模式下,消费者接收到消息后不会立即向 RabbitMQ 发送确认信息。只有当消费者真正完成了消息的处理并显式地发送一个确认信号给 RabbitMQ 之后,RabbitMQ 才会将该消息从队列中删除。如果消费者在处理消息期间崩溃,RabbitMQ 可以检测到这一点,并将未被确认的消息重新放入队列中,以便其他消费者可以尝试处理这些消息。NONE
: 在 RabbitMQ 的 API 和文档中,并没有直接使用NONE
这个术语来描述消费者的确认模式。通常不建议使用
消费者端代码演示
@RabbitListener(bindings =
@QueueBinding(
value = @Queue(value = "queue_sports_gossip"),
exchange = @Exchange(name = "news_exchange"),
key = {"gossip","sports"} // 可以写多个路由key
),ackMode = "MANUAL") // 进行单独设置接收模式为MANUAL 跟上面yml文件中配置的一样的意思,大家可以二选一
public void getRouteGossipMessage(String object,Message message, Channel channel) {
MessageProperties messageProperties = message.getMessageProperties();
// 类似于数据库中的主键 ID,用于唯一标识每个消息。是一个递增的整数,由 RabbitMQ 在消息传递给消费者时生成,并附带在消息属性中。这个标签的主要用途是在手动确认模式下,消费者可以使用它来确认消息已被成功处理。
long deliveryTag = messageProperties.getDeliveryTag();
// 获取到路由键
String receivedRoutingKey = messageProperties.getReceivedRoutingKey();
if ("sports".equals(receivedRoutingKey)) { // todo
}
if ("gossip".equals(receivedRoutingKey)) { // todo
}
try {
// 手动ack方法,第二个参数用来控制是否可以批量ack,后面在来介绍
channel.basicAck(deliveryTag,true);
} catch (Exception e) {
try {
// 为false则拒绝消息,丢掉该消息;为true会重新放回队列,重新消费
channel.basicReject(deliveryTag, false);
} catch (IOException ex) {
throw new RuntimeException(ex);
}
throw new RuntimeException(e);
}
System.out.println("----queue_work_3------"+deliveryTag+"-------------");
System.out.println("gossip ~: " + object);
}
设置手动确认区别:yml文件配置会影响所有使用默认配置的
@RabbitListener
注解的方法或类。,而ackMode = "MANUAL"这种方式只会影响该注解所标记的方法或类
拒绝方法还有一个:channel.basicNack(deliveryTag, false, true); 第二个参数表示为是否批量拒绝(后面会解释),第三个跟basicReject方法类似了,为true则重新回到队列,为false则丢弃
其他
单条消息预取
在 RabbitMQ 配置中,通过设置 prefetch
参数为 1
,可以确保每次从队列中拉取一条消息。这有助于控制消费者的并发处理能力,特别是在处理资源密集型任务时,可以避免消费者过载。
rabbitmq:
host: ${rabbitmq.host}
password: xx
username: xx
virtual-host: /
publisher-confirm-type: CORRELATED
publisher-returns: true
listener:
simple:
acknowledge-mode: manual
prefetch: 1 # 每次拉取一条
此时大家可以通过后端方式给指定路由队列发送100条信息,然后启动项目,为了让效果更加明显,可以添加一些睡眠
if ("sports".equals(receivedRoutingKey)) { // todo
TimeUnit.SECONDS.sleep(5);
}
if ("gossip".equals(receivedRoutingKey)) { // todo
TimeUnit.SECONDS.sleep(5);
}
当没有设置prefetch
为1时,启动项目时可以在控制台发现,Ready
从100瞬间变为0,unacked
在慢慢的手动确认
当设置了prefetch
时,启动项目可以看到每过5sReady
数量就会减一,然后unacked
就一直是1,因为每次只拉取1条信息,带确认的也就只有1
批量处理消息 (有待完善)
channel.basicAck(deliveryTag,true); // 批量手动确认
channel.basicNack(deliveryTag, false, true); // 批量是否丢弃/扔回队列
废话不多说,直接上代码
publisher-confirm-type: CORRELATED
publisher-returns: true
listener:
simple:
acknowledge-mode: manual
prefetch: 5
batch-size: 5 # 每次批量消费的消息数量
consumer-batch-enabled: true # 开启批量消费
@Configuration
@ConfigurationProperties("spring.rabbitmq.listener.simple")
@Data
public class RabbitConfig {
private int prefetch;
private int batchSize;
private boolean consumerBatchEnabled;
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
rabbitTemplate.setMandatory(true);
// 交换机
rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
if (ack) {
System.out.println("Message successfully sent!");
} else {
System.out.println("Message lost!");
}
// 当发送消息没有携带correlationData时,可以注释掉,否则会出现异常
System.out.println(correlationData.getId());
});
// 队列
rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
System.out.println("Returned message: " + message);
});
System.out.println("==================");
return rabbitTemplate;
}
// 二选一测试 Ⅰ
// 一个工厂,用来开启批量的,没有会报错,不是很懂这个
@Bean
public SimpleRabbitListenerContainerFactory cusSimpleRabbitListenerContainerFactory(ConnectionFactory connectionFactory) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setAcknowledgeMode(AcknowledgeMode.MANUAL);
factory.setPrefetchCount(prefetch);
factory.setBatchSize(batchSize);
factory.setConsumerBatchEnabled(consumerBatchEnabled);
factory.setBatchListener(true); // 设置批量监听
return factory;
}
// 二选一测试 Ⅱ
@Bean
public String cusSimpleRabbitListenerContainerFactory(SimpleRabbitListenerContainerFactory containerFactory) {
containerFactory.setBatchListener(true);
return "cusSimpleRabbitListenerContainerFactory";
}
}
如果配置了上面的SimpleRabbitListenerContainerFactory
则下面方式则需要放开注释,使用该指定的工厂,如果有其他队列的消费者没有指定,会报错,至于为什么笔者目前不是很清楚.如果使用第二个,则可以不用指定工厂,也能正常启动,测试成功
@RabbitListener(bindings =
@QueueBinding(
value = @Queue(value = "queue_sports_gossip"),
exchange = @Exchange(name = "news_exchange"),
key = {"sports", "gossip"}
)/*,containerFactory = "cusSimpleRabbitListenerContainerFactory"*/)
public void getRouteMessage(List<Message> message, Channel channel) throws InterruptedException {
// 根据我们的配置,此时数量是5,前提是队列中消息大于5的情况
for (Message msg : message) {
System.out.println("get message:" + new String(msg.getBody(), StandardCharsets.UTF_8));
}
long lastDeliveryTag = message.get(message.size() - 1).getMessageProperties().getDeliveryTag();
try {
// 批量接收,deliveryTag小于等于当前tag的就会被手动ack
// 比如当前 这么多消息里面有deliveryTag 0,1,2,3 ... 当tag为10时,那么小于等于10的就会被ack
channel.basicAck(lastDeliveryTag,true);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
测试完之后,可以把对应的批量处理yml配置注释掉哦,避免影响后面的测试
如果有小伙伴比较了解这一块的话,请在评论区帮我们讲解一下哦,学习中~~
超时
今天,我们将探讨一个在消息队列系统中常见的挑战——消息的过期时间管理,特别是在使用 RabbitMQ 进行消息传递时。这个问题不仅关系到系统的性能,还直接影响到系统的可靠性和数据的一致性。
想象一下这样一个场景:我们的应用系统通过 RabbitMQ 发送任务消息给多个消费者。然而,在某些情况下,消息可能因为网络延迟、消费者负载过高或是其他原因而长时间未能被处理。这不仅会导致资源浪费,还可能引发数据不一致的问题。例如,如果一条订单确认消息因为长时间未被处理而导致订单状态更新失败,那么客户可能会遇到支付成功但订单未生成的情况,这对用户体验和业务运营都会产生负面影响。
为了应对这一挑战,RabbitMQ 提供了多种机制来管理和控制消息的有效期,包括设置消息的 TTL(Time To Live)属性。通过合理配置消息的过期时间,我们可以有效地减少无效消息对系统的影响,提高系统的整体性能和可靠性。
队列的自动清理机制
控制台方式
创建队列,为其设置TTL属性,然后为其绑定上news_exchange
交换机上,并设置路由键tiemout
小伙伴们也可以绑定到其他队列,表现出的效果是一样的
后端代码方式
@Bean
public Queue queueSportsGossip() {
// 设置队列的 TTL 为 10000 毫秒(10s)
return QueueBuilder.durable("queue_test_timout_2")
.ttl(10000)
.build();
}
@Bean
public DirectExchange newsExchange() {
return new DirectExchange("news_exchange");
}
@Bean
public Binding binding(Queue queueSportsGossip, DirectExchange newsExchange) {
return BindingBuilder.bind(queueSportsGossip)
.to(newsExchange)
.with("timeout_2");
}
大家看这段是不是跟我们上面一直写的注解方式有很大不同,这也是创建队列、交换机和绑定关系的方式之一。由于使用注解方式不好设置队列的过期时间,所以采用此方法来设置过期时间。如图所示,绑定成功。
往该队列发送若干信息,然后等待10s之后查看,消息是否还有。
如图所示,很明显,当时间到了,无论是否有消费者正在消费,队列里面的消息都会被清除。那么就有人想了,能不能每条信息的TTL不一样呢?这样就能够精准的控制每一条信息了,当然可以了
后端方式
MessagePostProcessor messagePostProcessor = message -> {
// 设置消息过期时间
message.getMessageProperties().setExpiration("10000");
return message;
};
for (int i = 0; i < 100; i++) {
rabbitTemplate.convertAndSend("news_exchange", "gossip", "hello:",messagePostProcessor);
}
效果图如图所示,会发现过了10s之后,消息自动清空了,如果大家想要测试的更加复杂一点可以进行一部分设置
// 设置消息过期时间
MessagePostProcessor messagePostProcessor10 = message -> {
message.getMessageProperties().setExpiration("60000");
return message;
};
MessagePostProcessor messagePostProcessor15 = message -> {
message.getMessageProperties().setExpiration("5000");
return message;
};
for (int i = 0; i < 20; i++) {
if (i > 10){
rabbitTemplate.convertAndSend("news_exchange", "tech", "hello:", messagePostProcessor10);
} else {
rabbitTemplate.convertAndSend("news_exchange", "tech", "hello:", messagePostProcessor15);
}
}
我们设置了两种不同TTL的消息,来看一下其效果
死信队列
说到死信队列,我相信很多人都听过吧,但是这到底是什么东西呢?这就来讲解它.
在 RabbitMQ 消息队列中,有时候消息因为各种原因无法被正常消费,比如消息过期、队列达到最大长度限制、消费者拒绝消息且重新入队标志设为 false
等情况。这时,这些消息就会变成“死信”,需要被特殊处理。
什么是死信队列?
死信队列是一种特殊的队列,用于接收那些无法被正常处理的消息。通过配置 RabbitMQ 的死信交换机(DLX),我们可以将这些死信消息自动转发到另一个指定的队列中,以便进行重试、记录日志或其他处理操作。
大家通过这些文字消息可能不太了解,接下来我带着大家实操一下,让大家了解其样貌.
首先我们先创建一个正常的队列和交换机(direct)。这里先使用控制台方式来创建,后面在使用java代码来创建。创建过程就先省略了。
String USER_EX = "user_ex";
String USER_EX_QUEUE = "user_ex_queue";
String USER_EX_QUEUE_RK = "user_ex_queue_rk";
//死信
String USER_DEAD_EX = "user_dead_ex";
String USER_DEAD_EX_QUEUE = "user_dead_ex_queue";
String USER_DEAD_EX_QUEUE_RK = "user_dead_ex_queue_rk";
死信队列正常绑定,正常队列有一些特殊,如图所示
创建好队列后,将其与交换机绑定起来
效果图如下
此时我们可以向正常队列发送消息,由于我们设置了TTL为10s,所以等待10s后我们观察死信队列中是否有消息的产生即可验证是否配置成功
如图所示表示我们已经成功发送了消息,并且在10s后消息过期
此时我们可以来到死信队列中查看是否有消息产生,如图所示,我们的死信队列配置成功
接下来我们将采用java代码的方式来创建该队列,将控制台刚刚创建的死信队列和正常队列删除,我们仍然以该名称命名.由于死信队列有些麻烦,所以我们不太方便使用@RabbitListener
来完成,所以我们采用原始的方法@Bean
的方式来创建
// 正常交换机 begin
@Bean
public DirectExchange userDirectExchange(){
return new DirectExchange(USER_EX);
}
// 重点参考点
@Bean
public Queue userQueue() {
// 设置队列的 TTL 为 10000 毫秒(10s)
return QueueBuilder.durable(USER_EX_QUEUE)
.ttl(10000)
.deadLetterExchange(USER_DEAD_EX)
.deadLetterRoutingKey(USER_DEAD_EX_QUEUE_RK)
.maxLength(100)
.build();
}
@Bean
public Binding binding(Queue userQueue, DirectExchange userDirectExchange) {
return BindingBuilder.bind(userQueue)
.to(userDirectExchange)
.with(USER_EX_QUEUE_RK);
}
// 正常交换机 end
// 死信交换机 begin
@Bean
public DirectExchange userDeadDirectExchange(){
return new DirectExchange(USER_DEAD_EX);
}
@Bean
public Queue userDeadQueue() {
return QueueBuilder.durable(USER_DEAD_EX_QUEUE)
.build();
}
@Bean
public Binding userDeadBinding(Queue userDeadQueue, DirectExchange userDeadDirectExchange) {
return BindingBuilder.bind(userDeadQueue)
.to(userDeadDirectExchange)
.with(USER_DEAD_EX_QUEUE_RK);
}
此时大家创建结束后,可以查看控制台绑定关系是否建立,然后发送一条消息到正常队列测试是否正确.也可以使用后端方式来测试,我们使用后端方式来测试发送101条消息后,会是什么样的效果呢?大家不妨先猜猜看.
可以看到瞬间正常队列充满了100条信息,第101条信息则被发到了死信队列,等正常队列的消息都过期后则死信队列变成了101条。
到这里死信队列算也是讲完了,但是还是是有一点问题的,比如我想要实现订单15分钟后不支付就取消,我该如何实现呢?大家伙想到了哪几种方式呢?可以打在评论区哦~。使用我们刚才介绍的死信队列也是可以完成的哦,大家伙想到该怎么做了吗?
思路:其实也就是我们创建一个服务于订单的交换机和队列,然后创建一个死信将其绑定起来,等到订单消息过期之后,就会发送到死信队列,然后我们的死信消费者就可以消费该订单消息(更改订单状态、更改数量...,根据自己的业务场景进行变更)。
不知道大家发现问题了没有,我们设置了正常队列的TTL,也就是说,这个队列每过10s就自动清空一次,所以针对我们的每一条订单消息都有15分钟的过期时间,显然不是太符合逻辑。此时我们进行更改,也就是把创建队列时的 .ttl(10000) 移除掉即可,并且在发送消息时指定其过期时间(在上面讲述过如何指定每一条信息的过期时间),此时等待时间过期之后,就可以到死信队列查看,是否可以生效。
这里就不过多描述了,小伙伴们可以根据我的描述自行实现哦~~~ 可以先把每条消息过期时间调小一点,方便测试哦
监听死信队列就很简单了
@RabbitListener(queues = {"user_dead_ex_queue"})
public void deadMessage(String object,Message message, Channel channel) {
System.out.println("deadMessage 接收到信息啦~~~~" + object);
}
如果死信队列有消息(大于5条哦),并且按照本文的配置启动时可能会有小彩蛋哦
(可以先把其他消费者注释掉,只保留死信的消费者),请仔细观察控制台输出的日志信息及监控界面,相信各位小伙伴能够独立探索出其中的奥秘所在!
在这里补充一下,如果消息和队列都设置了TTL,那么谁的时间短则优先生效!
至此,本文的内容就告一段落了。鉴于笔者的研究尚不够深入,文中难免存在疏漏之处,恳请各位读者不吝指正,提出宝贵意见。