为什么要使用延迟队列
需求场景:
一 第三方支付平台的支付连接都是有时效性,创建订单后,需要再一定的时间内支付完成
- 微信支付、支付宝支付等
- 也可以不关闭订单,做订单二次支付的操作,但业务链路会更加复杂
- 电商业务里面还会涉及到商品库存的锁定和释放
延迟消息有两种实现方案:
1,基于死信队列
2,基于延迟插件
一、基于死信实现延迟消息
使用RabbitMQ来实现延迟消息必须先了解RabbitMQ的两个概念:死信和死信Exchange
1、消息什么时候变为死信(dead-letter)
- 消息被否定接收,消费者使用basic.reject 或者 basic.nack并且requeue 重回队列属性设为false。
- 消息在队列里得时间超过了该消息设置的过期时间(TTL)。
- 消息队列到达了它的最大长度,之后再收到的消息。
2、 死信交换机 Dead Letter Exchanges
死信交换机就是一种普通的exchange,和创建其他exchange没有两样。
当一个消息在队列里变为死信时,它会被重新publish到另一个exchange交换机上,这个exchange就为DLX。因此我们只需要在声明正常的业务队列时添加一个可选的"x-dead-letter-exchange"参数,值为死信交换机,死信就会被rabbitmq重新publish到配置的这个交换机上,我们接着监听这个交换机就可以了。
3、代码实现
创建配置类
/**
* 基于死信队列实现延迟消费 相关配置
*/
@Configuration
public class DeadLetterRabbitConfig {
//死信交换机,队列,路由相关变量
public static final String EXCHANGE_DLX = "exchange.dlx";
public static final String ROUTING_DLX = "routing.dlx";
public static final String QUEUE_DLX = "queue.dlx";
//业务交换机,队列,路由相关配置
public static final String EXCHANGE_DEMO = "exchange.demo";
public static final String ROUTING_DEMO = "routing.demo";
public static final String QUEUE_DEMO = "queue.demo";
// 定义业务交换机
@Bean(name = "demoExchange")
public DirectExchange demoExchange(){
return new DirectExchange(EXCHANGE_DEMO,true,false,null);
}
// 定义业务队列
@Bean
public Queue demoQueue(){
//添加x-dead-letter-exchange,值为死信交换机
//参数x-dead-letter-routing-key可以修改该死信的路由key,不设置则使用原消息的路由key
Map<String,Object> map = new HashMap<>();
map.put("x-dead-letter-exchange",EXCHANGE_DLX);
map.put("x-dead-letter-routing-key",ROUTING_DLX);
return new Queue(QUEUE_DEMO,true,false,false,map);
}
@Bean
public Binding demoBind(){
return BindingBuilder.bind(demoQueue()).to(demoExchange()).with(ROUTING_DEMO);
}
@Bean(name = "dlxExchange")
public DirectExchange dlxExchange(){
return new DirectExchange(EXCHANGE_DLX,true,false);
}
@Bean
public Queue dlxQueue(){
return new Queue(QUEUE_DLX,true,false,false);
}
@Bean
public Binding dlkBind(){
return BindingBuilder.bind(dlxQueue()).to(dlxExchange()).with(ROUTING_DLX);
}
}
生产者发送消息
@GetMapping("sendDeadLettle")
@ApiOperation(value = "测试延迟消费-基于死信队列")
@ApiOperationSupport(order = 7)
public String sendDeadLettle(String message,int time) {
//exchange和routingKey都为业务的就可以,只需要设置消息的过期时间
rabbitTemplate.convertAndSend(DeadLetterRabbitConfig.EXCHANGE_DEMO, DeadLetterRabbitConfig.ROUTING_DEMO,message,
new MessagePostProcessor() {
@Override
public Message postProcessMessage(Message message) throws AmqpException {
//设置消息的过期时间,是以毫秒为单位的
String ttl = String.valueOf(time*1000);
message.getMessageProperties().setExpiration(ttl);
return message;
}
}
);
log.info("生产者发送死信消息, time: {},msg: {}",DateUtils.dateTimeNow("yyyy-MM-dd HH:mm:ss"),message);
return "ok";
}
消费者
@Component
@Slf4j
public class DeadLetterConsumer {
@RabbitListener(queues = DeadLetterRabbitConfig.QUEUE_DLX)
public void process(Message message, Channel channel) {
// 消息序号
long deliveryTag = message.getMessageProperties().getDeliveryTag();
String msg = new String(message.getBody(), StandardCharsets.UTF_8);
try {
channel.basicAck(deliveryTag,false);
} catch (IOException e) {
/**
* TODO 消费者消费消息异常,手动否认信息,将消息退回到队列中
* tag:消息序号
* multiple:消息的标识,是否确认多条,false只确认当前一个消息收到,true确认所有consumer获得的消息(成功消费,消息从队列中删除
* requeue:是否要退回到队列
*/
try {
channel.basicNack(deliveryTag, true, false);
} catch (IOException ex) {
}
}
log.info("DeadLetterConsumer收到消息, time: {}, msg: {} " ,DateUtils.dateTimeNow("yyyy-MM-dd HH:mm:ss") ,msg);
}
}
启动项目,打开rabbitmq控制台,可以看到交换机和队列已经创建好
生产者发送死信消息, time: 2023-09-24 13:18:19,msg: A
------生产者发送消息至exchange成功,消息唯一标识: null, 确认状态: true, 造成原因: null-----
DeadLetterConsumer收到消息, time: 2023-09-24 13:04:57, msg: A
从控制台的输出来看,刚好10s后接收到消息
4、死信队列的一个小注意点
当我往死信队列中发送两条不同过期时间的消息时,如果先发送的消息A的过期时间大于后发送的消息B的过期时间时,由于消息的顺序消费,消息B过期后并不会立即重新publish到死信交换机,而是会等到消息A过期后一起被消费。
依次发送两个请求,消息A先发送,过期时间30S,消息B后发送,过期时间10S,我们想要的结果应该是10S收到消息B,30S后收到消息A,但结果并不是,
消息A30S后被成功消费,紧接着消息B被消费。因此当我们使用死信队列时应该注意是否消息的过期时间都是一样的,比如订单超过10分钟未支付修改其状态。如果当一个队列各个消息的过期时间不一致时,使用死信队列就可能达不到延时的作用。这时候我们可以使用延时插件来实现这需求。
二、基于延迟插件实现
1、安装插件
RabbitMQ Delayed Message Plugin是一个rabbitmq的插件,所以使用前需要安装它,可以参考的GitHub地址:https://github.com/rabbitmq/rabbitmq-delayed-message-exchange
将插件文件复制到RabbitMQ安装目录的plugins
目录下;
进入RabbitMQ安装目录的sbin
目录下,使用如下命令启用延迟插件
rabbitmq-plugins enable rabbitmq_delayed_message_exchange
启用插件成功后就可以看到如下信息,之后重新启动RabbitMQ服务即可
2、代码实现
- 安装好插件后只需要声明一个类型type为"x-delayed-message"的exchange,并且在其可选参数下配置一个key为"x-delayed-typ",值为交换机类型(topic/direct/fanout)的属性。
- 声明一个队列绑定到该交换机
- 在发送消息的时候消息的header里添加一个key为"x-delay",值为过期时间的属性,单位毫秒。
- 代码就在上面,配置类为DMP开头的,发送消息的方法为send2()。
- 启动后在rabbitmq控制台可以看到一个类型为x-delayed-message的交换机
/**
* 基于插件实现延迟消费 相关配置
*/
@Configuration
public class DeadLetterRabbit2Config {
public static final String EXCHANGE_DMP = "exchange.dmp";
public static final String ROUTING_DMP = "routing.dmp";
public static final String QUEUE_DMP = "queue.dmp";
//1、声明一个类型为x-delayed-message的交换机
//2、参数添加一个x-delayed-type值为交换机的类型用于路由key的映射
@Bean
public CustomExchange dmpExchange(){
Map<String, Object> arguments = new HashMap<>(1);
arguments.put("x-delayed-type", "direct");
return new CustomExchange(EXCHANGE_DMP,"x-delayed-message",true,false,arguments);
}
@Bean
public Queue dmpQueue(){
return new Queue(QUEUE_DMP,true,false,false);
}
@Bean
public Binding dmpBind(){
return BindingBuilder.bind(dmpQueue()).to(dmpExchange()).with(ROUTING_DMP).noargs();
}
}
@GetMapping("sendDeadLettle2")
@ApiOperation(value = "测试延迟消费-基于插件")
@ApiOperationSupport(order = 8)
public String sendDeadLettle2(String message,int time) {
rabbitTemplate.setMandatory(Boolean.FALSE);
rabbitTemplate.convertAndSend(DeadLetterRabbit2Config.EXCHANGE_DMP, DeadLetterRabbit2Config.ROUTING_DMP,message,
new MessagePostProcessor() {
@Override
public Message postProcessMessage(Message message) throws AmqpException {
//使用延迟插件只需要在消息的header中添加x-delay属性,值为过期时间,单位毫秒
message.getMessageProperties().setHeader("x-delay",time*1000);
return message;
}
}
);
log.info("生产者发送死信消息, time: {},msg: {}",DateUtils.dateTimeNow("yyyy-MM-dd HH:mm:ss"),message);
return "ok";
}
@Component
@Slf4j
public class DeadLetterConsumer2 {
@RabbitListener(queues = DeadLetterRabbit2Config.QUEUE_DMP)
public void process2(Message message, Channel channel) {
// 消息序号
long deliveryTag = message.getMessageProperties().getDeliveryTag();
String msg = new String(message.getBody(), StandardCharsets.UTF_8);
try {
channel.basicAck(deliveryTag,false);
} catch (IOException e) {
/**
* TODO 消费者消费消息异常,手动否认信息,将消息退回到队列中
* tag:消息序号
* multiple:消息的标识,是否确认多条,false只确认当前一个消息收到,true确认所有consumer获得的消息(成功消费,消息从队列中删除
* requeue:是否要退回到队列
*/
try {
channel.basicNack(deliveryTag, true, false);
} catch (IOException ex) {
}
}
log.info("DeadLetterConsumer2收到消息, time: {}, msg: {} " ,DateUtils.dateTimeNow("yyyy-MM-dd HH:mm:ss") ,msg);
}
}
现在不会出现死信队列出现的问题
3、延时插件小问题
RabbitMQ延迟队列消息路由失败(312 NO_ROUTE)原因及处理
原因:延迟插件不支持mandatory=true
参数,如果启用会同时收到延迟消息
和路由失败消息
解决方法
设置mandatory=false
三、两种实现方式对比
死信队列
死信队列是这样一个队列,如果消息发送到该队列并超过了设置的时间,就会被转发到设置好的处理超时消息的队列当中去,利用该特性可以实现延迟消息。
延迟插件
通过安装插件,自定义交换机,让交换机拥有延迟发送消息的能力,从而实现延迟消息。
结论
由于死信队列方式需要创建两个交换机(死信队列交换机+处理队列交换机)、两个队列(死信队列+处理队列),而延迟插件方式只需创建一个交换机和一个队列,所以后者使用起来更简单。