Spring定时任务动态更改(增、删、改)Cron表达式方案实例详解

Spring定时任务动态更改(增、删、改)Cron表达式方案实例详解

最近在做一个需求,用户可以在平台上配置任务计划数据(就是任务的执行和描述的相关信息,包括任务参数、cron表达式),配置后,后端可以根据配置的计划信息在cron表示式的时间生成相应的一条任务记录。这里用户可以新增任务计划、修改任务计划、删除任务计划,后端要根据用户的配置动态的生成相应的任务记录。

我这里的做法都是定时(比如每隔30s)扫描数据库中所有可用的用户任务配置,根据每次扫描到的配置信息,对已有的调度信息做增加、删除、修改。虽然服务是分布式部署的,因为我们的场景是根据用户的任务配置信息,在用户配置的时间点向数据库生成一条任务信息(给下游使用),并不是马上执行任务的内容,所以资源的消耗不大,为了简化开发,我们要求任务生成要只会在一台机器上执行。

这我们的方案中,不考虑用户实时修改后马上生效,这里主要原因是服务可能是分布式部署的,如果不同的任务信息分布到不同的机器,用户修改后要实时生效,就必须将变化的任务分配到调度信息所在的机器上才能实时更新。

这里我实现了两个版本。方案一是基于延迟队列做的,方案二是基于Spring的SchedulingConfigurer做的。

方案一:基于延迟队列

延迟队列DelayQueue的做法,是基于延迟的属性,让服务在固定的时间根据用户的配置,生成一条任务信息。使用消息的生产和消费模式。延迟消息的延迟时间根据cron表达式生成。

每隔30s扫描一次用户的配置表,根据用户的各个任务配置信息,分别生产一条延迟消息,同时使用Map记录消息信息,Map的key使用这个任务配置信息的json字符串(数据库的一条数据)对应的md5值,value是这条任务配置的数据对象。

每次生产消息时,校验Map中是否已经存在相同的md5值,如果存在相同的md5值,说明配置信息没有更新,并且延迟队列中已经有未消费的消息,本次就不生成新的消息。如果不存在相同的md5值,说明是一个新任务配置或是用户修改后的任务配置(不管是修改cron表达式还是任务的其他参数),这时就生成新的延迟消息。因此,对应任务的修改,同一个任务配置,在Map中会有多条消息,在消费时需要校验哪条消息才是有效的,无效的消息消费后被过滤掉。

首先,定义一个消息对象,实现Delayed接口:

package com.XXXXX.or.algo.full.warehouse.bo.msg;

import com.XXXXX.or.algo.full.warehouse.entity.db1.WarehouseAdjustmentPlan;
import lombok.Data;
import org.jetbrains.annotations.NotNull;

import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;

/**
 * 延迟队列的消息
 */
@Data
public class PlanMsg implements Delayed {

    private String planId;

    // 延迟时间 秒
    private long delaySec;

    // 过期时间 纳秒 对与cpu来说毫秒太大
    private long expire;

    // 数据库中的任务计划配置
    private WarehouseAdjustmentPlan detail;

    // 本条消息中数据库的md5
    private String md5;

    public PlanMsg(String planId, long delaySec,WarehouseAdjustmentPlan detail,String md5) {
        this.planId = planId;
        this.delaySec = delaySec;
        // 过期时间 纳秒
        this.expire = System.nanoTime() + TimeUnit.NANOSECONDS.convert(delaySec,TimeUnit.SECONDS);
        this.detail = detail;
        this.md5 = md5;
    }

    @Override
    public long getDelay(@NotNull TimeUnit unit) {
        return unit.convert(this.expire - System.nanoTime(),TimeUnit.NANOSECONDS);
    }

    @Override
    public int compareTo(@NotNull Delayed o) {
        long time = getDelay(TimeUnit.NANOSECONDS) - o.getDelay(TimeUnit.NANOSECONDS);
        return time == 0 ? 0 : ( time > 0 ? 1 : -1);
    }
}

使用一个组件对消息做生产和消费:

cron表达式依赖:

<!--        cron表达式相关-->
        <dependency>
            <groupId>com.cronutils</groupId>
            <artifactId>cron-utils</artifactId>
            <version>9.1.5</version>
        </dependency>

这里的生产使用@Scheduled(cron = "0/30 * * * * ?")定期扫描数据库中计划配置信息,和planMd5Map比较后决定是否生成新的消息。这里的消息的延迟时间根据cron表达式生成。

消息的消费,项目启动后,使用@PostConstruct启动一个线程消费消息,如果消息没有到延迟的时间,会阻塞在delayQueue.take()位置。当消费到消息后,根据消息的id到数据库中找到这条配置消息,通过比较md5决定是否向数据库插入一条任务。如果消息中的md5和根据消息id到数据库中查询的记录的md5一致,则插入一条任务数据;否则丢弃该消息。这样用户对任务配置的参数增删改都能很好的覆盖了。

package com.XXXXX.or.algo.full.warehouse.job;

import com.alibaba.fastjson.JSON;
import com.cronutils.model.CronType;
import com.cronutils.model.definition.CronDefinition;
import com.cronutils.model.definition.CronDefinitionBuilder;
import com.cronutils.model.time.ExecutionTime;
import com.cronutils.parser.CronParser;
import com.dangdang.ddframe.job.api.ShardingContext;
import com.dangdang.ddframe.job.api.simple.SimpleJob;
import com.XXXXX.or.algo.full.warehouse.bo.PlanningCommitReq;
import com.XXXXX.or.algo.full.warehouse.bo.msg.PlanMsg;
import com.XXXXX.or.algo.full.warehouse.entity.db1.WarehouseAdjustmentPlan;
import com.XXXXX.or.algo.full.warehouse.service.WarehouseAdjustmentService;
import com.XXXXX.or.algo.full.warehouse.util.CommUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;

import javax.annotation.PostConstruct;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.concurrent.DelayQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;


@Component
@Slf4j
public class CronExpTaskJob implements SimpleJob {

    // 每个plan的一条或多条消息(如果用户修改计划,会生成多条消息)
    private DelayQueue<PlanMsg> delayQueue =  new DelayQueue<>();

    // 每个md5 对应的计划数据
    private Map<String, WarehouseAdjustmentPlan> planMd5Map = new HashMap<>();

    private ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();

    @Autowired
    private WarehouseAdjustmentService warehouseAdjustmentService;

    @PostConstruct
    public void loadValidPlans(){
        // 监听消息 生成任务
        singleThreadExecutor.execute(()->{
            while (true) {
                String planId = "";
                String msgMd5 = "";
                try {
                    // 阻塞队列
                    PlanMsg msg = delayQueue.take();
                    log.info("消费消息:{}",JSON.toJSONString(msg));
                    planId = msg.getPlanId();
                    msgMd5 = msg.getMd5();

                    // 校验
                    WarehouseAdjustmentPlan dbPlan = warehouseAdjustmentService.query(planId);
                    String dbPlanMd5 = CommUtil.getMD5(JSON.toJSONString(dbPlan));

                    // 消息的md5值和数据中数据的md5值不一样,说明数据有变,不生成任务
                    if(! msgMd5.equals(dbPlanMd5)){
                        log.info("计划改变,不提交任务。改变前消息:{}; 改变后数据库:{}", JSON.toJSONString(msg.getDetail()), JSON.toJSONString(dbPlan));
                        continue;
                    }

                    PlanningCommitReq req = new PlanningCommitReq();
                    req.setPlanId(msg.getPlanId());
                    req.setUserId("sys");
                    req.setUserName("sys");
                    // 生成任务
                    warehouseAdjustmentService.commit(req);
                    log.info("计划id:{},提交成功。时间{}",msg.getPlanId(),new Date());
                } catch (Exception e) {
                    log.info("计划id:{},提交失败,提交时间{}", planId, new Date(), e);
                }finally {
                    planMd5Map.remove(msgMd5);
                }
            }
        });
    }

    @Scheduled(cron = "0/30 * * * * ?") // 30秒一次测试使用  线上分布式部署使用elastic-job
    public void generateMsg(){

        // 找到所有计划
        List<WarehouseAdjustmentPlan> planList = warehouseAdjustmentService.loadValidPlans();

        if(CollectionUtils.isEmpty(planList)){
            return;
        }

        for (WarehouseAdjustmentPlan plan : planList) {

            try {
                String dbPlanMd5 = CommUtil.getMD5(JSON.toJSONString(plan));

                // 不同md5值的相同计划id,都可以提交;相同md5值计划,不能重复提交
                if(planMd5Map.containsKey(dbPlanMd5)){
                    // 消息已经存 并且未被消费
                    log.info("存在未消费的相同信息,不生成当前消息,plan_id:{}",plan.getPlanId());
                    continue;
                }

                CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ);
                CronParser parser = new CronParser(cronDefinition);
                ExecutionTime executionTime = ExecutionTime.forCron(parser.parse(plan.getCronExpression()));

                // 离下一次执行还有多久
                Optional<Duration> duration = executionTime.timeToNextExecution(ZonedDateTime.now());

                PlanMsg planMsg = new PlanMsg(plan.getPlanId(), duration.get().getSeconds(),plan,dbPlanMd5);

                // 发消息
                log.info("生产消息成功。计划:{}", JSON.toJSONString(plan));
                delayQueue.add(planMsg);

                // 记录队列中planId的最新的一个md5值
                planMd5Map.put(dbPlanMd5, plan);
            }catch (Exception e){
                log.info("任务消息生产失败。计划:{}", JSON.toJSONString(plan), e);
            }

        }
    }
}

方案二:基于Spring的SchedulingConfigurer接口

实现SchedulingConfigurer接口中的public void configureTasks(ScheduledTaskRegistrar taskRegistrar)方法,方法的入参ScheduledTaskRegistrar是个关键变量。

为了适合用户配置计划任务较多的场景,使用ThreadPoolTaskScheduler线程池。

这里的关键是自定义的freshTasks()方法,这个方法有两处调用,一个是configureTasks方法中的调用,一个是通过@Scheduled(cron = "0/30 * * * * ?")定时调用。freshTasks()方案中,首先全量查询数据库中的用户任务配置数据,和上一次查询的全量配置数据进行比较,找到哪些是用户新增的,哪些是用户修改的,哪些是用户删除的(停止的)。然后针对这三种数据,分别调用对应的方法修改ScheduledTaskRegistrar 中已经加载的任务信息。

成员变量Map<String,ScheduledTask>是一个自定义的关键变量,key是数据库中用户的配置计划的id,value是Spring调度器中的每个任务,任务的增、删、改都是操作这个map。

package com.XXXXX.or.algo.full.warehouse.job;

import com.alibaba.fastjson.JSON;
import com.XXXXX.or.algo.full.warehouse.bo.PlanningCommitReq;
import com.XXXXX.or.algo.full.warehouse.entity.db1.WarehouseAdjustmentPlan;
import com.XXXXX.or.algo.full.warehouse.service.WarehouseAdjustmentService;
import com.XXXXX.or.algo.full.warehouse.util.CommUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.config.CronTask;
import org.springframework.scheduling.config.ScheduledTask;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.util.CollectionUtils;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;


/**
 * 定期扫描数据库的最新配置信息,对任务做增、删、改
 */
@Configuration
@Slf4j
@SuppressWarnings("Duplicates")
public class MyScheduleConfig implements SchedulingConfigurer{

    // 用于查询数据库中每一个任务配置信息:包括任务id,对应的cron表达式
    @Autowired
    private WarehouseAdjustmentService warehouseAdjustmentService;

    // 上一次查询到的数据库任务配置信息 用于和本次查询进行对比后对existedTask任务做增、删、改
    private List<WarehouseAdjustmentPlan> historyConfList = new ArrayList<>();

    // 根据数据库任务配置信息生成的任务, 任务的增、删、改都是操作这个map
    private Map<String,ScheduledTask> existedTask = new HashMap<>();

    private ScheduledTaskRegistrar taskRegistrar;

    /**
     * 用线程池执行任务
     * @return
     */
    @Bean
    public ThreadPoolTaskScheduler threadPoolTaskScheduler(){
        ThreadPoolTaskScheduler threadPool = new ThreadPoolTaskScheduler();
        threadPool.setPoolSize(20);
        threadPool.setThreadNamePrefix("plan-to-task");
        return threadPool;
    }

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {

        // 将taskRegistrar作为成员变量 便于后续任务的增删改
        this.taskRegistrar = taskRegistrar;

        // 通过线程池去启动不同的定时任务。适合定时任务较多的场景。
        ThreadPoolTaskScheduler threadPool = threadPoolTaskScheduler();
        taskRegistrar.setScheduler(threadPool);

        // 根据数据库配置 启动全量刷新任务 分布式部署时这里不能加载 
        // freshTasks();
    }


    /**
     * 根据数据库配置 定期全量刷新任务
     */
    @Scheduled(cron = "0/30 * * * * ?")  // 分布式部署时需要考虑其他方案,比如@Scheduled+分布式锁 或使用elastic-job等
    public void shceduled(){
        freshTasks();
    }

    /**
     * 通过比较数据库中配置信息变化 找到增、删、改的任务,并刷新任务列表
     */
    public synchronized void freshTasks(){
        // 找到数据库最新的全量有效配置
        List<WarehouseAdjustmentPlan> newestConfList = warehouseAdjustmentService.loadValidPlans();
        // 上一次的全量有效配置
        List<WarehouseAdjustmentPlan> historyConfList = this.historyConfList;

        if(CollectionUtils.isEmpty(newestConfList)){
            newestConfList = new ArrayList<>();
        }
        if(CollectionUtils.isEmpty(historyConfList)){
            historyConfList = new ArrayList<>();
        }
        // list转map
        Map<String, WarehouseAdjustmentPlan> newestConfMap = newestConfList.stream().collect(Collectors.toMap(WarehouseAdjustmentPlan::getPlanId, Function.identity(), (o1, o2) -> o1));
        Map<String, WarehouseAdjustmentPlan> historyConfMap = historyConfList.stream().collect(Collectors.toMap(WarehouseAdjustmentPlan::getPlanId, Function.identity(), (o1, o2) -> o1));

        // 找到哪些是新增的、哪些是修改的、哪些是删除的
        List<WarehouseAdjustmentPlan> addList = findAddList(newestConfMap,historyConfMap);
        List<WarehouseAdjustmentPlan> modifyList = findModifyList(newestConfMap,historyConfMap);
        List<WarehouseAdjustmentPlan> delList = findDelList(newestConfMap,historyConfMap);

        // 新增任务
        for(WarehouseAdjustmentPlan tmp : addList){
            addTask(tmp.getPlanId(),tmp.getCronExpression());
        }

        // 修改任务
        for(WarehouseAdjustmentPlan tmp : modifyList){
            modifyTask(tmp.getPlanId(),tmp.getCronExpression());
        }

        // 删除任务
        for(WarehouseAdjustmentPlan tmp : delList){
            stopTask(tmp.getPlanId());
        }

        // 将本次查询的列表做历史列表
        this.historyConfList = newestConfList;
    }
    
    /**
	 *  找到新增的用户配置
     */
    private List<WarehouseAdjustmentPlan> findAddList(Map<String, WarehouseAdjustmentPlan> newestConfMap, Map<String, WarehouseAdjustmentPlan> historyConfMap) {
        List<WarehouseAdjustmentPlan> result = new ArrayList<>();
        for(Map.Entry<String, WarehouseAdjustmentPlan> n : newestConfMap.entrySet()){
            // 只在新map中存在 即为新增
            if(! historyConfMap.containsKey(n.getKey())){
                result.add(n.getValue());
            }
        }
        return result;
    }

    /**
	 *  找到修改的用户配置
     */
    private List<WarehouseAdjustmentPlan> findModifyList(Map<String, WarehouseAdjustmentPlan> newestConfMap, Map<String, WarehouseAdjustmentPlan> historyConfMap) {
        List<WarehouseAdjustmentPlan> result = new ArrayList<>();
        for(Map.Entry<String, WarehouseAdjustmentPlan> n : newestConfMap.entrySet()){
            // 新老map同时存在 并且md5值不一样 即为修改
            if(historyConfMap.containsKey(n.getKey())){
                String newMd5 = CommUtil.getMD5(JSON.toJSONString(n.getValue()));
                String oldMd5 = CommUtil.getMD5(JSON.toJSONString(historyConfMap.get(n.getKey())));
                if(!newMd5.equals(oldMd5)){
                    result.add(n.getValue());
                }
            }
        }
        return result;
    }

    /**
	 *  找到删除的用户配置
     */
    private List<WarehouseAdjustmentPlan> findDelList(Map<String, WarehouseAdjustmentPlan> newestConfMap, Map<String, WarehouseAdjustmentPlan> historyConfMap) {
        List<WarehouseAdjustmentPlan> result = new ArrayList<>();
        for(Map.Entry<String, WarehouseAdjustmentPlan> h : historyConfMap.entrySet()){
            // 只在老的map中存在 即为删除
            if(! newestConfMap.containsKey(h.getKey())){
                result.add(h.getValue());
            }
        }
        return result;
    }

    /**
     * 添加任务
     * @param taskId
     * @param cronExp
     */
    public void addTask(String taskId, String cronExp){
        if(existedTask.containsKey(taskId)){
            log.info("任务添加失败,重复。{}", taskId);
            return;
        }
        cronExp = CommUtil.corn7To6(cronExp);
        try {
            // 执行的具体内容
            Runnable task = ()->{
                PlanningCommitReq req = new PlanningCommitReq();
                req.setPlanId(taskId);
                req.setUserId("sys");
                req.setUserName("sys");
                // 生成任务
                warehouseAdjustmentService.commit(req);
                log.info("计划提交成功。planId:{}",taskId);
            };

            // 组成具体任务
            CronTask cronTask = new CronTask(task,cronExp);

            ScheduledTask scheduledTask = taskRegistrar.scheduleCronTask(cronTask);

            // 保存任务信息
            existedTask.put(taskId,scheduledTask);

            log.info("任务添加成功。{}", taskId);

        }catch (Exception e){
            log.info("任务添加失败。{}", taskId, e);
        }

    }

    /**
     * 修改任务
     * @param taskId
     * @param cronExp
     */
    public void modifyTask(String taskId,String cronExp){
        if(! existedTask.containsKey(taskId)){
            log.info("任务修改失败,不存在。{}", taskId);
            return;
        }

        cronExp = CommUtil.corn7To6(cronExp);

        try {
            ScheduledTask currTask = existedTask.get(taskId);
            Runnable runnable = currTask.getTask().getRunnable();
            // 停止currTask任务
            currTask.cancel();
            // 重新添加,并修改触发时间
            ScheduledTask newTask = taskRegistrar.scheduleCronTask(new CronTask(runnable, cronExp));
            // 保存修改后的任务信息
            existedTask.put(taskId,newTask);
            log.info("任务修改成功。{}", taskId);
        }catch (Exception e){
            log.info("任务修改失败。{}", taskId, e);
        }
    }

    /**
     * 停止任务
     * @param taskId
     */
    public void stopTask(String taskId){
        if(! existedTask.containsKey(taskId)){
            log.info("任务删除失败,不存在。{}", taskId);
            return;
        }

        try{
            ScheduledTask currTask = existedTask.get(taskId);
            // 停止currTask任务
            currTask.cancel();
            // 删除任务信息
            existedTask.remove(taskId);

            log.info("任务删除成功。{}", taskId);
        }catch (Exception e){
            log.info("任务删除失败。{}", taskId, e);
        }

    }
}
相关推荐
Lojarro2 小时前
【Spring】Spring框架之-AOP
java·mysql·spring
zjw_rp3 小时前
Spring-AOP
java·后端·spring·spring-aop
撒呼呼6 小时前
# 起步专用 - 哔哩哔哩全模块超还原设计!(内含接口文档、数据库设计)
数据库·spring boot·spring·mvc·springboot
天使day6 小时前
SpringMVC
java·spring·java-ee
壹佰大多8 小时前
【spring-cloud-gateway总结】
java·spring·gateway
CodeChampion8 小时前
60.基于SSM的个人网站的设计与实现(项目 + 论文)
java·vue.js·mysql·spring·elementui·node.js·mybatis
秋意钟8 小时前
Spring框架处理时间类型格式
java·后端·spring
科马12 小时前
【Redis】缓存
数据库·redis·spring·缓存
cloud___fly12 小时前
Spring AOP入门
java·后端·spring
zxguan14 小时前
Springboot 学习 之 logback-spring.xml 日志压缩 .tmp 临时文件问题
spring boot·学习·spring