一.前言
在电商、支付等领域,往往会有这样的场景,用户下单后放弃支付了,那这笔订单会在指定的时间段后进行关闭操作,细心的你一定发现了像某宝、某东都有这样的逻辑,而且时间很准确,误差在1s内;那他们是怎么实现的呢?
一般实现的方法有几种:
-
使用 rocketmq、rabbitmq、pulsar 等消息队列的延时投递功能
-
使用 redisson 提供的 DelayedQueue
有一些方案虽然广为流传但存在着致命缺陷,不要用来实现延时任务
-
使用 redis 的过期监听
-
使用 rabbitmq 的死信队列
-
使用非持久化的时间轮
二.缺陷分析
2.1 使用 redis 的过期监听
2.1.1 实现
通过开启key过期的事件通知,当key过期时,会发布过期事件;我们定义key过期事件的监听器,当key过期时,就能收到回调通知。
注意:
1)由于Redis key过期删除是定时(设置定时器)+惰性(查询删除),当key过多时,删除会有延迟,回调通知同样会有延迟,因此性能较低。
2)且通知是一次性的,没有ack机制,若收到通知后处理失败,将不再收到通知。需自行保证收到通知后处理成功。
3)通知只能拿到key,拿不到value。
4)Redis将数据存储在内存中,如果遇到恶意下单或者刷单的将会给内存带来巨大压力。
使用场景:实现延时队列,消息作为key,将需要延迟的时间设置为key的TTL,当key过期时,在监听器收到通知,达到延迟的效果。
步骤:
1.修改Redis配置
首先将redis的配置文件中的 notify-keyspace-events改成 notify-keyspace-events Ex
这里的 EX代表 expire 和 evicted 过期和驱逐 的时间监听 ,注意:改了配置要重启Redis
2.开发配置
pom.xml
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- 使用jedis -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
application.yml
spring:
redis:
host: 127.0.0.1
jedis:
pool:
max-active: 8 #最大连接数据库连接数,设 -1 为没有限制
max-idle: 8 #最大等待连接中的数量,设 0 为没有限制
max-wait: -1ms #最大建立连接等待时间。如果超过此时间将接到异常。设为-1表示无限制。
min-idle: 0 #最小等待连接中的数量,设 0 为没有限制
shutdown-timeout: 100ms
password: ''
port: 6379
3.配置类 RedisConfig.java
RedisConfig 中配置 Message Listener Containers (消息订阅者容器)
类似于Redis pub/sub 中 Message Listener Containers 的配置,区别少了监听器的指定。
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory redisConnectionFactory) {
// redis 消息订阅(监听)者容器
RedisMessageListenerContainer messageListenerContainer = new RedisMessageListenerContainer();
messageListenerContainer.setConnectionFactory(redisConnectionFactory);
messageListenerContainer.addMessageListener(new ProductUpdateListener(), new PatternTopic("*.product.update"));
return messageListenerContainer;
}
4.定义 key过期监听器,继承 KeyExpirationEventMessageListener
定义的继承了KeyExpirationEventMessageListener的redis key 过期监听器本质上就是一个消息监听器,监听来自channel为__keyevent@*__:expired 上发布的消息
@Component
public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {
/**
* 创建RedisKeyExpirationListener bean时注入 redisMessageListenerContainer
*
* @param redisMessageListenerContainer RedisConfig中配置的消息监听者容器bean
*/
public RedisKeyExpirationListener(RedisMessageListenerContainer redisMessageListenerContainer) {
super(redisMessageListenerContainer);
}
@Override
public void onMessage(Message message, byte[] pattern) {
String channel = new String(message.getChannel()); // __keyevent@*__:expired
String pa = new String(pattern); // __keyevent@*__:expired
String expiredKey = message.toString();
System.out.println("监听到过期key:" + expiredKey);
}
}
可以看到,其本质是Redis的发布订阅,当key过期,发布过期消息(key)到Channel :__keyevent@*__:expired中,再看KeyExpirationEventMessageListener 源码:
public class KeyExpirationEventMessageListener extends KeyspaceEventMessageListener implements ApplicationEventPublisherAware {
// 发布key过期频道的topic
private static final Topic KEYEVENT_EXPIRED_TOPIC = new PatternTopic("__keyevent@*__:expired");
private @Nullable
ApplicationEventPublisher publisher;
/**
* 子类在由Spring容器创建bean的时候调用父类的此构造器,并传入容器中的RedisMessageListenerContainer bean作为参数
*/
public KeyExpirationEventMessageListener(RedisMessageListenerContainer listenerContainer) {
super(listenerContainer);
}
/**
* doRegister 在 KeyspaceEventMessageListener(实现了InitializingBean和 MessageListener) 中的init方中被调用
* 将我们自定义的key过期监听器添加到 消息监听容器中
*/
@Override
protected void doRegister(RedisMessageListenerContainer listenerContainer) {
listenerContainer.addMessageListener(this, KEYEVENT_EXPIRED_TOPIC);
}
/**
* 发布key过期事件
*
* @param message 过期key
*/
@Override
protected void doHandleMessage(Message message) {
publishEvent(new RedisKeyExpiredEvent(message.getBody()));
}
/**
* 由Spring RedisKeyExpiredEvent事件监听器执行实际上的redis key过期消息的发布
*/
protected void publishEvent(RedisKeyExpiredEvent event) {
if (publisher != null) {
this.publisher.publishEvent(event);
}
}
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
this.publisher = applicationEventPublisher;
}
}
redis过期监听_Cool_Pepsi的博客-优快云博客_redis 过期监听
2.1.2 缺点
2.2 使用 rabbitmq 的死信队列
死信(Dead Letter) 是 rabbitmq 提供的一种机制。当一条消息满足下列条件之一那么它会成为死信:
-
消息被否定确认(如channel.basicNack) 并且此时requeue 属性被设置为false(消费端执行nack或者reject时,设置requeue=false)
-
消息在队列的存活时间超过设置的TTL时间
-
消息队列的消息数量已经超过最大队列长度
若配置了死信队列,死信会被 rabbitmq 投到死信队列中,如果没有配置死信队列,这些死信消息会被丢弃。
理解死信队列
死信队列并不仅仅只是一个queue,还包含死信交换机(Dead Letter Exchange),关于死信队列和死信交换机要说明几点:
死信交换机可以是fanout、direct、topic等类型,和普通交换机并无不同;
死信交换机要绑定要业务队列上才会生效;
给死信交换机绑定的队列称之为死信队列,其实就是普通的队列,没有任何特殊之处;
并不是整个项目只能设置一个死信交换机和死信队列,可以根据业务需要设置多个或者单个死信交换机使用不同的routing-key;
2.2.1 实现
在 rabbitmq 中创建死信队列的操作流程大概是:
创建一个交换机作为死信交换机
在业务队列中配置 x-dead-letter-exchange 和 x-dead-letter-routing-key,将第一步的交换机设为业务队列的死信交换机
在死信交换机上创建队列,并监听此队列
pom.xml添加依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.amqp</groupId>
<artifactId>spring-rabbit-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
application.properties配置RabbitMQ连接信息
spring:
rabbitmq:
addresses: 127.0.0.1:5672
username: lzm
password: lzm
virtual-host: test
listener:
simple:
acknowledge-mode: manual # 手动ack
default-requeue-rejected: false # 设置为false,requeue或reject
创建交换机和队列以及绑定
/**
* 死信交换机
*/
@Bean
public DirectExchange dlxExchange(){
return new DirectExchange(dlxExchangeName);
}
/**
* 死信队列
*/
@Bean
public Queue dlxQueue(){
return new Queue(dlxQueueName);
}
/**
* 死信队列绑定死信交换机
*/
@Bean
public Binding dlcBinding(Queue dlxQueue, DirectExchange dlxExchange){
return BindingBuilder.bind(dlxQueue).to(dlxExchange).with(dlxRoutingKey);
}
/**
* 业务队列
* 注意创建业务队列的部分,设置业务队列的超时时间是10s,队列中消息最大数量为10。
* 业务交换机为fanout类型的交换机,死信交换机为Direct类型的交换机。
*/
@Bean
public Queue queue(){
Map<String,Object> params = new HashMap<>();
params.put("x-dead-letter-exchange",dlxExchangeName);//声明当前队列绑定的死信交换机
params.put("x-dead-letter-routing-key",dlxRoutingKey);//声明当前队列的死信路由键
params.put("x-message-ttl",10000);//设置队列消息的超时时间,单位毫秒,超过时间进入死信队列
params.put("x-max-length", 10);//生命队列的最大长度,超过长度的消息进入死信队列
return QueueBuilder.durable(queueName).withArguments(params).build();
}
/**
* 业务交换机
*/
@Bean
public FanoutExchange fanoutExchange(){
return new FanoutExchange(exchangeName,true,false);
}
/**
* 业务队列和业务交换机的绑定
*/
@Bean
public Binding binding(Queue queue, FanoutExchange fanoutExchange){
return BindingBuilder.bind(queue).to(fanoutExchange);
}
生产者
/**
注意:
队列中消息的超时时间可以是在创建队列时设置,表示对队列中所有的消息生效,
也可以在发送消息时设置,两者相比取最小值作为TTL的值。
*/
public void send(){
for (int i = 0; i < 5; i++) {
CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());
rabbitTemplate.convertAndSend(exchangeName,"","消息==>"+i,message -> {
message.getMessageProperties().setExpiration(3000+"");//发送消息时设置消息的超时时间
return message;
},correlationData);
}
}
//启动消费者,并且模拟在某些情况下执行nack操作,先看消费者代码
@RabbitHandler
//platform.queue-name为dlxQueueName即dlx-queue,concurrency =1,即每个Listener容器将开启一个线程去处理消息
@RabbitListener(queues = {"${platform.queue-name}"},concurrency = "1")
public void msgConsumer(String msg, Channel channel, Message message) throws IOException {
try {
if(msg.indexOf("5")>-1){
throw new RuntimeException("抛出异常");
}
log.info("消息{}消费成功",msg);
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
} catch (Exception e) {
log.error("接收消息过程中出现异常,执行nack");
//第三个参数为true表示异常消息重新返回队列,会导致一直在刷新消息,且返回的消息处于队列头部,影响后续消息的处理
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, false);
log.error("消息{}异常",message.getMessageProperties().getHeaders());
}
}
2.2.2 缺点
当往死信队列中发送两条不同过期时间的消息时,如果先发送的消息A的过期时间大于后发送的消息B的过期时间时,由于消息的顺序消费,消息B过期后并不会立即重新publish到死信交换机,而是会等到消息A过期后一起被消费。
依次发送两个请求http://localhost:4399/send?msg=消息A&time=30和http://localhost:4399/send?msg=消息B&time=10,消息A先发送,过期时间30S,消息B后发送,过期时间10S,我们想要的结果应该是10S收到消息B,30S后收到消息A,但结果并不是,控制台输出如下:
2020-12-16 22:54:47.339 INFO 13304 --- [nio-4399-exec-5] c.l.rabbitmqdlk.rabbitmq.MessageSender : 使用死信队列消息:消息A发送成功,过期时间:30秒。
2020-12-16 22:54:54.278 INFO 13304 --- [nio-4399-exec-6] c.l.rabbitmqdlk.rabbitmq.MessageSender : 使用死信队列消息:消息B发送成功,过期时间:10秒。
2020-12-16 22:55:17.356 INFO 13304 --- [ntContainer#0-1] c.l.r.rabbitmq.MessageReceiver : 使用死信队列,收到消息:消息A
2020-12-16 22:55:17.357 INFO 13304 --- [ntContainer#0-1] c.l.r.rabbitmq.MessageReceiver : 使用死信队列,收到消息:消息B
消息A30S后被成功消费,紧接着消息B被消费。因此当我们使用死信队列时应该注意是否消息的过期时间都是一样的,比如订单超过10分钟未支付修改其状态。如果当一个队列各个消息的过期时间不一致时,使用死信队列就可能达不到延时的作用。这时候我们可以使用延时插件来实现这需求。
死信队列的设计目的是为了存储没有被正常消费的消息,便于排查和重新投递。死信队列同样也没有对投递时间做出保证,在第一条消息成为死信之前,后面的消息即使过期也不会投递为死信。
为了解决这个问题,rabbit 官方推出了延迟投递插件 rabbitmq-delayed-message-exchange ,推荐使用官方插件来做延时消息。使用 redis 过期监听或者 rabbitmq 死信队列做延时任务都是以设计者预想之外的方式使用中间件,这种出其不意必自毙的行为通常会存在某些隐患,比如缺乏一致性和可靠性保证,吞吐量较低、资源泄漏等。比较出名的一个事例是很多人使用 redis 的 list 作为消息队列,以致于最后作者看不下去写了 disque 并最后演变为 redis stream。工作中还是尽量不要滥用中间件,用专业的组件做专业的事
2.3 使用非持久化的时间轮
时间轮是一种很优秀的定时任务的数据结构,然而绝大多数时间轮实现是纯内存没有持久化的。运行时间轮的进程崩溃之后其中所有的任务都会灰飞烟灭,所以奉劝各位勇士谨慎使用。
三.实现方法
3.1 使用消息队列的延时投递功能
推荐使用 rocketmq、pulsar 等拥有定时投递功能的消息队列
详解RabbitMQ实现延时消息的2种方法:死信队列+延时插件_java 分享官的博客-优快云博客_rabbitmq 延迟消息插件
延时插件实现步骤:
1.安装好插件后只需要声明一个类型type为"x-delayed-message"的exchange,并且在其可选参数下配置一个key为"x-delayed-typ",值为交换机类型(topic/direct/fanout)的属性。
//延时插件DelayedMessagePlugin的交换机,队列,路由相关配置
public static final String DMP_EXCHANGE = "dmp.exchange";
public static final String DMP_ROUTEKEY = "dmp.routeKey";
public static final String DMP_QUEUE = "dmp.queue";
//延迟插件使用
//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(DMP_EXCHANGE,"x-delayed-message",true,false,arguments);
}
@Bean
public Queue dmpQueue(){
return new Queue(DMP_QUEUE,true,false,false);
}
@Bean
public Binding dmpBind(){
return BindingBuilder.bind(dmpQueue()).to(dmpExchange()).with(DMP_ROUTEKEY).noargs();
}
2.声明一个队列绑定到该交换机
3.在发送消息的时候消息的header里添加一个key为"x-delay",值为过期时间的属性,单位毫秒。
//使用延迟插件发送消息方法封装
public void send2(String message,Integer time){
rabbitTemplate.convertAndSend(RabbitmqConfig.DMP_EXCHANGE, RabbitmqConfig.DMP_ROUTEKEY,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("使用延迟插件发送消息:{}发送成功,过期时间:{}秒。",message,time);
4.代码就在上面,配置类为DMP开头的,发送消息的方法为send2()。
@RabbitHandler
@RabbitListener(queues = RabbitmqConfig.DMP_QUEUE)
public void onMessage2(Message message){
log.info("使用延迟插件,收到消息:{}",new String(message.getBody()));
}
5.启动后在rabbitmq控制台可以看到一个类型为x-delayed-message的交换机。
启动项目,打开rabbitmq控制台,可以看到交换机和队列已经创建好。
在浏览器中请求http://localhost:4399/send?msg=hello&time=5,从控制台的输出来看,刚好5s后接收到消息。
2020-12-16 22:47:28.071 INFO 13304 --- [nio-4399-exec-1] c.l.rabbitmqdlk.rabbitmq.MessageSender : 使用死信队列消息:hello发送成功,过期时间:5秒。
2020-12-16 22:47:33.145 INFO 13304 --- [ntContainer#0-1] c.l.r.rabbitmq.MessageReceiver : 使用死信队列,收到消息:hello
3.2 使用 redisson 提供的 DelayedQueue
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.8.0</version>
</dependency>
生成订单并放进延时队列的类
package com.jeiao.redis;
import org.redisson.Redisson;
import org.redisson.api.RBlockingQueue;
import org.redisson.api.RDelayedQueue;
import org.redisson.api.RQueue;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import java.util.concurrent.TimeUnit;
/**
* Author:ZhuShangJin
* Date:2018/8/31
*/
public class RedisPutInQueue {
public static void main(String args[]) {
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.19.173:6379").setPassword("ps666").setDatabase(2);
RedissonClient redissonClient = Redisson.create(config);
RBlockingQueue<CallCdr> blockingFairQueue = redissonClient.getBlockingQueue("delay_queue");
RDelayedQueue<CallCdr> delayedQueue = redissonClient.getDelayedQueue(blockingFairQueue);
for (int i = 0; i <10 ; i++) {
try {
Thread.sleep(1*1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 一分钟以后将消息发送到指定队列
//相当于1分钟后取消订单
// 延迟队列包含callCdr 1分钟,然后将其传输到blockingFairQueue中
//在1分钟后就可以在blockingFairQueue 中获取callCdr了
CallCdr callCdr = new CallCdr(30000.00);
callCdr.setPutTime();
delayedQueue.offer(callCdr, 1, TimeUnit.MINUTES);
}
// 在该对象不再需要的情况下,应该主动销毁。
// 仅在相关的Redisson对象也需要关闭的时候可以不用主动销毁。
delayedQueue.destroy();
//redissonClient.shutdown();
}
}
取消订单的操作类
package com.jeiao.redis;
import org.redisson.Redisson;
import org.redisson.api.RBlockingQueue;
import org.redisson.api.RDelayedQueue;
import org.redisson.api.RQueue;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.TimeUnit;
import java.util.logging.SimpleFormatter;
/**
* Author:ZhuShangJin
* Date:2018/8/31
*/
public class RedisOutFromQueue {
public static void main(String args[]) {
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.19.173:6379").setPassword("ps666").setDatabase(2);
RedissonClient redissonClient = Redisson.create(config);
RBlockingQueue<CallCdr> blockingFairQueue = redissonClient.getBlockingQueue("delay_queue");
RDelayedQueue<CallCdr> delayedQueue = redissonClient.getDelayedQueue(blockingFairQueue);
while (true){
CallCdr callCdr = null;
try {
callCdr = blockingFairQueue.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("订单取消时间:"+new SimpleDateFormat("hh:mm:ss").format(new Date())+"==订单生成时间"+callCdr.getPutTime());
}
}
}
聊聊redisson的DelayedQueue_Redis - UCloud云社区
Redisson延迟队列RDelayedQueue的使用 - 灰信网(软件开发博客聚合)