Bootstrap

springboot 极简微服务之分布式定时任务 Scheduled 与 SchedulerLock, 配置中心动态cron定时任务

最近有个小项目, 生产是要双活的, 硬件资源不足,整套微服务满足不了,因此阉割了只用 nacos 和 springboot ,在定时任务上要满足双活, 不能并发, 因此技术选型上就用了 Scheduled 与 SchedulerLock ;

先上执行结果:

2022-10-15 11:58:00.058  INFO 35600 --- [taskScheduler-2] c.p.c.c.q.service.impl.TaskServiceImpl   :  =============================== 我执行了 createFile ===============================
2022-10-15 11:58:00.058  INFO 35600 --- [taskScheduler-3] c.p.c.c.q.service.impl.TaskServiceImpl   :  =============================== 我执行了 getData ===============================
2022-10-15 11:58:30.059  INFO 35600 --- [taskScheduler-2] c.p.c.c.q.t.DataWarehouseScheduledUtil   : I'm  sleeped 30000ms end;
2022-10-15 11:58:45.059  INFO 35600 --- [taskScheduler-3] c.p.c.c.q.t.DataWarehouseScheduledUtil   : I'm  sleeped 45000ms end;

前提条件, SchedulerLock 的依赖jar版本号

<!-- 分布式锁依赖 -->
        <dependency>
            <groupId>net.javacrumbs.shedlock</groupId>
            <artifactId>shedlock-spring</artifactId>
            <version>3.0.0</version>
        </dependency>
        <dependency>
            <groupId>net.javacrumbs.shedlock</groupId>
            <artifactId>shedlock-provider-jdbc-template</artifactId>
            <version>3.0.0</version>
        </dependency>

需新建的数据库表

CREATE TABLE shedlock(name VARCHAR(64) NOT NULL, lock_until TIMESTAMP(3) NOT NULL,
    locked_at TIMESTAMP(3) NOT NULL, locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name));

COMMENT ON TABLE shedlock IS '数据库锁';
COMMENT ON COLUMN shedlock.name IS '锁名称';
COMMENT ON COLUMN shedlock.lock_until IS '释放锁时间';
COMMENT ON COLUMN shedlock.locked_at IS '获取锁时间';
COMMENT ON COLUMN shedlock.locked_by IS '锁提供者';

我这里是用的oracle的,当然其他数据库的也可以,redis等都可以, 参考链接: ShedLock

启动类

@SpringBootApplication(scanBasePackages = Constants.SCAN_BASE_PACKAGES)
@EnableFeignClients
public class QueryApplication {

    public static void main(String[] args) {
        SpringApplication.run(QueryApplication.class, args);
    }
}

分布式定时任务数据库锁及多线程配置

import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;

import javax.sql.DataSource;

import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;

import javax.sql.DataSource;

/**
 * @author zengyujie
 * @create 2022-10-15 10:13
 * @Description
 */
@Configuration
@EnableScheduling
//defaultLockAtMostFor 指定在执行节点结束时应保留锁的默认时间使用ISO8601 Duration格式
//作用就是在被加锁的节点挂了时,无法释放锁,造成其他节点无法进行下一任务
//这里默认30s
@EnableSchedulerLock(defaultLockAtMostFor = "PT30S")
public class ScheduledLockConfig implements SchedulingConfigurer {
    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(dataSource);
    }

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.setScheduler(taskScheduler());
    }

    @Bean
    public TaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
        taskScheduler.setPoolSize(64);
        return taskScheduler;
    }
}


定时任务组件:


import lombok.extern.slf4j.Slf4j;
import net.javacrumbs.shedlock.core.SchedulerLock;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

/**
 * @author zengyujie
 * @create 2022-10-14 17:40
 * @Description
 */
@Component
@Slf4j
public class DataWarehouseScheduledUtil {
    @Resource
    TaskService taskService;

    /**
     * cron 表示2分钟执行一次
     * nacos配置中心方案 :@Scheduled(cron = "${sku.cron}")
     */
    @Scheduled(cron = "0 0/2 * * * *")
    @SchedulerLock(name = "executePushData", 
            lockAtMostFor = 1000*60*10, 
            lockAtLeastFor = 1000*60*10)
    public void executePushData(){
        taskService.createFile();
        try {
            Thread.sleep(30000);
            log.info("I'm  sleeped 30000ms end;");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

	/**
	* 1000*60*10
	* 表示间隔10分钟
	*/
    @Scheduled(cron = "0 0/2 * * * *")  //这里建议配置在nacos的配置中心,统一管理 
    @SchedulerLock(name = "executeGetData",
            lockAtMostFor = 1000*60*10,  //这里建议配置在nacos的配置中心,统一管理
            lockAtLeastFor = 1000*60*10)
    public void executeGetData(){
        taskService.getData();
        try {
            Thread.sleep(45000);
            log.info("I'm  sleeped 45000ms end;");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

做到这儿, 基本功能就已经实现了,但是如果我们想在nacos配置中心做定时任务的暂停处理呢? 修改定时任务的执行间隔呢?

配置中心增加配置 testScheduled.yaml

cron:
    executeGetData: 0 0/2 * * * *
    classPrefix: com.primeton.ckpt.namelist.task.TaskScheduledUtil.
    enabled: false

增加配置类


import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * aop控制定时任务启停要用到它
 * @author zengyujie
 * @create 2023-06-14 20:37
 * @Description
 */
@ConfigurationProperties("cron")
@Component
@Data
public class CronConfig {
    //private String classPrefix;
    private String enabled;
    
    //它可以不用定义在配置类, 没必要
    //private String executeGetData;
}

增加自定义注解

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * @author zengyujie
 * @create 2023-04-03 16:02
 * @Description
 */
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface TaskScheduledAnnotation {
    String methodName() default "";
}

我们稍微改造一下定时任务

	/**
	* 1000*60*10
	* 表示间隔10分钟
	*/
    @Scheduled(cron = "${cron.executeGetData}")  //使用配置中心配置 
    @SchedulerLock(name = "executeGetData",
            lockAtMostFor = 1000*60*10,  //这里建议配置在nacos的配置中心,统一管理
            lockAtLeastFor = 1000*60*10)
    @TaskScheduledAnnotation
    public void executeGetData(){
        taskService.getData();
        try {
            Thread.sleep(45000);
            log.info("I'm  sleeped 45000ms end;");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

aop


import com.primeton.ckpt.ca.police.annotation.TaskScheduledAnnotation;
import com.primeton.ckpt.ca.police.config.CronConfig;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

/**
 * 这儿如果用 @Value 获取 cron.enabled的值的话, 并且使用@RefreshScope刷新@Value 的值的话, 会看到日志打两次...
 * @author zengyujie
 * @create 2023-04-03 16:03
 * @Description
 */
@Aspect
@Component
@Slf4j
public class ScheduledTaskProxy {

    @Resource
    CronConfig cronConfig;

    @Around("@annotation(taskScheduledAnnotation)")
    public void aroudTaskScheduledAnnotation(ProceedingJoinPoint point, TaskScheduledAnnotation taskScheduledAnnotation) throws Throwable{
//        log.info("cron.enabled = {}",cronConfig.getEnabled());
        long start  = System.currentTimeMillis();

        if ("true".equals(cronConfig.getEnabled())) {
            log.info("调用定时任务方法:{}" , point.getSignature().getName());
            //调用目标方法
            point.proceed();
            long end = System.currentTimeMillis();
            log.info("调用定时任务方法 {} 结束: 耗时:{} ms", point.getSignature().getName(),end-start);
        }
    }
}

侦听配置文件刷新, 修改定时任务执行间隔


import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.environment.EnvironmentChangeEvent;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationListener;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.annotation.ScheduledAnnotationBeanPostProcessor;
import org.springframework.scheduling.config.CronTask;
import org.springframework.scheduling.config.ScheduledTask;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.lang.reflect.Field;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;

/**
 * 动态定时器监听
 * @author zengyujie
 * @create 2023-04-06 09:00
 * @Description
 */
@Component
@Slf4j
public class ScheduledTaskEnviromentChangeListener implements ApplicationListener<EnvironmentChangeEvent> {

    @Resource
    ApplicationContext applicationContext;

    @Value("${cron.classPrefix}")
    private String classPrefix ;

    private ConcurrentHashMap<String, ScheduledFuture<?>> scheduledFutureConcurrentHashMap = new ConcurrentHashMap<>();

    @Resource
    private ConfigurableEnvironment configurableEnvironment;

    @Override
    public void onApplicationEvent(EnvironmentChangeEvent event) {
        for (String key: event.getKeys()){
                log.info("key = {} 更新了", key );
            if (key.startsWith("cron.")){

                if ("cron.classPrefix".equals(key)){
                    classPrefix = configurableEnvironment.getProperty(key);
                }else {
                    try {
                        ScheduledAnnotationBeanPostProcessor scheduledAnnotationBeanPostProcessor = applicationContext.getBean(ScheduledAnnotationBeanPostProcessor.class);
                        Field registrar = scheduledAnnotationBeanPostProcessor.getClass().getDeclaredField("registrar");
                        registrar.setAccessible(true);

                        ScheduledTaskRegistrar taskRegistrar = (ScheduledTaskRegistrar) registrar.get(scheduledAnnotationBeanPostProcessor);
                        TaskScheduler scheduler = taskRegistrar.getScheduler();
                        Set<ScheduledTask> scheduledTaskSet = scheduledAnnotationBeanPostProcessor.getScheduledTasks();

                        for (ScheduledTask scheduledTask:scheduledTaskSet){
                            log.info(scheduledTask.getTask().getRunnable().toString());

                            String methodName = key.split("\\.")[1];
                            String methodFullName = classPrefix+methodName;

                            if (scheduledTask.getTask().getRunnable().toString().equals(methodFullName)){
                                log.info("取消 {} 的执行......", methodFullName);
                                scheduledTask.cancel();

                                CronTask cronTask = new CronTask(scheduledTask.getTask().getRunnable(),configurableEnvironment.getProperty(key));

                                if (scheduledFutureConcurrentHashMap.containsKey(methodFullName)){
                                    ScheduledFuture<?> scheduledFuture = scheduledFutureConcurrentHashMap.get(methodFullName);
                                    scheduledFuture.cancel(true);
                                }

                                ScheduledFuture<?> scheduledFuture = scheduler.schedule(cronTask.getRunnable(),cronTask.getTrigger());
                                log.info("重置 {} 的执行 cron 为: {}", methodFullName, configurableEnvironment.getProperty(key));

                                scheduledFutureConcurrentHashMap.put(methodFullName,scheduledFuture);

                                //匹配到了就不再更新了。 退出 for (ScheduledTask scheduledTask:scheduledTaskSet){
                                break;
                            }

                        }
                    }catch (Exception e){
                        log.error("{}",e.getMessage());
                    }
                }
            }

        }
    }
}

最后的springboot使用nacos配置中心的我就不写了,这个参考nacos官方网站好了。

20230615更新: 添加配置中心控制定时任务间隔、 添加配置中心控制整体的定时任务是否执行。 对于为什么不做针对每个定时任务的启停控制, 是没这个需求。 单个的话可以参考其他的数据库动态定时任务的文章。

;