延时通知解决方案

前言

这段时间项目中遇到了客户提出的新的功能,需要当用户参加志愿者活动的时候,在改活动开始的前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 +
            '}';
    }
}

复制代码

运行结果:

1.png

基于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 + '\'' +
                '}';
        }  
    }
}

复制代码

运行结果:

2.png

基于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

4.png

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延时
    }
}

复制代码

运行结果:

3.png

总结

上面阐述了三种关于实现延时通知的方案,其实通过实现的过程各位也能看出各自的优劣点。如果在分布式的环境下,需要保证消息的可靠性建议使用基于Redis和RabbitMQ的两种方式,这两种方式提供了消息的持久化,Reids(RDB和AOF),RabbitMQ开启Exchange和Queue的持久化。如果需要实现方便,可以采用基于Redis的方案,我相信在分布式环境下Redis肯定是会出现在各位的系统中,然而RabbitMQ不一定会被使用到,为了保证整体系统的高可用不去引入其他不必要的组件,采用基于Redis实现延迟通知,我认为是最好的一种方式。

分类:
后端
标签: