为什么要延迟消息?
场景:在电商的支付业务中,对于一些库存有限的商品,为了更好的用户体验,通常都会在用户下单时立刻扣减商品库存。例如电影院购票、高铁购票,下单后就会锁定座位资源,其他人无法重复购买。
因此,电商中通常的做法就是:对于超过一定时间未支付的订单,应该立刻取消订单并释放占用的库存。
这种在一段时间以后才执行的任务,称之为延迟任务
,而要实现延迟任务,最简单的方案就是利用MQ的延迟消息。
在 RabbitMQ 中实现延迟消息也有两种方案:
-
TTL + 死信队列
-
延迟消息插件
1、TTL + 死信队列
What is 死信?
当一个队列中的消息满足下列情况之一时,可以成为死信(dead letter):
-
消费者使用
basic.reject
或basic.nack
声明消费失败,并且消息的requeue
参数设置为false
-
消息是一个过期消息,超时无人消费
-
要投递的队列消息满了,无法投递
如果一个队列中的消息已经成为死信,并且这个队列通过dead-letter-exchange
属性指定了一个交换机
,那么队列中的死信就会投递到这个交换机中,而这个交换机就称为死信交换机
(Dead Letter Exchange,简称DLX)。
而此时加入有队列与死信交换机绑定,则最终死信就会被投递到这个队列中。
死信交换机的作用呢?
-
收集那些因处理失败而被拒绝的消息
-
收集那些因队列满了而被拒绝的消息
-
收集因TTL(有效期)到期的消息
把死信交换机当做一种消息处理的最终兜底方案,与消费者重试时的RepublishMessageRecoverer
作用类似。
具体实现:
消费者:交换机队列声明 + 绑定死信交换机
package com.itheima.consumer.config;
import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class NormalConfig {
/**
* 声明交换机
*
* @return Direct类型交换机
*/
@Bean
public DirectExchange normalExchange() {
return ExchangeBuilder.directExchange("normal.direct").build();
}
/**
* 声明队列且绑定死信交换机
*/
@Bean
public Queue normalQueue() {
return QueueBuilder
.durable("normal.queue")
.deadLetterExchange("dlx.direct")
.build();
}
/**
* 绑定队列和交换机
*/
@Bean
public Binding normalExchangeBinding(Queue normalQueue, DirectExchange normalExchange) {
return BindingBuilder.bind(normalQueue).to(normalExchange).with("hi");
}
}
消费者:监听dlx.queue
死信队列消息
/**
* 死信队列模拟延迟消息
*
* @param msg
*/
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "dlx.queue", durable = "true"),
exchange = @Exchange(name = "dlx.direct", type = ExchangeTypes.DIRECT),
key = {"hi"}
))
public void listenDlxQueue(String msg) {
System.out.println("消费者1接收到dlx.queue的消息:【" + msg + "】");
}
生产者:发送消息
/**
* 测试发送延迟消息(死信交换机实现)
*/
@Test
void testSendDelayMessage() {
// 不能用Message转换器,因为Message是转换成字节,而消费者已经设置消息转换器了
rabbitTemplate.convertAndSend("normal.direct", "hi", "hello", new MessagePostProcessor() {
@Override
public Message postProcessMessage(Message message) throws AmqpException {
// 设置过期消息
message.getMessageProperties().setExpiration("10000");
return message;
}
});
}
注意:这里的
normal.direct
的RoutingKey
,当消息变为死信并投递到死信交换机时,会沿用之前的RoutingKey
,这样dlx.direct
才能正确路由消息。
注意:RabbitMQ的消息过期是基于追溯方式来实现的,也就是说当一个消息的TTL到期以后不一定会被移除或投递到死信交换机,而是在消息恰好处于队首时才会被处理。
当队列中消息堆积很多的时候,过期消息可能不会被按时处理,因此你设置的TTL时间不一定准确
。
2、DelayExchange 延迟消息插件
基于死信队列虽然可以实现延迟消息,但是太麻烦了。
因此RabbitMQ社区提供了一个延迟消息插件来实现相同的效果。
官方文档说明:https://www.rabbitmq.com/blog/2015/04/16/scheduling-messages-with-rabbitmq
插件下载:https://github.com/rabbitmq/rabbitmq-delayed-message-exchange
由于练习测试安装的MQ是3.8版本,因此这里下载3.8.17版本:
安装插件
首先:查看RabbitMQ的插件目录对应的数据卷
- docker volume inspect mq-plugins
发现:插件目录被挂载到了/var/lib/docker/volumes/mq-plugins/_data
这个目录,于是上传插件到该目录下。
上传后,执行命令,安装插件:
- docker exec -it mq rabbitmq-plugins enable rabbitmq_delayed_message_exchange
具体实现
首先,声明延迟交换机
/**
* 延迟消息
* @param msg
*/
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "delay.queue", durable = "true"),
exchange = @Exchange(name = "delay.direct", delayed = "true"),
key = "delay"
))
public void listenDelayMessage(String msg) {
log.info("接收到delay.queue的延迟消息:{}", msg);
}
其次,发送延迟消息
(基于注解声明,若未安装MQ延迟插件Spring会报错,无法声明队列)
/**
* 测试发送延迟消息(延迟插件实现)
*/
@Test
void testSendDelayMessageByPlugin() {
// 不能用Message转换器,因为Message是转换成字节,而消费者已经设置消息转换器了
rabbitTemplate.convertAndSend("delay.direct", "delay", "hello", new MessagePostProcessor() {
@Override
public Message postProcessMessage(Message message) throws AmqpException {
// 设置过期消息
message.getMessageProperties().setExpiration("10000");
return message;
}
});
}
注意:
延迟消息插件内部会维护一个本地数据库表,同时使用Elang Timers
功能实现计时。如果消息的延迟时间设置较长,可能会导致堆积的延迟消息非常多,会带来较大的CPU开销
,同时延迟消息的时间会存在误差。
因此,不建议设置延迟时间过长的延迟消息。
练习代码参考黑马商城