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);
        }

    }
}
相关推荐
小小虫码1 小时前
项目中用的网关Gateway及SpringCloud
spring·spring cloud·gateway
带刺的坐椅6 小时前
无耳科技 Solon v3.0.7 发布(2025农历新年版)
java·spring·mvc·solon·aop
精通HelloWorld!9 小时前
使用HttpClient和HttpRequest发送HTTP请求
java·spring boot·网络协议·spring·http
LUCIAZZZ10 小时前
基于Docker以KRaft模式快速部署Kafka
java·运维·spring·docker·容器·kafka
拾忆,想起11 小时前
如何选择Spring AOP的动态代理?JDK与CGLIB的适用场景
spring boot·后端·spring·spring cloud·微服务
鱼骨不是鱼翅12 小时前
Spring Web MVC基础第一篇
前端·spring·mvc
hong_zc14 小时前
Spring MVC (三) —— 实战演练
java·spring·mvc
Future_yzx15 小时前
Spring AOP 入门教程:基础概念与实现
java·开发语言·spring
安清h15 小时前
【基于SprintBoot+Mybatis+Mysql】电脑商城项目之用户注册
数据库·后端·mysql·spring·mybatis
yang_shengy1 天前
【JavaEE】Spring(5):Mybatis(上)
spring·java-ee·mybatis