破局延时任务(下):Spring Boot + DelayQueue 优雅实现分布式延时队列(实战篇)

1.概述

在上篇文章中,我们深入分析了各种延时任务方案的优劣,并提出了基于Spring Boot + DelayQueue的自研分布式延时队列架构设计。今天,我们将聚焦核心实现细节,手把手带你构建一个高性能、高可用的分布式延时任务组件。

上一篇文章请看:破局延时任务(上):为什么选择Spring Boot + DelayQueue来自研分布式延时队列组件?

2.核心架构

让我们快速回顾一下整体架构设计:

组件核心模块包括:

  1. 协调服务(Coordinator) :节点自动注册与发现、心跳检测与续期、健康状态监控、集群节点管理
  2. 任务存储(TaskStorage) :延时任务数据持久化、任务状态管理、执行记录追踪
  3. 任务处理器(DelayTaskExecutor) :业务逻辑回调接口、任务执行状态管理、异常处理与重试机制
  4. 分布式延时队列(DistributedDelayQueue) :对外统一API、任务调度核心逻辑、分布式协调控制

消息队列对消息处理有三大核心要求:不丢失、不重复、不堆积 。对应到我们的延时任务组件,同样有三大核心目标:不丢失、不重复、要准时。只要任务能够准时触发执行,自然就不会出现堆积问题。

下面我们围绕分布式架构和三大核心目标,详细解析组件的具体实现方案。

3.组件核心实现

3.1 延时任务实体封装

首先定义组件的延时任务核心实体类,这是框架使用的基础

arduino 复制代码
@Data
public class DelayTask implements Delayed {
​
    /**
     * 队列名称,区分不同的业务类型
     */
    private final String queueName;
    /**
     * 延时任务id,执行任务时通过id获取任务详情
     */
    private final String taskId;
    /**
     * 任务执行时间  单位:ms
     */
    private final long executeTime;
​
​
    public DelayTask(String queueName, String taskId, long executeTime) {
        this.queueName = queueName;
        this.taskId = taskId;
        this.executeTime = executeTime;
    }
​
    @Override
    public long getDelay(TimeUnit unit) {
        return unit.convert(executeTime - System.currentTimeMillis(),
                TimeUnit.MILLISECONDS);
    }
​
    @Override
    public int compareTo(@NonNull Delayed o) {
        return Long.compare(executeTime, ((DelayTask)o).executeTime);
    }
​
    /**
     * taskId可能重复,因为不同的业务队列,任务id可能是相同的
     * @return 唯一的业务任务id
     */
    public String queueTaskId() {
        return this.queueName + ":" + this.taskId;
    }
}

该类实现了 Delayed接口(JDK延时队列的要求),包含三个核心属性:

  • queueName:队列名称,用于区分不同业务类型
  • taskId:延时任务ID,执行时用于获取任务详情
  • executeTime:任务执行时间戳(毫秒)

通过 queueName + taskId构建全局唯一的延时任务标识。

3.2 任务处理器接口

该接口定义了延时任务的具体业务逻辑。任务到期后,组件会回调对应接口,通过队列名称匹配相应的任务执行器。

csharp 复制代码
public interface DelayTaskExecutor {
    /**
     * 执行业务任务处理逻辑
     * @param delayedTask 任务
     */
    void run(DelayTask delayedTask);
​
    /**
     * 不同业务区分不同队列
     * @return 业务队列名称
     */
    String queueName();
}

3.3 任务存储层实现

由于使用JDK内存DelayQueue实现延时队列,直接将所有任务数据放入内存可能导致OOM,且服务重启会造成数据丢失。因此我们需要将任务持久化存储,通过组件调度拉取即将到期的数据分片到集群节点。

定义任务存储接口,支持多种存储后端(Redis、MySQL等):

arduino 复制代码
public interface TaskStorage {
    /**
     * 新增任务
     * @param task 延迟任务
     */
    void addTask(DelayTask task);
​
    /**
     * 删除任务
     * @param task 延迟任务
     */
    void removeTask(DelayTask task);
​
    /**
     * 获取任务数据
     */
    List<DelayTask> listTask(String queueName, Long startTime, Long endTime);
​
    /**
     * 记录已经执行过的任务
     * @param task 延时任务
     */
    void addExecutedTask(DelayTask task);
​
    /**
     * 判断当前任务是否执行过
     * @param task 延时任务
     * @return 执行过标识
     */
    boolean isExecuted(DelayTask task);
​
    /**
     * 删除执行过的任务
     */
    void removeExecutedTask(Long startTime, Long endTime);
}

基于Redis实现任务存储:

ini 复制代码
public class RedisTaskStorage implements TaskStorage {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
​
​
​
    @Override
    public void addTask(DelayTask task) {
        String queueName = task.getQueueName();
        String taskId = task.getTaskId();
        long executeTime = task.getExecuteTime();
        // 不同业务的任务单独存储,防止一个key存储所有任务导致big key
        stringRedisTemplate.opsForZSet().add(DelayConstant.DELAY_TASK_KEY_PREFIX + queueName, taskId, executeTime);
​
    }
​
    @Override
    public void removeTask(DelayTask task) {
        String queueName = task.getQueueName();
        String taskId = task.getTaskId();
        stringRedisTemplate.opsForZSet().remove(DelayConstant.DELAY_TASK_KEY_PREFIX + queueName, taskId);
    }
​
    @Override
    public List<DelayTask> listTask(String queueName, Long startTime, Long endTime) {
        Set<ZSetOperations.TypedTuple<String>> tuples = stringRedisTemplate.opsForZSet()
                .rangeByScoreWithScores(DelayConstant.DELAY_TASK_KEY_PREFIX + queueName, startTime, endTime);
        List<DelayTask> result = new ArrayList<>();
        if (CollUtil.isEmpty(tuples)) {
            return result;
        }
        for (ZSetOperations.TypedTuple<String> tuple : tuples) {
            String taskId = tuple.getValue();
            Double score = tuple.getScore();
            assert score != null;
            DelayTask task = new DelayTask(queueName, taskId, score.longValue());
            result.add(task);
        }
        return result;
    }
​
    @Override
    public void addExecutedTask(DelayTask task) {
        String queueTaskId = task.queueTaskId();
        stringRedisTemplate.opsForZSet().add(DelayConstant.DELAY_EXECUTED_KEY, queueTaskId, System.currentTimeMillis());
    }
​
    @Override
    public boolean isExecuted(DelayTask task) {
        String queueTaskId = task.queueTaskId();
        Double score = stringRedisTemplate.opsForZSet().score(DelayConstant.DELAY_EXECUTED_KEY, queueTaskId);
        if (score == null) {
            return false;
        }
        // 如果任务的执行时间大于记录的执行时间,说明是业务对此任务调整执行时间后重新入队,需要再次执行,并不是框架分配导致的重复执行问题
        long executeTime = task.getExecuteTime();
        return executeTime <= score.longValue();
    }
​
    @Override
    public void removeExecutedTask(Long startTime, Long endTime) {
        stringRedisTemplate.opsForZSet().removeRangeByScore(DelayConstant.DELAY_EXECUTED_KEY, startTime, endTime);
    }
}

3.4 协调服务

作为分布式组件,集群节点间的协调至关重要。协调服务需要实现:节点注册发现、心跳检测、健康监控、集群管理等功能。

定义协调服务接口,支持多种协调后端(Redis、Zookeeper等):

arduino 复制代码
public interface Coordinator {
​
    /**
     * 服务节点注册
     */
    String registerNode();
​
    /**
     * 注销节点
     */
    void unRegisterNode(String nodeId);
​
    /**
     * 获取存活节点
     * @return 保活节点
     */
    List<String> getActiveNodes();
​
    /**
     * 心跳续期
     */
    void heartBeat(String nodeId);
​
    /**
     * 健康检查
     * @param consumer 下线节点任务转移
     */
    void checkClusterHealth(BiConsumer<List<String>, List<String>> consumer);
​
}

基于Redis实现如下:

scss 复制代码
public class RedisCoordinator implements Coordinator {
    private static final Logger logger = LoggerFactory.getLogger(RedisCoordinator.class);
​
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private RedissonClient redissonClient;
    @Resource
    private DelayProperties delayProperties;
​
    // 心跳续期执行器
    private final ScheduledExecutorService heartbeatExecutor = Executors.newSingleThreadScheduledExecutor();
​
​
​
    /**
     * 注册节点
     * @return nodeId节点标识
     */
    @Override
    public String registerNode() {
        // 生成节点id
        String nodeId = UUID.randomUUID().toString().replace("-", "");
        // 注册节点
        stringRedisTemplate.opsForList().rightPush(DelayConstant.NODES_KEY, nodeId);
        // 设置保活标识
        setNodeHeartbeat(nodeId);
        return nodeId;
    }
​
    /**
     * 注销节点
     * @param nodeId 节点id
     */
    @Override
    public void unRegisterNode(String nodeId) {
        // 在集群中下线节点
        stringRedisTemplate.opsForList().remove(DelayConstant.NODES_KEY, 1, nodeId);
        // 删除节点
        stringRedisTemplate.delete(DelayConstant.NODE_HEARTBEAT_KEY_PREFIX + nodeId);
        heartbeatExecutor.shutdown();
    }
​
    /**
     * 获取存活的节点
     */
    @Override
    public List<String> getActiveNodes() {
        List<String> nodes = stringRedisTemplate.opsForList().range(DelayConstant.NODES_KEY, 0, -1);
        if (CollUtil.isEmpty(nodes)) {
            return new ArrayList<>();
        }
        List<String> activeNodes = new ArrayList<>();
        for (String nodeId : nodes) {
            // 判断节点是否存活
            Boolean exist = stringRedisTemplate.hasKey(DelayConstant.NODE_HEARTBEAT_KEY_PREFIX + nodeId);
            if (exist) {
                activeNodes.add(nodeId);
            }
        }
        return activeNodes;
    }
​
    /**
     * 心跳续期保活,延迟一个周期开始心跳续期
     * @param nodeId 节点id
     */
    @Override
    public void heartBeat(String nodeId) {
        heartbeatExecutor.scheduleAtFixedRate(() -> setNodeHeartbeat(nodeId),
                delayProperties.getHeartbeatPeriod(), delayProperties.getHeartbeatPeriod(), TimeUnit.SECONDS);
    }
​
    /**
     * 集群健康检查
     */
    @Override
    public void checkClusterHealth(BiConsumer<List<String>, List<String>> consumer) {
        // 分布式锁,保证集群中只有一个节点在执行健康检查
        RLock lock = redissonClient.getLock(DelayConstant.NODES_HEALTH_KEY);
        try {
            // 没有获得锁,说明其他节点在执行监控检查,当前节点就不用了,直接返回
            boolean isLock = lock.tryLock();
            if (!isLock) {
                return;
            }
            logger.info("distributeDelayQueue check cluster health");
            // 获取集群节点信息
            List<String> nodes = stringRedisTemplate.opsForList().range(DelayConstant.NODES_KEY, 0, -1);
            if (CollUtil.isEmpty(nodes)) {
                return;
            }
            List<String> deadNodes = new ArrayList<>();
            // 健康检查
            for (String nodeId : nodes) {
                // 判断节点是否存活
                Boolean exist = stringRedisTemplate.hasKey(DelayConstant.NODE_HEARTBEAT_KEY_PREFIX + nodeId);
                if (!exist) {
                    deadNodes.add(nodeId);
                }
            }
            if (CollUtil.isEmpty(deadNodes)) {
                return;
            }
            // 删除原列表然后重新写入
            // stringRedisTemplate.delete(DelayConstant.NODES_KEY);
            // stringRedisTemplate.opsForList().rightPushAll(DelayConstant.NODES_KEY, aliveNodes);
            // 上面先删除再插入无法保证操作原子性,并发情况下可能导致读取存活节点是空的
            // 所以使用下面循环单个删除,节点数量少没啥性能问题
            deadNodes.forEach(nodeId -> {
                // 每个deadNode都是唯一的,所以只需删除1次
                stringRedisTemplate.opsForList().remove(DelayConstant.NODES_KEY, 1, nodeId);
            });
​
            // 有节点下线,将下线节点的数据移到当前节点, 重平衡的核心逻辑在这里
            consumer.accept(nodes, deadNodes);
        } catch (Exception e) {
            logger.error("check nodes health: ", e);
        } finally {
            lock.unlock();
        }
​
​
    }
​
    /**
     * 节点保活 过期时间是心跳需求周期的2倍,保证一个心跳续期周期内节点不会过期
     */
    private void setNodeHeartbeat(String nodeId) {
        stringRedisTemplate.opsForValue().set(DelayConstant.NODE_HEARTBEAT_KEY_PREFIX + nodeId,
                "1", delayProperties.getHeartbeatPeriod() * 2, TimeUnit.SECONDS);
    }
​
}

3.5 分布式延时队列核心

DistributedDelayQueue是组件的核心入口,作为单例Bean注入Spring容器:

scss 复制代码
public class DistributedDelayQueue implements InitializingBean, ApplicationListener<ContextClosedEvent> {
    private static final Logger logger = LoggerFactory.getLogger(DistributedDelayQueue.class);
    @Resource
    private Coordinator coordinator;
    @Resource
    private TaskStorage taskStorage;
    @Resource
    private RedissonClient redissonClient;
    @Resource
    private DelayProperties delayProperties;
    @Autowired(required = false)
    private List<DelayTaskExecutor> taskExecutors;
​
    /**  队列名称 -> 延迟队列
     *   业务队列隔离开来
     */
    private final ConcurrentMap<String, DelayQueue<DelayTask>> delayMap = new ConcurrentHashMap<>();
    /** 业务队列名称   */
    private final Set<String> queueNameSet = new HashSet<>();
    /** 当前节点存储的业务任务id  */
    private final Set<String> taskIdSet = new HashSet<>();
    /** 当前节点id   */
    private String nodeId;
    /** 运行标志, 控制工作线程的启停,防止重复启动,做到优雅关闭  */
    private final AtomicBoolean running = new AtomicBoolean(false);
    /** 加载数据执行器   */
    private final ScheduledExecutorService loadExecutor = Executors.newSingleThreadScheduledExecutor();
    /** 运行工作执行器   */
    private final ScheduledExecutorService runningExecutor = Executors.newSingleThreadScheduledExecutor();
    /** 健康检查执行器   */
    private final ScheduledExecutorService healthExecutor = Executors.newSingleThreadScheduledExecutor();
    /** 删除已执行数据执行器   */
    private final ScheduledExecutorService removeExecutor = Executors.newSingleThreadScheduledExecutor();
    /** 工作线程池,异步执行任务业务逻辑   */
    private final PlasticeneThreadExecutor workExecutor = new PlasticeneThreadExecutor(
            Runtime.getRuntime().availableProcessors() + 1,
            Runtime.getRuntime().availableProcessors() * 5,
            1000,
            "delay-consumer-"
            );
​
​
    /**
     * 添加延迟任务的入口,这是分布式延时队列暴露给业务方使用的;
     * 执行时间在拉取数据周期内,直接放入当前节点延时队列执行,
     * 这样能让任务准时触发,但是存储任务之后被拉取进行分片,可能分到其他节点重复执行
     * eg: 任务A在5分钟之后执行,但是拉取数据到本地队列周期是10分钟,如果等到周期去拉取,那么任务A就没办法准时执行了
     * @param delayTask 延迟任务
     */
    public void addTask(DelayTask delayTask) {
        long executeTime = delayTask.getExecuteTime();
        long afterTime = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(delayProperties.getPullPeriod());
        if (afterTime > executeTime) {
            offerTask(delayTask);
        }
        taskStorage.addTask(delayTask);
    }
​
    /**
     * 服务启动,进行初始化相关操作
     */
    @Override
    public void afterPropertiesSet() {
        // 没有业务延时任务处理器,就没必要进行后续初始化操作了
        if (CollUtil.isEmpty(taskExecutors)) {
            return;
        }
        // 获取业务类型
        taskExecutors.forEach(taskExecutor -> this.queueNameSet.add(taskExecutor.queueName()));
        // 1.注册节点,建立心跳机制
        this.nodeId = coordinator.registerNode();
        coordinator.heartBeat(this.nodeId);
​
        // 2.启动工作线程
        running.set(true);
        runningExecutor.submit(()-> {
            while (running.get()) {
                try {
                    // 根据不同业务队列名称扫描延迟队列
                    for (Map.Entry<String, DelayQueue<DelayTask>> entry : delayMap.entrySet()) {
                        DelayQueue<DelayTask> delayQueue = entry.getValue();
                        DelayTask task = delayQueue.poll(1, TimeUnit.SECONDS);
                        executeTask(task);
                    }
                    // 睡眠1s
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    running.set(false);
                    Thread.currentThread().interrupt();
                    logger.error("delay task error:", e);
                }
            }
        });
        // 3.数据加载:每隔一定周期拉取一次数据 如每隔10分钟拉取,拉取数据是执行时间【0,当前时间+10m】
        loadExecutor.scheduleAtFixedRate(this::loadTask, delayProperties.getPullInitialDelay(),
                delayProperties.getPullPeriod(), TimeUnit.SECONDS);
        // 4.启动集群节点监控检查
        healthExecutor.scheduleWithFixedDelay(
                () -> coordinator.checkClusterHealth(this::moveOfflineNodeTask),
                delayProperties.getHealthInitialDelay(), delayProperties.getHealthPeriod(), TimeUnit.SECONDS);
        // 5.删除执行过的记录
        long endTime = System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(delayProperties.getRemovePeriod());
        removeExecutor.scheduleAtFixedRate(() -> taskStorage.removeExecutedTask(0L, endTime),
                delayProperties.getRemoveInitialDelay(), delayProperties.getRemovePeriod(), TimeUnit.SECONDS);
        logger.info("distributeDelayQueue init: {}", this.nodeId);
    }
​
    /**
     * 优雅下线
     */
    @Override
    public void onApplicationEvent(@NonNull ContextClosedEvent event) {
        running.set(false);
        // 在正式销毁前先执行Redis相关操作,此时Redis连接还可用
        coordinator.unRegisterNode(this.nodeId);
        // 先关闭健康检查和拉取数据
        healthExecutor.shutdown();
        loadExecutor.shutdown();
        runningExecutor.shutdown();
        workExecutor.shutdown();
        removeExecutor.shutdown();
        delayMap.clear();
        taskIdSet.clear();
        queueNameSet.clear();
        logger.info("distributeDelayQueue destroy: {}", this.nodeId);
    }
​
    private void executeTask(DelayTask task) {
        if (task == null) {
            return;
        }
        // 真正执行延时任务逻辑
        workExecutor.submit(() -> {
            // 先删除本地taskId
            taskIdSet.remove(task.queueTaskId());
            // 因可能重复分配导致同一任务存在于不同节点上,分布式锁控制一个任务同一时间只能在一个节点上执行
            RLock lock = redissonClient.getLock(DelayConstant.DELAY_EXECUTING_KEY_PREFIX + task.queueTaskId());
            try {
                boolean isLock = lock.tryLock();
                // 没有获得锁直接返回
                if (!isLock) {
                    return;
                }
                taskStorage.removeTask(task);
                // 判断是否执行过,防止重复执行
                boolean executed = taskStorage.isExecuted(task);
                if (executed) {
                    logger.info("task executed, queueTaskId:{}", task.queueTaskId());
                    return;
                }
                // 先记录执行过标识
                taskStorage.addExecutedTask(task);
                // 匹配对应业务的延时任务处理器
                taskExecutors.forEach(executor -> {
                    if (Objects.equals(task.getQueueName(), executor.queueName())) {
                        // 执行延时任务业务处理逻辑
                        executor.run(task);
                    }
                });
                logger.info("task execute success, queueTaskId:{}  ", task.queueTaskId());
            } catch (Exception e) {
                logger.error(" task execute error queueTaskId: {}", task.queueTaskId(), e);
            } finally {
                lock.unlock();
            }
        });
​
    }
​
​
    /**
     * 将下线的节点的任务分配到当前节点来
     * @param originNodes  集群元节点
     * @param deadNodes  下线节点
     */
    private void moveOfflineNodeTask(List<String> originNodes, List<String> deadNodes) {
        if (!running.get()) {
            return;
        }
        if (CollUtil.isEmpty(originNodes) || CollUtil.isEmpty(deadNodes)) {
            return;
        }
        long end = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(delayProperties.getPullPeriod());
        deadNodes.forEach(deadNode -> queueNameSet.forEach(queueName -> {
            List<DelayTask> tasks = taskStorage.listTask(queueName, 0L, end);
            tasks.forEach(task-> {
                int hash = Math.abs(task.getTaskId().hashCode());
                int index = hash % originNodes.size();
                // 判断当前任务之前是否分配到了下线节点上
                if (Objects.equals(originNodes.get(index), deadNode)) {
                    offerTask(task);
                }
            });
        }));
    }
​
​
    /**
     * 拉取数据
     * 如加载执行时间为【0, 当前时间+10分钟】内的数据
     * 每10分钟拉取一次,下一次拉取之前正常情况上一次拉取的数据已经处理完成了,
     * 所以一般情况任务存储和本地延时队列内存里面都不会堆积过多数据
     */
    private void loadTask() {
        if (!running.get()) {
            return;
        }
        logger.info("distributeDelayQueue load data: {}", this.nodeId);
        List<String> activeNodes = coordinator.getActiveNodes();
        long end = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(delayProperties.getPullPeriod());
        queueNameSet.forEach(queueName -> {
            List<DelayTask> tasks = taskStorage.listTask(queueName, 0L, end);
            tasks.forEach(task-> {
                // 判断任务是否应该分配到此节点
                if (canProcess(task, activeNodes)) {
                    offerTask(task);
                }
            });
        });
​
    }
​
    /**
     * 判断任务是否可以分配当前节点
     */
    private Boolean canProcess(DelayTask task, List<String> activeNodes) {
        if (!running.get()) {
            return false;
        }
        if (taskIdSet.contains(task.queueTaskId())) {
            return false;
        }
        if (CollUtil.isEmpty(activeNodes)) {
            return false;
        }
        int hash = Math.abs(task.getTaskId().hashCode());
        int index = hash % activeNodes.size();
        return activeNodes.get(index).equals(this.nodeId);
    }
​
    /**
     * 添加延时任务到本地延时队列
     */
    private void offerTask(DelayTask task) {
        String queueName = task.getQueueName();
        DelayQueue<DelayTask> delayQueue = delayMap.computeIfAbsent(
                queueName,
                k -> new DelayQueue<>()
        );
        delayQueue.offer(task);
        // 记录当前节点已经加载了这个业务任务
        taskIdSet.add(task.queueTaskId());
        logger.info("task offer, queueTaskId:{}, nodeId: {}", task.queueTaskId(), this.nodeId);
    }
}

这里是分布式延时任务组件的核心逻辑所在,注释比较详细,核心逻辑有:业务使用入口addTask(),服务启动的节点注册、心跳续期吉致,优雅下线,后台启动几个线程:

  • 工作线程:扫描当前服务本地DelayQueue到期的任务,不同业务放在不同的任务队列中,防止相互影响。同时使用线程池去执行延时任务逻辑,防止具体逻辑复杂执行过慢导致任务堆积。
  • 拉取数据线程:按照指定时间拉取快到期任务数据分片到集群中各个节点。
  • 监控检查线程:监控集群节点健康情况,对下线节点的任务数据进行重分配。这和kafka的消费者重平衡概念差不多。

3.6 使用示例

定义业务任务处理器

typescript 复制代码
@Component
@Slf4j
public class OrderTaskExecutor implements DelayTaskExecutor {
    
    @Override
    public void run(DelayTask delayedTask) {
        log.info("执行订单延时任务: {}", delayedTask);
        // 具体的业务逻辑实现
    }
​
    @Override
    public String queueName() {
        return "order";
    }
}

添加延时任务

less 复制代码
@RestController
@RequestMapping("/delay")
public class DelayController {
    
    @Resource
    private DistributedDelayQueue distributedDelayQueue;
​
    @PostMapping("/add")
    public void addDelayTask() {
        // 添加3分钟后执行的任务
        DelayTask task1 = new DelayTask("order", "001", 
            System.currentTimeMillis() + 3 * 60 * 1000);
        distributedDelayQueue.addTask(task1);
        
        // 添加多个不同延时的任务
        DelayTask task2 = new DelayTask("order", "002", 
            System.currentTimeMillis() + 15 * 60 * 1000);
        distributedDelayQueue.addTask(task2);
        
        // ... 更多任务
    }
}

集群部署测试

  • 启动两个服务实例构成集群
  • 通过上面的API添加延时任务
  • 观察各节点控制台日志,验证任务分配与执行情况

3.7 配置与常量

常量定义

arduino 复制代码
public class DelayConstant {
    /**
     * 存储分布式集群节点信息
     */
    public static final String NODES_KEY = "ptc:nodes";
    /**
     * 节点心跳续期key
     */
    public static final String NODE_HEARTBEAT_KEY_PREFIX = "ptc:heartbeat:";
    /**
     * 集群健康检查分布式锁key
     */
    public static final String NODES_HEALTH_KEY = "ptc:nodes:health";
    /**
     * 存储业务的任务id
     */
    public static final String DELAY_TASK_KEY_PREFIX = "ptc:delay:task:";
    /**
     * 存储已执行的任务id
     */
    public static final String DELAY_EXECUTED_KEY = "ptc:delay:Executed";
    /**
     * 任务执行中的分布式锁key
     */
    public static final String DELAY_EXECUTING_KEY_PREFIX = "ptc:delay:executing:";
}

配置属性

ini 复制代码
@Data
@ConfigurationProperties(prefix = "ptc.delay")
public class DelayProperties {
    /**
     * 节点心跳续期保活周期,单位:秒,默认30s
     */
    private Integer heartbeatPeriod = 30;
    /**
     * 拉取数据初始化延迟时间,单位:秒,默认0s
     */
    private Integer pullInitialDelay = 0;
    /**
     * 拉取数据的周期,单位:秒,默认10m
     */
    private Integer pullPeriod = 10*60;
    /**
     * 健康检查初始化延迟时间,单位:秒,默认2m
     */
    private Integer healthInitialDelay = 2*60;
    /**
     * 健康检查周期,单位:秒,默认3m
     */
    private Integer healthPeriod = 3*60;
    /**
     * 删除数据的初始化延迟时间,单位:秒,默认1h
     */
    private Integer removeInitialDelay = 60*60;
    /**
     * 删除数据周期,单位:秒,默认1h
     */
    private Integer removePeriod = 60*60;
}

自动配置和条件注入

java 复制代码
@Configuration
@EnableConfigurationProperties({DelayProperties.class})
public class DelayAutoConfiguration {
​
    @ConditionalOnMissingBean(Coordinator.class)
    @Bean
    public Coordinator coordinator() {
        return new RedisCoordinator();
    }
​
    @ConditionalOnMissingBean(TaskStorage.class)
    @Bean
    public TaskStorage taskStorage() {
        return new RedisTaskStorage();
    }
​
    @Bean
    public DistributedDelayQueue distributedDelayQueue() {
        return new DistributedDelayQueue();
    }
​
}

4.总结与展望

通过本文的完整实现,我们成功构建了一个基于Spring Boot + DelayQueue的分布式延时任务组件,具备以下核心特性:

🎯 核心优势

  1. 准时触发 :基于DelayQueue的精确延时控制,毫秒级精度
  2. 分布式协调:多节点自动分片和负载均衡,支持水平扩展
  3. 数据可靠性:任务持久化保障,服务重启不丢失
  4. 高可用性:自动故障转移,无单点故障
  5. 易扩展性:支持自定义任务处理器,业务接入简单

🏗 架构亮点

  • 优雅启停:完整的生命周期管理,避免任务丢失
  • 资源隔离:不同业务队列相互隔离,互不影响
  • 智能重平衡:节点故障时自动任务转移
  • 防重复执行:多层防护机制确保任务唯一性

📈 生产就绪

组件已具备生产环境使用的基本条件,包括:

  • 详细的运行日志记录
  • 有序的资源清理机制
  • 合理的异常处理逻辑
  • 可配置的运行参数

🔮 后续优化方向

对于追求更高标准的团队,还可以进一步优化:

  • 监控体系:集成Prometheus等监控方案,收集运行指标
  • 管理界面:开发任务管理后台,支持手动干预
  • 性能优化:大数据量下的存储和分片算法优化
  • 告警机制:集成钉钉、企业微信等告警通道

完整代码开源:gitee.com/plasticene3...

如果觉得有帮助,欢迎Star支持!如有问题欢迎Issue讨论。

期待您的反馈与交流! 在实际使用过程中遇到任何问题或有改进建议,欢迎在评论区留言讨论。

相关推荐
绝无仅有7 小时前
某游戏大厂分布式系统经典实战面试题解析
后端·面试·程序员
听风吟丶7 小时前
深入解析 Spring Boot 自动配置:原理、实践与进阶
java·数据库·sql
lang201509287 小时前
Spring Boot 核心技巧与实战指南
java·数据库·spring boot
9ilk7 小时前
【基于one-loop-per-thread的高并发服务器】--- 项目介绍&&模块划分
运维·服务器·c++·后端·中间件
SimonKing7 小时前
Spring Boot还能这样玩?同时监听多个端口的黑科技
java·后端·程序员
日月星辰Ace7 小时前
JDK 工具学习系列(三):javadoc 命令实用教程
java
青梅主码7 小时前
CB Insights最新发布《2025年Q3全球AI状态》:融资交易单笔规模暴增86% AI初创融资像过山车 代理AI成香饽饽
后端
亚林瓜子7 小时前
SpringBoot中使用tess4j进行OCR(在macos上面开发)
java·spring boot·macos·ocr·lstm·tess4j
CoovallyAIHub7 小时前
Cursor 2.0 太离谱了!8 个 AI 同时写代码,还能自己测!
css·后端·程序员