目前常见的应用软件都有消息的延迟推送的影子,应用也极为广泛,例如:
淘宝七天自动确认收货,自动评价功能等。在我们签收商品后,物流系统会在七天后延时发送一个消息给支付系统,通知支付系统将款打给商家,这个过程持续七天,就是使用了消息中间件的延迟推送功能;相应的,自动评价也是类似的。
12306 购票支付确认页面。我们在选好票点击确定跳转的页面中往往都会有倒计时,代表着 30 分钟内订单不确认的话将会自动取消订单。其实在下订单那一刻开始购票业务系统就会发送一个延时消息给订单系统,延时30分钟。
当用户下订单后,将用户的订单的标识全部发送到延时队列中,30分钟后进去消费队列中被消费,消费时先检查该订单的状态,如果未支付则标识该订单失效,如果之前已经支付了,则可以通过逻辑代码判断来忽略掉收到的消息。
有以下几种延时任务处理方式:
Java自带的DelayQueue队列(底层代码DelayQueue,而Delayed继承了Comparable,所以,可以实现一个排序效果)
这是java本身提供的一种延时队列,如果项目业务复杂性不高可以考虑这种方式。它是使用jvm内存来实现的,停机会丢失数据(需要自行持久化),扩展性不强。
使用redis监听key的过期来实现
当用户下订单后把订单信息设置为redis的key,30分钟失效,程序编写监听redis的key失效,然后处理订单(我也尝试过这种方式)。这种方式最大的弊端就是只能监听一台redis的key失效,集群下将无法实现,也有人监听集群下的每个redis节点的key,但我认为这样做很不合适。如果项目业务复杂性不高,redis单机部署,就可以考虑这种方式。
而其他的解决方案,重点讲解延迟插件,以前的死信队列+TTL过期时间方式,请自行研究。
在 RabbitMQ3.6.x 之前我们一般采用死信队列+TTL过期时间来实现延迟队列,我们这里不做过多介绍,网上很多文章都有过介绍。在 RabbitMQ 3.6.x 开始,RabbitMQ 官方提供了延迟队列的插件,可以下载放置到 RabbitMQ 根目录下的 plugins 下。
本人使用的RabbitMQ是3.7.7版本,rabbitmq_delayed_message_exchange-3.8.0.ez这个插件放到RabbitMQ安装目录的plugins文件中 在RabbitMQ 安装目的sbin用cmd使用命令
插件下载地址:https://www.rabbitmq.com/community-plugins.html
延迟:发消息后,要过一段时间才进行消费。
使用过死信队列,完成对消息进行延迟,借助的是:死信交换机与延迟队列来实现,配置相对麻烦
实际工作中,如果要实现消息延迟,还可以借助延迟插件来实现通过安装插件,通过自定义交换机,让交换机拥有延迟发送消息的能力,从而实现延迟消息。
两种区别:
由于死信队列方式需要创建两个交换机(死信队列交换机+处理队列交换机)、两个队列(死信队列+处理队列),而延迟插件方式只需创建一个交换机和一个队列,所以后者使用起来更简单。
下图就是基于死信队列,来实现延迟队列的效果。
rabbitmq-plugins enable rabbitmq_delayed_message_exchange
开启插件后,启动RabbitMQ,访问登录后访问http://localhost:15672,用guest/guest登录后,在交换机exchanges的tab下,底部新增将看到如下图设置,则表示插件已启动,以后直接就可以使用了。
插件从github下载速度较慢,本人提交了插件到码云了,同时项目地址也在下方。
链接: rabbitmq_delayed_message_exchange-3.8.0.ez下载地址.
延迟插件底层简单原理图:
实现原理
原始的DLX + TTL 的模式,消息首先会路由到一个正常的队列,根据设置的 TTL 进入死信队列,与之不同的是通过 x-delayed-message 声明的交换机(具体代码请看下面config下的配置类交换机定义参数),它的消息在发布之后不会立即进入队列,先将消息保存至 Mnesia(一个分布式数据库管理系统,适合于电信和其它需要持续运行和具备软实时特性的 Erlang 应用。目前资料介绍的不是很多)。
这个插件将会尝试确认消息是否过期,首先要确保消息的延迟范围是 Delay > 0, Delay =< ?ERL_MAX_T(在 Erlang 中可以被设置的范围为 (2^32)-1 毫秒),如果消息过期通过 x-delayed-type 类型标记的交换机投递至目标队列,整个消息的投递过程也就完成了。
项目结构:
controller:不想解释。
rabbitmq.listener:消费者,如果是微服务,请提出去。这里为了方便,则放入了同一个项目中。
service:生产者,用于发送消息到mq。
po:实体类,简单业务测试
test:测试类,单独只写了一个批量延迟消息生产者,可以生产一定数量的消息。
本项目是在springboot下,代码如下,首先引入amqp:
org.springframework.boot
spring-boot-starter-amqp
配置文件如下:
spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
virtual-host: /
connection-timeout: 15000
#设置为true后 生产者在消息入队列后,但是没有被路由到合适队列情况下,会被生产者者这边的ReturnCallback监听,不会自动删除,为false,会被自动剔除
template:
mandatory: true
listener:
simple:
# 开启手动确认
acknowledge-mode: manual
#开启return 确认消息
publisher-returns: true
# ConfirmCallback开启发送到交换机Exchange触发回调
publisher-confirm-type: correlated
配置文件,下面有注释的地方,是额外确保消息可达防丢失,和成功失败触发相关的配置。
具体的业务逻辑如上图所示(目前测试过主题和直连,广播模式经过测试也可以使用):
配置config包下,用于配置交换机,队列和routingkey,对应上图中的名称:
package com.woniuxy.config;
import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;
/**
* @author: mayuhang
* Date: 2021/3/17:16:03
* Description:延迟队列配置类
*/
@Configuration
public class LazyExchangeConfig {
public static final String LAZY_EXCHANGE="Ex.LazyExchange";
public static final String LAZY_QUEUE="MQ.LazyQueue";
public static final String LAZY_KEY="lazy.#";
@Bean
public CustomExchange lazyExchange() {
//第一种设置方法 设置延迟交换机配置
Map<String, Object> pros = new HashMap<>();
//设置交换机支持延迟消息推送
pros.put("x-delayed-type", "topic");
CustomExchange exchange = new CustomExchange(LAZY_EXCHANGE, "x-delayed-message",false, true,pros);
// CustomExchange exchange = new CustomExchange(LAZY_EXCHANGE, "x-delayed-message",true, true);
// //第二种设置方法 这里有bug 如果直接就配置这个 就会报错 必须先用第一种 持久化交换机后就再使用下面这个配置才行
// exchange.setDelayed(true);
return exchange;
}
@Bean
public Queue lazyQueue(){
// HashMap pros = new HashMap<>();
// 设置队列长度
// pros.put("x-max-length",1);
//先不持久化
// return new Queue(LAZY_QUEUE,false,false,true,pros);
return new Queue(LAZY_QUEUE,false);
}
@Bean
public Binding lazyBinding(){
return BindingBuilder.bind(lazyQueue()).to(lazyExchange()).with(LAZY_KEY).noargs();
}
}
对应业务service接口中生产者(发送方的)方法:
package com.woniuxy.service.impl;
import com.rabbitmq.client.Return;
import com.rabbitmq.client.ReturnCallback;
import com.woniuxy.po.Mail;
import com.woniuxy.service.Publisher;
import org.springframework.amqp.AmqpException;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageDeliveryMode;
import org.springframework.amqp.core.MessagePostProcessor;
import org.springframework.amqp.core.ReturnedMessage;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* @author: mayuhang
* Date: 2021/3/19:11:03
* Description:这边实现了接口,对应接口代码忽略,消息发送端
*/
@Service("publisher")
public class PublisherImpl implements Publisher {
@Autowired
RabbitTemplate rabbitTemplate;
//消息发送到交换机时,触发回调!
final RabbitTemplate.ConfirmCallback confirmCallback = new RabbitTemplate.ConfirmCallback() {
/**
* Description : 回调信息实现
* ChangeLog : 1. 创建 (2021/3/16 14:43 [mayuhang]);
* @param correlationData 相关数据,感兴趣自己打印出来研究
* @param ack 相应返回如果交换机收到即刻返回为true
* @param cause 原因返回
* @return void
**/
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
if(ack){
System.out.println("发送成功,例如:可以对redis中的某数据进行删除业务从操作....");
}else {
System.out.println("发送失败,例如:异常处理业务......");
}
}
};
//从交换机发送到队列,如果队列收到则会执行回调 这里有个问题,无论队列是否接收到,都会进入这个方法
private RabbitTemplate.ReturnsCallback returnsCallback = new RabbitTemplate.ReturnsCallback() {
@Override
public void returnedMessage(ReturnedMessage returned) {
System.out.println(returned.getMessage()+"消息被退回");
System.out.println("返回码:"+returned.getReplyCode());
System.out.println("消息退回原因:"+returned.getReplyText());
System.out.println("路由:"+returned.getRoutingKey());
System.out.println("获得消费者全局唯一标签:"+returned.getMessage().getMessageProperties().getHeader("spring_returned_message_correlation"));
System.out.println("队列收到消息,执行业务处理代码......");
};
/**
* Description : 延迟队列发送消息到交换机,routingkey简单改为lazy.1000 1000为1s延迟
* ChangeLog : 1. 创建 (2021/3/17 17:30 [mayuhang]);
* @param mail
* @param routingkey
* @return void
**/
@Override
public void sendLazyTopicMail(Mail mail, String routingkey) {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
System.out.println("msg发送时间:"+simpleDateFormat.format(new Date()));
//id+时间戳 全局唯一
CorrelationData correlationData = new CorrelationData("123456"+simpleDateFormat.format(new Date()));
//设置响应回调,如果找不到交换机,则回调ack为false
rabbitTemplate.setConfirmCallback(confirmCallback);
//设置返回相应,如果找不到对应的队列,则回调消息至returnsCallback
rabbitTemplate.setReturnsCallback(returnsCallback);
//消息发送
rabbitTemplate.convertAndSend("Ex.LazyExchange", routingkey, mail, new MessagePostProcessor() {
@Override
public Message postProcessMessage(Message message) throws AmqpException {
System.out.println(routingkey);
//简单处理routingkey,截取.后面数据为延迟的时间,实际业务中,肯定不会使用这个,可在实体里定义,或者根据业务定义
String[] split = routingkey.split("\\.");
int i = Integer.parseInt(split[1]);
System.out.println("延迟时间:"+ i);
//设置消息持久化
message.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT);
//这个底层就是setHeader("x-delay",i);是一样的
message.getMessageProperties().setDelay(i);
return message;
}
},correlationData);
}
}
对应的消费者,比较简单,具体项目业务再进行优化:
package com.woniuxy.rabbitMQ.listener;
import com.rabbitmq.client.Channel;
import com.woniuxy.po.Mail;
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;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* @author: mayuhang
* Date: 2021/3/17:17:08
* Description:监听 打印接受到的延迟后的消息 从channel中获取queue数据
*/
@Component
public class LazyTopicListener {
@RabbitListener(queues = "MQ.LazyQueue")
public void displayMail(Mail mail,Channel channel, Message message) throws Exception {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-HH-dd hh:mm:ss");
System.out.println("消息接收的时间"+simpleDateFormat.format(new Date()));
System.out.println("mail:"+mail.toString());
//加入休眠,仅仅为了交换机排好序的数据,能够在队列中堆积,用于测试进入队列的数据能否排序,结果是不能,排序是在交换机之前
//有一个
// Thread.sleep(1000);
System.out.println("对应队列通道标签:"+message.getMessageProperties().getDeliveryTag());
//手动ack确认,表示消费者已经成功消费,另外肯定还有自动确认,只不过容易出现消息未消费则丢失的情况
channel.basicAck(message.getMessageProperties().getDeliveryTag(),true);
}
}
对应controller代码和前端代码,请去码云中下载吧:
链接: 对应码云下载地址.
代码中,需要注意一点,在前端测试的时候,需要注意一点,如下图:
后台打印消息如下:
实现了延迟10000ms消费。
同时其他的还能测验很多功能:比如插队!
package com.woniuxy;
import com.woniuxy.po.Mail;
import com.woniuxy.service.Publisher;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class RabbitmqApplicationTests {
@Autowired
Publisher publisher;
@Test
void contextLoads() {
for (int i = 0; i <100 ; i++) {
Mail mail = new Mail();
mail.setMailId(i+"");
publisher.sendLazyTopicMail(mail,"lazy."+i*1000);
}
}
}
在测试类中,生成100个任务(如果要高并发的话,可以通过countDownLatch同时发送100个任务):
打印结果如下
生成的数据为id递增,延迟时间为i*1000 ms,如果中途希望添加一个临时的任务,则在页面中如下:
发送一个1ms的临时任务,插队立刻执行:
这样既可往之前的任务中,新增一段任务,变相解决了一个时序问题(在同一个队列,必须前一个消费,第二个才能消费)。
本次研究的内容,还剩幂等性,防消息丢失等,更多业务功能,希望大家可以分享分享,感谢指导。
这里发现了一个问题:
RabbitTemplate.ReturnsCallback()这个接口,里面自己写的实现,无论交换机是否成功通过路由发送到队列,都会进入这个方法,而且就算成功了,打印的消息退回原因依旧是NO_ROUTE,有没有谁研究过这个?而且成功失败返回码都是312的!那么我怎么判断是消息入队列失败呢??不能判断要它何用……
下图是成功路由后返回的完整消息:
下图是失败路由返回的完整消息:
这里,出现上述情况的原因,分析后判断为delayed_message_exchange交换机的原因,因为是延迟的,所以并不会已进入就会去判断队列路由的routingkey是否存在,则由MQ框架底层,并未对延迟队列进行优化,所以存在无论是否有队列,都会进入ReturnsCallback中,显示NO_ROUTE。后续看了MQ源码,确认bug后再说,也许后续升级,处理过了,目前还未看最新版。
回答评论问题:
美短的小肚兜 2021.12.23
mandatory=true : 当消息无法根据routingKey 到达对应的queue中,那么我们的消息就会丢失。为true时,就将消息返回给生产者,为false时,直接丢弃该消息,不是消费者的ReturnCallBack,而是生产者的;