手写轻量级分布式动态定时任务框架

0、背景
  几乎每个项目都会有定时任务的场景,目前的一般想法都是借助第三方框架,例如quartz、xxl-job。那如果一些原因,无法使用这些框架,而又想快速实现这样的场景,如何处理呢?本文提供了一种实现方式,仅通过springboot+mysql即可实现相关的需求场景。

1、分布式动态定时任务的难点

  • 难点1:如何选择分布式定时任务实现框架
  • 难点2:如何让多个分布式实例都能进行相应配置的变更当动态变更定时任务的配置(删除、新增、修改cron表达式)时
  • 难点3:如何防止多个实例多次执行同一个任务
  • 难点4:如何控制分布式任务每个实例的执行比例
  • 难点5:如何去监控定时任务的执行状态
  • 难点6:如何让用户使用起来更加便捷

2、如何去克服这些难点
难点1:如何选择分布式定时任务实现框架
动态定时任务管理可以参考这篇博文:springboot实现定时任务,采用CronTaskRegistrar去管理线程任务的动态增加、变更和删除(其中变更可以理解为先删除后新增)

难点2:如何让多个分布式实例都能进行相应配置的变更当动态变更定时任务的配置(删除、新增、修改cron表达式)时
正常情况下,通过接口修改cron的参数,只能会在其中1个实例中生效,而没办法通知到各个实例上面,这个时候需要实例之间通信才行,在分布式实例通信上面其实有多个选择:

  • 通过配置中心,每次任务调度修改的时候修改配置中心的参数,然后广播到各个实例上
  • 通过消息中间件的事件广播模式,让各个实例去订阅和消费,例如通过MQ、KAFKA或者REDIS的订阅发布机制
  • 通过数据库模拟事件变更

考虑到简洁性,本文采用了第3种方案。通过一张调度任务表承载这些任务的变更消息。表设计如下:

CREATE TABLE `schedule` (
  `job_id` int NOT NULL AUTO_INCREMENT COMMENT '任务ID',
  `cron_key` varchar(50) DEFAULT NULL COMMENT '定时KEY',
  `cron_name` varchar(100) DEFAULT NULL COMMENT '定时任务名称',
  `cron_expression` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT 'cron表达式',
  `bean_name` varchar(255) DEFAULT NULL COMMENT 'bean名称',
  `method_name` varchar(255) DEFAULT NULL COMMENT '方法名称',
  `operation` varchar(20) DEFAULT NULL COMMENT '操作类型:ADD/REMOVE/MODIFY',
  `valid` char(1) DEFAULT NULL COMMENT 'Y:有效 N:无效',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '修改时间',
  PRIMARY KEY (`job_id`)
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='调度作业'

如果此时新增1个任务变更的时候,则会在这张表里面去新增一条记录,写明其中变更的操作类型,例如ADD/REMOVE/MODIFY。每一个实例写一个固定时长定时任务,按照jobId去轮询看是否有消息的变更,从而将变更交接给CronTaskRegistrar去处理。这样就可以实现增量的变更,而不需要每次都扫描全表。

难点3: 如何防止多个实例多次执行同一个任务
定时任务需要按照幂等要求去设计,同时为了防止并行执行的问题,可以采用分布式锁,分布式常见的做法可以采用zk、redis或者mysql。本文考虑到简单采用了mysql完成了通用的分布式锁框架。方法执行的核心代码如下:

package org.spring.springboot.entity;

import lombok.Data;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.spring.springboot.util.SpringContextUtils;
import org.spring.springboot.mapper.ScheduleInstanceMapper;
import org.spring.springboot.util.MLock.MLock;
import org.springframework.util.ReflectionUtils;

import java.lang.reflect.Method;
import java.util.Objects;

@Data
public class SchedulingRunnable implements Runnable {

    private static final Logger logger = LoggerFactory.getLogger(SchedulingRunnable.class);

    private String beanName;

    private String methodName;

    private String cronKey;

    private Integer jobId;

    private ScheduleInstanceMapper mapper;

    public SchedulingRunnable(String beanName, String methodName, String cronKey, Integer jobId) {
        this.beanName = beanName;
        this.methodName = methodName;
        this.cronKey = cronKey;
        this.jobId = jobId;
    }


    @Override
    public void run() {
        long startTime = System.currentTimeMillis();
        MLock mLock = new MLock(cronKey);
        boolean lockResult = mLock.tryLock();
        if(!lockResult){
            System.out.println("未能获取到锁");
            return;
        }
        String executeStatus = "SUCCESS";
        String errorMessage = null;
        try {
            Object target = SpringContextUtils.getBean(beanName);
            Method method = target.getClass().getDeclaredMethod(methodName);
            ReflectionUtils.makeAccessible(method);
            method.invoke(target);
        } catch (Exception ex) {
            executeStatus = "FAIL";
            int length = Math.min(ex.getCause().toString().length(),1000);
            errorMessage = ex.getCause().toString().substring(0,length);
        } finally {
            Integer times = Math.toIntExact(System.currentTimeMillis() - startTime);
            ScheduleInstance scheduleInstance = ScheduleInstance.builder()
                    .jobId(jobId)
                    .cronKey(cronKey)
                    .beanName(beanName)
                    .methodName(methodName)
                    .executeStatus(executeStatus)
                    .errorMessage(errorMessage)
                    .executeTimes(times)
                    .build();
            mapper.insertOne(scheduleInstance);
            mLock.unlock();
        }
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        SchedulingRunnable that = (SchedulingRunnable) o;
        return this.cronKey.equals(that.cronKey);
    }

    @Override
    public int hashCode() {
        return Objects.hash(cronKey);
    }
}

难点4:如何控制分布式任务每个实例的执行比例
暂未解决

难点5:如何去监控定时任务的执行状态
设计一张表,专门用来记录执行状态

CREATE TABLE `schedule_instance` (
  `inc_id` int NOT NULL AUTO_INCREMENT COMMENT '实例ID',
  `job_id` int DEFAULT NULL COMMENT '任务ID',
  `cron_key` varchar(50) DEFAULT NULL COMMENT '定时key',
  `bean_name` varchar(255) DEFAULT NULL COMMENT 'bean名称',
  `method_name` varchar(255) DEFAULT NULL COMMENT '方法名称',
  `execute_status` varchar(10) DEFAULT NULL COMMENT '执行状态:SUCCESS、FAIL',
  `error_message` varchar(2000) DEFAULT NULL COMMENT '错误信息',
  `execute_times` int DEFAULT NULL COMMENT '执行时长',
  `update_time` datetime DEFAULT NULL COMMENT '修改时间',
  PRIMARY KEY (`inc_id`)
) ENGINE=InnoDB AUTO_INCREMENT=259 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='调度作业实例'

这样每次去运行线程的时候记录执行情况以及错误信息

难点6:如何让用户使用起来更加便捷
通过自定义注解的方式,当应用启动的时候拦截这些注解,然后去数据库里面初始化这些调度任务。具体代码:
使用方法:

package org.spring.springboot.service;

import org.spring.springboot.config.ScheduleCron;
import org.springframework.stereotype.Service;

@Service
public class TestService {

    @ScheduleCron(scheduleKey = "cron1Test", cronName = "测试一下", cronExpression = "*/10 * * * * ?")
    public void testService() {
        System.out.println(1);
    }


    @ScheduleCron(scheduleKey = "cron2Test", cronExpression = "*/59 * * * * ?")
    public void testService2() {
        System.out.println(3);
    }

    @ScheduleCron(scheduleKey = "errorTest", cronExpression = "*/15 * * * * ?")
    public void errorTest() {
        int a = 1 / 0;
    }
}

自定义注解:

package org.spring.springboot.config;

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

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ScheduleCron {

    /**
     * 调度任务key
     */
    String scheduleKey() default "";

    /**
     * 定时任务表达式
     */
    String cronName() default "";

    /**
     * 定时任务表达式
     */
    String cronExpression();

}

启动的时候拦截注解,并且存储到scheduleList列表中

package org.spring.springboot.config;

import org.spring.springboot.entity.Schedule;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;

@Component
public class MyListenerProcessor implements BeanPostProcessor {

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        return bean;
    }

    public static List<Schedule> scheduleList = new ArrayList<>(100); //保存国籍信息

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        Method[] methods = ReflectionUtils.getAllDeclaredMethods(bean.getClass());
        if (methods != null) {
            for (Method method : methods) {
                ScheduleCron scheduleCron = AnnotationUtils.findAnnotation(method, ScheduleCron.class);
                if (null != scheduleCron) {
                    Schedule setting = Schedule.builder()
                            .cronKey(scheduleCron.scheduleKey())
                            .cronName(scheduleCron.cronName())
                            .cronExpression(scheduleCron.cronExpression())
                            .beanName(beanName)
                            .methodName(method.getName())
                            .valid("Y")
                            .operation("ADD")
                            .build();
                    scheduleList.add(setting);
                }
            }
        }
        return bean;
    }
}

将scheduleList的内容列表进行库里面更新:

package org.spring.springboot.config;

import lombok.extern.slf4j.Slf4j;
import org.spring.springboot.util.SpringContextUtils;
import org.spring.springboot.entity.Schedule;
import org.spring.springboot.entity.SchedulingRunnable;
import org.spring.springboot.mapper.ScheduleMapper;
import org.spring.springboot.mapper.ScheduleInstanceMapper;
import org.spring.springboot.service.CronTaskRegistrar;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;

import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Service
@Slf4j
public class SysJobRunner implements CommandLineRunner {

    @Autowired
    private CronTaskRegistrar cronTaskRegistrar;

    @Autowired
    private ScheduleMapper jobMapper;

    @Override
    public void run(String... args) {
        List<Schedule> annotationJobList = MyListenerProcessor.scheduleList;
        List<Schedule> dbJobList = jobMapper.selectAll();

        Set<String> annotationCronKeys = annotationJobList.stream().map(Schedule::getCronKey).collect(Collectors.toSet());
        Set<String> dbCronKeys = dbJobList.stream().map(Schedule::getCronKey).collect(Collectors.toSet());

        ScheduleInstanceMapper scheduleInstanceMapper = SpringContextUtils.getBean(ScheduleInstanceMapper.class);

        // Step1:程序识别有,但是数据库没有的,得新增
        for (Schedule setting : annotationJobList) {
            if (!dbCronKeys.contains(setting.getCronKey())) {
                jobMapper.insertOne(setting);
            }
        }
        // Step2:数据库识别有,但是程序识别没有的,得删除
        for (Schedule setting : dbJobList) {
            if (!annotationCronKeys.contains(setting.getCronKey())) {
                jobMapper.delete(setting.getJobId());
            }
        }

        // 初始加载数据库里状态为正常的定时任务
        List<Schedule> jobList = jobMapper.selectValid();
        if (!CollectionUtils.isEmpty(jobList)) {
            for (Schedule job : jobList) {
                SchedulingRunnable task = new SchedulingRunnable(job.getBeanName()
                        , job.getMethodName()
                        , job.getCronKey()
                        , job.getJobId());
                task.setMapper(scheduleInstanceMapper);
                cronTaskRegistrar.addCronTask(task, job.getCronExpression());
            }
        }
    }
}

完整代码见:代码

3、总结
纸上得来终觉浅,绝知此事要躬行。很多时候只有去实践,才能更好的去感悟。这个过程中困难很多,阻碍很多,没有坚定的意志,很容易去放弃。而在这个时候,正是我们能力提升的时候。引用教员的一句话:

      我们的同志在困难的时候,要看到成绩,要看到光明,要提高我们的勇气!
      奋斗不止,止于至善,做一个专业的软件工程师!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值