基于redis实现的延迟队列

835 阅读7分钟

整体介绍

工作中是否碰到过需要延迟一定时间后再执行的任务?通常的做法是将延迟任务持久化至数据库,然后通过定时任务轮询扫描,处理符合条件的任务。通常来说,此类场景主要考虑2个要素:

  1. 延迟执行的时延,和轮询的时间间隔有关
  2. 任务的处理性能,和整体的处理架构有关 这里提供另外一种轻量级的实现思路:基于 redis 有序集合实现延迟队列,提供任务添加、查询、删除、处理能力。并封装成spring-boot starter组件,供应用方低成本接入。 使用redis从根本上避免了重复处理任务的问题,不必考虑分布式锁等其他互斥方案,降低方案复杂度。

软件架构

整体架构介绍如下

  1. 基于 redis 有序集合实现延迟队列,提供添加、拉取、删除任务基础功能,见 DelayQueue 接口
  2. 延迟队列消费采用异步线程模式,定时拉取 redis 有序集合,整体调度方式见 DelayQueuePollScheduler
  3. 延时队列中的任务处理方式采用 spring 自带的事件模式,事件处理可配置异步、同步模式
  4. 使用方通过实现 DelayQueuePollEventHandler 接口,定制事件处理具体逻辑,即策略模式思想

模块架构图如下

IMG-20240303110909070.png

重点设计

DelayQueue

客户端 SDK,提供延迟队列的基本操作,接口DelayQueue定义如下,提供基于redis有序集合的实现 ZSetDelayQueue

public interface DelayQueue<T> {

    /**

     * 往延迟队列中添加任务, 如果队列容量满,则直接返回 false

     *

     * @param task      任务

     * @param topic     主题,区分不同的延迟队列

     * @param delayTime 延迟时间,即相对值

     * @param timeUnit  延迟时间单位

     * @return true 表示添加成功,false 表示添加失败

     */

    boolean add(T task, String topic, int delayTime, TimeUnit timeUnit);

    /**

     * 往延迟队列中添加任务, 如果队列容量满,则直接返回 false

     *

     * @param task        任务

     * @param topic       主题,区分不同的延迟队列

     * @param executeTime 任务执行时间,即绝对值

     * @return true 表示添加成功,false 表示添加失败

     */

    boolean add(T task, String topic, Date executeTime);

    /**

     * 获取到期任务列表

     *

     * @param topic      主题,区分不同的延迟队列

     * @param expireTime 到期时间

     * @return 任务列表

     */

    List<T> poll(String topic, Long expireTime);

}

应用方若要使用默认实现ZSetDelayQueue,直接注入,具体代码如下

@Autowired(required = false)

@Qualifier("zSetDelayQueue")

private DelayQueue<String> delayQueue;

DelayQueuePollEventHandler

延迟队列组件提供DelayQueuePollEventHandler接口供应用方具体实现处理逻辑。接口定义如下

public interface DelayQueuePollEventHandler {

    /**

     * 事件处理

     *

     * @param pollEvent 轮询事件

     */

    void handle(DelayQueuePollEvent pollEvent);

    /**

     * 支持处理的延迟队列主题集合

     *

     * @return 主题集合

     */

    Set<String> getSupportedTopics();

其中:

  • handle方法即为任务具体处理逻辑
  • getSupportedTopics方法返回此handler支持处理的topic集合 组件使用策略模式,自动构建 topic-handler 的映射关系,根绝具体topic选取符合要求的handler进行处理。核心逻辑如下:

/**
 * 延迟队列处理器
 */
 
@Bean
public Map<String, DelayQueuePollEventHandler> delayQueueTopic2Handler(@Autowired List<DelayQueuePollEventHandler> eventHandlerList) {

    // 获取所有业务应用定义的handler bean

    Map<String, DelayQueuePollEventHandler> delayQueueTopic2Handler = Maps.newHashMap();

    for (DelayQueuePollEventHandler handler : eventHandlerList) {

        // 遍历所有handler,收集topic集合

        handler.getSupportedTopics().forEach(t -> delayQueueTopic2Handler.put(t, handler));

    }

    // 返回 topic-handler 映射

    return delayQueueTopic2Handler;

}

DelayQueuePollScheduler

延迟队列任务的定时拉取采用 ScheduledThreadPoolExecutor 实现,corePoolSize固定等于1,线程池的数量由配置 DelayQueuePollSchedulerConfig.size 决定。默认情况下,size=1,即一个线程池串行循环拉取所有topic的延迟队列任务(其中topic集合通过调用所有DelayQueuePollEventHandler实现类的getSupportedTopics实现)。由上面的模块图,定时拉取到任务后,可以通过同步或者异步的方式发送事件,由具体的handler(2中的策略模式实现topic维度路由)实现处理。如果这里使用异步事件模式,那么拉取过程耗时非常短,建议一个线程池循环处理所有topic即可。

初始化核心代码如下:

@PostConstruct

public void init() {

    if (CollectionUtils.isEmpty(delayQueueTopics)) {

        return;

    }

    Assert.notNull(schedulerConfig, "DelayQueuePollSchedulerConfig cannot be null!");

    int schedulerSize = schedulerConfig.getSize();

    Assert.isTrue(schedulerSize > 0, "scheduler size must > 0");

    List<List<String>> topicGroupList = ListUtils.partitionByFixedGroup(delayQueueTopics, schedulerSize);

    createExecutors();

    int idx = 0;

    for (List<String> topicGroup : topicGroupList) {

        ScheduledExecutorService executorService = scheduledExecutorServices.get(idx);

        log.info("ScheduledExecutorService idx: {}, topic list: {}", idx, JsonUtils.toJson(topicGroup));

        Runnable command = () -> topicGroup.forEach(t -> {

            BatchDelayQueueTask task =

                    BatchDelayQueueTask.builder().topic(t).expireTime(System.currentTimeMillis()).build();

            try {

                delayQueueConsumer.accept(task);

            } catch (Exception e) {

                delayQueueConsumerExceptionHandler.handle(e, task);

            }

        });

        executorService.scheduleAtFixedRate(command, schedulerConfig.getInitialDelayInMillis(),

                schedulerConfig.getPeriodInMillis(), TimeUnit.MILLISECONDS);

        idx++;

    }

}

/**

 * 创建定时调度 ScheduledExecutorService

 */

private void createExecutors() {

    scheduledExecutorServices = Lists.newArrayList();

    for (int i = 0; i < schedulerConfig.getSize(); i++) {

        String threadName = "DelayQueuePollExecutor" + i + "-%d";

        // 核心1个线程

        scheduledExecutorServices.add(new ScheduledThreadPoolExecutor(1,

                new ThreadFactoryBuilder().setNameFormat(threadName).setDaemon(true).build(),

                new ThreadPoolExecutor.DiscardPolicy()));

    }

    log.info("Create scheduledExecutorServices size: {}", scheduledExecutorServices.size());

}

DelayQueuePollEventAsyncConfig

基于Spring自带事件机制,实现异步模式,直接使用SimpleAsyncTaskExecutor,具体配置如下

/**  
 * 延时队列轮询事件发送模式,默认同步  
 */  
@Bean(name = "applicationEventMulticaster")  
public ApplicationEventMulticaster simpleApplicationEventMulticaster(  
        @Autowired(required = false) @Qualifier("delayQueuePollEventErrorHandler") ErrorHandler errorHandler) {  
    SimpleApplicationEventMulticaster eventMulticaster = new SimpleApplicationEventMulticaster();  
    ThreadPoolProperties properties = delayQueuePollEventListenerPoolConfig();  
    Executor taskExecutor = buildExecutor(properties);  
    eventMulticaster.setTaskExecutor(taskExecutor);  
    eventMulticaster.setErrorHandler(errorHandler);  
    return eventMulticaster;  
}

如何使用

maven配置

<!-- 引入leporidae依赖模块,做为统一版本管理,通常在父模块引入 -->
<properties>

    <leporidae.version>1.0.1</nmp-starters.version>

</properties>

<dependencyManagement>

    <dependencies>

        <dependency>

            <groupId>io.gitee.skyarthur1987</groupId>

            <artifactId>leporidae</artifactId>

            <version>${leporidae.version}</version>

            <type>pom</type>

            <scope>import</scope>

        </dependency>

    </dependencies>

</dependencyManagement>

<!-- 引入leporidae-delay-queue, 通常在实际需要使用的子模块引入 -->

<dependencies>

    <dependency>

        <groupId>io.gitee.skyarthur1987</groupId>

        <artifactId>leporidae-delay-queue</artifactId>

    </dependency>

</dependencies>

配置信息

leporidae:
# 全局 redis 配置
  redis:
    host: 127.0.0.1
    port: 6378
    lettuce:
      pool:
        min-idle: 5
        max-idle: 10
        max-active: 8
        max-wait: 1ms
      shutdown-timeout: 100ms
  delay-queue:
    # 延迟队列组件总开关
    enable: true
    # 延迟队列使用redis zset实现,组件redis配置,优先于全局配置
    redis:
      host: 127.0.0.1
      port: 6478
      lettuce:
        pool:
          min-idle: 5
          max-idle: 10
          max-active: 8
          max-wait: 1ms
        shutdown-timeout: 100ms

    # 是否开启延迟队列消费配置,默认false。针对消费端,无需开启消费能力。对于消费端,后面的poll-executor、poll-event-async均无需配置
    consumer.enable: false
    # 可选(有默认值),延迟队列定时轮询(请求redis,拉取任务)配置
    poll-executor:
      # 定时轮询周期,单位:毫秒,默认 5000(5秒)
      periodInMillis: 5000
      # 初始延迟时间,单位:毫秒,默认 5*1000*60(5分钟)
      initialDelayInMillis: 5000
      # 轮询的线程池数量,即并发度,默认 1。仅在多topic延迟队列情况下设置有意义
      size: 1
    # 可选,默认同步处理,延迟队列任务异步处理配置
    poll-event-async:
      # 可选,默认false,即同步处理
      enable: true
      # 异步处理开启后,线程池配置
      pool:
        # 可选,指定已有线程池名称,默认不指定即会更具如下配置创建新线程池
        executor-bean-name: testTaskExecutor
        # 如果指定executor-bean-name,后续线程池配置无意义
        core-pool-size: 8
        maximum-pool-size: 16
        keep-alive-in-seconds: 30
        blocking-queue-size: 500
        pool-name: delayQueuePollEventListener-pool-%d

starter使用

1.提供接口实现延迟队列任务添加和查询,需要在业务应用中注入 DelayQueue,代码如下

@Autowired(required = false)
@Qualifier("zSetDelayQueue")
private DelayQueue<String> delayQueue;

2.延迟队列任务处理,通过实现 DelayQueuePollEventHandler 接口,接口定义如下:其中handler方法即为具体处理逻辑;getSupportedTopics 即此handler支持的延迟队列topic集合,组件框架会自动探测handler,建立 topic->handler的映射,即策略模式,应用方无需感知。具体例子,在单测中也有体现(建议维护一个枚举 DeLayQueueTaskType)

// DelayQueueTopic.java
@Getter
public enum DelayQueueTopic {
    TASK_PRINT("taskPrint", TaskDto.class);

    private final String topic;
    private final Class<?> elementClz;

    DelayQueueTopic(String topic, Class<?> elementClz) {
        this.topic = topic;
        this.elementClz = elementClz;
    }
}

// EventCollectTestHandler.java
@Service
public class EventCollectTestHandler implements DelayQueuePollEventHandler {

    @Override
    public void handle(DelayQueuePollEvent pollEvent) {
        System.out.println("EventCollectTestHandler handle event.¬");
    }

    @Override
    public Set<String> getSupportedTopics() {
        return Sets.newHashSet(DelayQueueTopic.TASK_PRINT.getTopic());
    }
}

3.支持自定义延迟队列消费异常处理,实现 DelayQueueConsumerExceptionHandler 接口,组件默认提供了一种实现(打印异常栈并发送如流报警),基本能满足大多数场景,核心代码如下

public interface DelayQueueConsumerExceptionHandler {  
  
    /**  
     * 具体处理方法  
     *  
     * @param e    异常  
     * @param task 一批延迟队列任务  
     */  
    void handle(Throwable e, BatchDelayQueueTask task);  
}

4.事件异步处理线程池初始化:如果应用方已定义 Executor,可以通过leporidae.delay-queue.poll-event-async.pool.executor-bean-name配置bean名称,指定具体要使用的executor,否则会默认根据pool配置创建executor,见如下代码

    private Executor buildExecutor(ThreadPoolProperties properties) {

        String executorBeanName = properties.getExecutorBeanName();

        if (StringUtils.hasText(executorBeanName)) {

            log.info("ApplicationEventMulticaster executor: {}", executorBeanName);

            return applicationContext.getBean(executorBeanName, Executor.class);

        }

        log.info("Init new executor, config: {}", JsonUtils.toJson(properties));

        return new ThreadPoolExecutor(properties.getCorePoolSize(),

                properties.getMaximumPoolSize(),

                properties.getKeepAliveInSeconds(), TimeUnit.SECONDS,

                new LinkedBlockingQueue<>(properties.getBlockingQueueSize()),

                new ThreadFactoryBuilder().setNameFormat(properties.getPoolName()).setDaemon(true).build(),

                new ThreadPoolExecutor.AbortPolicy());

    }

源码下载

延迟队列实现源码