前言
这段时间项目中遇到了客户提出的新的功能,需要当用户参加志愿者活动的时候,在改活动开始的前5分钟和后10分钟推送一条微信消息到用户的微信上。其实这样的延时操作的场景还有很多,比如常见的电商系统里面的30分钟订单为支付就关闭这种功能。
然后我进行技术方案的调研,发现目前能满足该业务场景方案主要有以下几种:
- 轮询数据库表 ,构建消息之后存放到数据库中,然后开启一个每分钟执行定时任务扫描数据库表中,活动开始时间在当前时间的后5分钟和活动的结束时间在当前时间的后10分钟。这种方式实现简单,但是对数据库的压力较大(最次)
- 基于JDK中的DelayQueue,Java中自带了一个延时队列的功能,通过实现
Delayd
接口可以实现自定义的延时逻辑,非常简单。但是消息数据没有持久化,当发生了故障宕机了之后,这些消息就不存在了。不过可以考虑将消息持久化到数据库中,同时使用延时队列,当消费成功之后删除数据库中的消息。如果应用宕机了,在重启的时候将满足时间要求的消息重新投放到延时队列中。(较次) - 基于Redis的Key过期通知,用户订阅消息之后,将消息存放到Redis中然后设置Key的过期时间,服务端开启一个线程监听Redis Key过期事件的回调。这种方式实现简单,利用Redis本身提供过的特性,但是Key过期的回调中并不能获取到该Key对应的Value,所以还需要在Redis中冗余一份Key+“特殊字符”->Value映射的一个键值对,这样对导致不必要的键值对存在。(较次)
- 基于RabbitMQ的延时队列,RabbitMQ通过一个普通队列和一个死信队列可以实现一个延时队列的功能,首先将消息设置一个过期时间投放到普通队列中,这个普通队列没有消费者,那么当消息过期后该消息将会被转移到死信队列中,开启一个消费者消费死信队列中的数据即可;默认RabbitMQ是不支持延时队列的,不过提供了延时队列的插件可以集成到RabbitMQ,集成的过程非常简单,百度一下,你就知道哦。(好)
下面我将会把上面的四种方式都实现一遍(除了第一种),然后各位体会一下不同实现的方式的优越点
各种实现
基于JDK的DelayQueue
public class DelayedMessage implements Delayed {
//微信的用户Id
private final String openId;
//活动名称
private final String activityName;
private final Long expireTime;
public DelayedMessage(String openId, String activityName, long expireTime) {
this.openId = openId;
this.activityName = activityName;
this.expireTime = expireTime;
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(expireTime, TimeUnit.NANOSECONDS) - unit.convert(System.currentTimeMillis(), TimeUnit.NANOSECONDS);
}
@Override
public int compareTo(Delayed o) {
DelayedMessage message = (DelayedMessage) o;
return this.expireTime.compareTo(message.getExpireTime());
}
public String getOpenId() {
return openId;
}
public String getActivityName() {
return activityName;
}
public Long getExpireTime() {
return expireTime;
}
public static void main(String[] args) throws InterruptedException {
DelayQueue<DelayedMessage> delayQueue = new DelayQueue<>();
//投放消息线程
new Thread(() -> {
DelayedMessage message1 = new DelayedMessage("微信用户1", "打扫门前雪活动",
System.currentTimeMillis() + 1000 * 10); //10S后过期
delayQueue.put(message1);
System.out.println("投放消息:" + message1 + " 投放时间:" + LocalDateTime.now());
}).start();
//消费消息线程
new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
DelayedMessage delayedMessage = null;
try {
delayedMessage = delayQueue.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("获取到消息:" + delayedMessage + " 获取时间:" + LocalDateTime.now());
}
}).start();
Thread.currentThread().join();
}
@Override
public String toString() {
return "DelayedMessage{" +
"openId='" + openId + '\'' +
", activityName='" + activityName + '\'' +
", expireTime=" + expireTime +
'}';
}
}
复制代码
运行结果:
基于Redis的Key过期事件通知
首先如果要使用Rdis的Key过期事件通知,需要修改一下Redis的Config配置文件。这里贴上官网对应文档的地址:redis.io/topics/noti…
找到你的Redis对应的redis.conf
文件,打开下面的配置即可:
notify-keyspace-events Ex # x代表过期事件
复制代码
下面采用SpringBoot的集成Redis的方式,实现一下该功能
pom.xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.9</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
复制代码
RedisConfig.java
@Configuration
public class RedisConfig{
@Bean
public RedisMessageListenerContainer listenerContainer(RedisConnectionFactory factory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
return container;
}
}
复制代码
RedisKeyExpireListener.java
package com.pkit.config;
import cn.hutool.core.lang.Console;
import cn.hutool.json.JSONUtil;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.listener.KeyExpirationEventMessageListener;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
/**
* @author zhuxy zhuxy@pukkasoft.cn
* @date 2021-08-25 22:05
*/
@Component
public class RedisKeyExpireListener extends KeyExpirationEventMessageListener {
private final StringRedisTemplate redisTemplate;
public RedisKeyExpireListener(RedisMessageListenerContainer listenerContainer, StringRedisTemplate redisTemplate) {
super(listenerContainer);
this.redisTemplate = redisTemplate;
}
@PostConstruct
public void startProducerThread(){
//启动一个生产者线程
Thread producerThread = new Thread(() -> {
MqMessage message = new MqMessage("123456", "打扫门前雪活动");
String key = "ExpireData"+message.getOpenId();
//这个键值对的value可以随便写,不一定要是message.getOpenId()
redisTemplate.setEnableTransactionSupport(true);
redisTemplate.multi();
redisTemplate.opsForValue().set(key,message.getOpenId(),10, TimeUnit.SECONDS); //设置过期时间为10S后
Console.log("投放消息的时间:{},消息是:{}",LocalDateTime.now(),message);
redisTemplate.opsForValue().set("Cache_"+key, JSONUtil.toJsonStr(message));
redisTemplate.exec();
});
producerThread.start();
}
@Override
protected void doHandleMessage(Message message) {
String key = message.toString();
//表明是这个业务的Key过期了
if (key.contains("ExpireData")){
String messageData = redisTemplate.opsForValue().get("Cache_" + key);
Console.log("消费数据的时间:{},消息是:{}",LocalDateTime.now(),messageData);
}
}
static class MqMessage{
//微信的用户Id
private String openId;
//活动名称
private String activityName;
public MqMessage(String openId, String activityName) {
this.openId = openId;
this.activityName = activityName;
}
public String getOpenId() {
return openId;
}
public void setOpenId(String openId) {
this.openId = openId;
}
public String getActivityName() {
return activityName;
}
public void setActivityName(String activityName) {
this.activityName = activityName;
}
@Override
public String toString() {
return "MqMessage{" +
"openId='" + openId + '\'' +
", activityName='" + activityName + '\'' +
'}';
}
}
}
复制代码
运行结果:
基于RabbitMQ的延时队列
下面是我们的最重点的一种实现方式,项目依旧是上面的那个Spring Boot项目,我们把RabbitMQ集成进来。
首先,我们需要给RabbitMQ安装延时队列插件,这里我就不讲解如何安装插件了,安装的过程非常简单,百度一下你就知道 www.cnblogs.com/isunsine/p/…
pom.xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.6.RELEASE</version>
<relativePath/>
</parent>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
<version>2.0.3.RELEASE</version>
</dependency>
复制代码
**注意上面 spring-cloud-starter-stream-rabbit
和Spring Boot对应的版本! **
注意上面 spring-cloud-starter-stream-rabbit
和Spring Boot对应的版本!
注意上面 spring-cloud-starter-stream-rabbit
和Spring Boot对应的版本 !
application.yml
spring:
redis:
database: 1
password: PUKKA028
host: 192.168.102.69
port: 6379
rabbitmq:
addresses: 192.168.102.69
listener:
direct:
acknowledge-mode: manual
type: direct
port: 5672
username: pukka
password: PUKKA028
virtual-host: /industry
cloud:
stream:
bindings:
WxMessageOutChannel:
destination: wxMessageExchange #绑定生产者的Channel
WxMessageInputChannel:
destination: wxMessageExchange #绑定消费者的Channel
group: wxMessageGroup
rabbit:
bindings:
WxMessageOutChannel:
producer:
delayed-exchange: true # 指明创建一个延时交换机
复制代码
当项目成功启动的实现,各位可以看到下图所示的Exchange
MessageInputChannel.java
消费者通道接口
package com.pkit.config;
import org.springframework.cloud.stream.annotation.Input;
import org.springframework.messaging.MessageChannel;
/**
* @author zhuxy zhuxy@pukkasoft.cn
* @date 2021-08-27 23:50:27
*/
public interface MessageInputChannel {
String WX_MESSAGE_INPUT_CHANNEL = "WxMessageInputChannel";
@Input(MessageInputChannel.WX_MESSAGE_INPUT_CHANNEL)
MessageChannel wxMessageInputChannel();
}
复制代码
MessageInputChannelHandler.java
消费者处理
package com.pkit.config;
import cn.hutool.core.lang.Console;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.annotation.StreamListener;
import org.springframework.messaging.Message;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* @author zhuxy zhuxy@pukkasoft.cn
* @date 2021-08-27 23:52:06
*/
@Component
@EnableBinding(MessageInputChannel.class)
public class MessageInputChannelHandler {
@StreamListener(MessageInputChannel.WX_MESSAGE_INPUT_CHANNEL)
public void sendWxMessageToUser(Message<MqMessage> message){
MqMessage mqMessage = message.getPayload();
Console.log("消费消息的时间:{},消息是:{}", LocalDateTime.now(),mqMessage);
}
}
复制代码
MessageOutPutChannel.java
生产在通道接口
package com.pkit.config;
import org.springframework.cloud.stream.annotation.Output;
import org.springframework.messaging.MessageChannel;
/**
* @author zhuxy zhuxy@pukkasoft.cn
* @date 2021-08-27 23:43:17
*/
public interface MessageOutPutChannel {
String WX_MESSAGE_OUT_CHANNEL = "WxMessageOutChannel";
@Output(MessageOutPutChannel.WX_MESSAGE_OUT_CHANNEL)
MessageChannel wxMessageOutChannel();
}
复制代码
MessageOutPutChannelHandler.java
生产者投放消息逻辑
package com.pkit.config;
import cn.hutool.core.lang.Console;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* @author zhuxy zhuxy@pukkasoft.cn
* @date 2021-08-27 23:45:30
*/
@Component
@EnableBinding(MessageOutPutChannel.class)
public class MessageOutPutChannelHandler {
private final MessageChannel wxMessageOutChannel;
public MessageOutPutChannelHandler(@Qualifier(MessageOutPutChannel.WX_MESSAGE_OUT_CHANNEL) MessageChannel wxMessageOutChannel) {
this.wxMessageOutChannel = wxMessageOutChannel;
}
public void sendWxMessage(MqMessage message,long delayTime){
Message<MqMessage> messageMessage = MessageBuilder.withPayload(message)
.setHeader("x-delay", delayTime).build(); //发送消息到队列中 设置超时时间
Console.log("投递消息的时间:{},消息是:{}", LocalDateTime.now(),message);
wxMessageOutChannel.send(messageMessage);
}
}
复制代码
Test.java
package com.pkit;
import com.pkit.config.MessageOutPutChannelHandler;
import com.pkit.config.MqMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author zhuxy zhuxy@pukkasoft.cn
* @date 2021-08-25 16:42
*/
@RestController
public class Test {
@Autowired
private MessageOutPutChannelHandler outPutChannelHandler;
@GetMapping("/test")
public void test(){
MqMessage mqMessage = new MqMessage("RabbitMq-123456","打扫门前雪");
outPutChannelHandler.sendWxMessage(mqMessage,1000*10); //10S延时
}
}
复制代码
运行结果:
总结
上面阐述了三种关于实现延时通知的方案,其实通过实现的过程各位也能看出各自的优劣点。如果在分布式的环境下,需要保证消息的可靠性建议使用基于Redis和RabbitMQ的两种方式,这两种方式提供了消息的持久化,Reids(RDB和AOF),RabbitMQ开启Exchange和Queue的持久化。如果需要实现方便,可以采用基于Redis的方案,我相信在分布式环境下Redis肯定是会出现在各位的系统中,然而RabbitMQ不一定会被使用到,为了保证整体系统的高可用不去引入其他不必要的组件,采用基于Redis实现延迟通知,我认为是最好的一种方式。