The premise

Start-up teams, no matter what solution they choose, prioritize cost savings. Mature candidates for distributed scheduling frameworks include XXL-Job, Easy Scheduler, Light Task Scheduler, and Elastic JOB, which have been used in production environments before. However, to build highly available distributed scheduling platforms, these frameworks (whether decentralized or not) require additional server resources to deploy central scheduling management service instances, and sometimes even rely on middleware such as Zookeeper. After spending some time looking at Quartz’s source code to analyze its threading model, it occurred to me that it could implement a service cluster with a single trigger that only one node (the node that successfully locks) could execute, based on MySQL, using a less-recommended X locking scheme. It can only rely on the existing MySQL instance resources to achieve distributed scheduling task management. Generally speaking, business applications with relational data retention requirements will have their own MySQL instances, which can introduce a distributed scheduling management module at almost no cost. After hammering out the initial plan on an overtime Saturday afternoon, the wheel was built over the course of a few hours. Here’s how it worked:

The project design

Let’s start with all the dependencies used:

  • Uikit: a lightweight front-end of choiceUIThe framework is mainly due to its light weight and relatively complete documentation and components.
  • JQuery: choosejsFrames, for one reason: simplicity.
  • Freemarker: template engine, subjectively comparedJspandThymeleafIt works.
  • Quartz: Industrial grade scheduler.

Project dependencies are as follows:

<dependencies>
    <dependency>
        <groupId>org.quartz-scheduler</groupId>
        <artifactId>quartz</artifactId>
        <exclusions>
            <exclusion>
                <groupId>com.zaxxer</groupId>
                <artifactId>HikariCP-java7</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-context-support</artifactId>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-freemarker</artifactId>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>com.zaxxer</groupId>
        <artifactId>HikariCP</artifactId>
        <scope>provided</scope>
    </dependency>
</dependencies>
Copy the code

Uikit and JQuery can directly use off-the-shelf CDN:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/uikit.min.css"/>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/uikit-icons.min.js"></script>
<script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>
Copy the code

Table design

After the introduction of Quartz depend on its org. Quartz. Impl. Jdbcjobstore package can be seen under a series of DDL, If MySQL is used, tables_mysql_innodb. SQL and tables_mysql_innodb.

Scheduled task information in applications should be managed separately to provide unified query and change apis. It’s important to note that Quartz’s built-in tables use a lot of foreign keys, so try to use the Quartz API to add, delete, and modify the contents of the built-in tables. Do not do this manually, as it may cause unexpected failures.

Two new tables are introduced, schedule_task and schedule_task_parameter:

CREATE TABLE `schedule_task`
(
    `id`               BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT 'primary key'.`creator`          VARCHAR(16)     NOT NULL DEFAULT 'admin' COMMENT 'Founder'.`editor`           VARCHAR(16)     NOT NULL DEFAULT 'admin' COMMENT 'Modifier'.`create_time`      DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Creation time'.`edit_time`        DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Modification time'.`version`          BIGINT          NOT NULL DEFAULT 1 COMMENT 'Version number'.`deleted`          TINYINT         NOT NULL DEFAULT 0 COMMENT 'Soft delete flag'.`task_id`          VARCHAR(64)     NOT NULL COMMENT 'Task identification'.`task_class`       VARCHAR(256)    NOT NULL COMMENT 'Task class'.`task_type`        VARCHAR(16)     NOT NULL COMMENT 'Task type,CRON,SIMPLE'.`task_group`       VARCHAR(32)     NOT NULL DEFAULT 'DEFAULT' COMMENT 'Task Groups'.`task_expression`  VARCHAR(256)    NOT NULL COMMENT 'Task expression'.`task_description` VARCHAR(256) COMMENT 'Task Description'.`task_status`      TINYINT         NOT NULL DEFAULT 0 COMMENT 'Task status'.UNIQUE uniq_task_class_task_group (`task_class`.`task_group`),
    UNIQUE uniq_task_id (`task_id`))COMMENT 'Scheduling task';

CREATE TABLE `schedule_task_parameter`
(
    `id`              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT 'primary key'.`task_id`         VARCHAR(64)     NOT NULL COMMENT 'Task identification'.`parameter_value` VARCHAR(1024)   NOT NULL COMMENT 'Parameter value'.UNIQUE uniq_task_id (`task_id`))COMMENT 'Scheduling Task Parameters';
Copy the code

Parameters are stored in JSON strings. Therefore, one scheduling task entity corresponds to 0 or 1 scheduling task parameter entity. There is no problem considering multiple applications use the same data source, actually this problem should be considered based on different org. Quartz. JobStore. TablePrefix isolation, namely different application if library, or quartz use different table prefix for each application, Or separate all scheduling tasks to the same application.

How Quartz works

Quartz actually schedules Trigger Trigger when designing scheduling model. Generally, when scheduling corresponding Job, Trigger and the scheduled task instance need to be bound, and then Trigger will be fired when Trigger time is reached. It then calls back the execute() method of the Job instance associated with the trigger. Triggers and Job instances have a many-to-many relationship. It looks like this in a nutshell:

To achieve this many-to-many relationship, Quartz defines JobKey and TriggerKey as unique identifiers for Job (actually JobDetail) and Trigger, respectively.

TriggerKey -> [name, group]
JobKey -> [name, group]
Copy the code

To reduce maintenance costs, we force the many-to-many binding to be one-to-one and assimilate TriggerKey and JobKey as follows:

JobKey,TriggerKey -> [jobClassName, ${spring.application.name} || applicationName]
Copy the code

In fact, most of the scheduling-related work is delegated to org.Quartz.Scheduler, as an example:

public interface Scheduler {... Omit extraneous code......// Add scheduling tasks - including task content and triggers
    void scheduleJob(JobDetail jobDetail, Set<? extends Trigger> triggersForJob, boolean replace) throws SchedulerException;

    // Remove the trigger
    boolean unscheduleJob(TriggerKey triggerKey) throws SchedulerException;
    
    // Remove task content
    boolean deleteJob(JobKey jobKey) throws SchedulerException; . Omit extraneous code...... }Copy the code

Schedule_task manages the scheduled tasks of the service, hands them over to Quartz through the API provided by org.Quartz.Scheduler, and adds some extensions. This module has been packaged into a lightweight framework named Quartz-Web-UI-Kit (KIT).

Kit core logic analysis

All of kit’s core functions are packaged in the Module Quartz – Web-UI-Kit-core. The main functions include:

The web user interface (WebUI) is written by Freemarker, JQuery, and Uikit, and consists of three pages:

Templates - common/script.ftl Common scripts - task-add. FTL Add a new task page - task-edit. FTL Edit task page - task-list. FTL Task listCopy the code

The core method for scheduling task management is QuartzWebUiKitService#refreshScheduleTask() :


@Autowired
private Scheduler scheduler;

public void refreshScheduleTask(ScheduleTask task, Trigger oldTrigger, TriggerKey triggerKey, Trigger newTrigger) throws Exception {
    JobDataMap jobDataMap = prepareJobDataMap(task);
    JobDetail jobDetail =
            JobBuilder.newJob((Class<? extends Job>) Class.forName(task.getTaskClass()))
                    .withIdentity(task.getTaskClass(), task.getTaskGroup())
                    .usingJobData(jobDataMap)
                    .build();
    // Always overwrite
    if (ScheduleTaskStatus.ONLINE == ScheduleTaskStatus.fromType(task.getTaskStatus())) {
        scheduler.scheduleJob(jobDetail, Collections.singleton(newTrigger), Boolean.TRUE);
    } else {
        if (null! = oldTrigger) { scheduler.unscheduleJob(triggerKey); }}}private JobDataMap prepareJobDataMap(ScheduleTask task) {
    JobDataMap jobDataMap = new JobDataMap();
    jobDataMap.put("scheduleTask", JsonUtils.X.format(task));
    ScheduleTaskParameter taskParameter = scheduleTaskParameterDao.selectByTaskId(task.getTaskId());
    if (null! = taskParameter) { Map<String, Object> parameterMap = JsonUtils.X.parse(taskParameter.getParameterValue(),new TypeReference<Map<String, Object>>() {
                });
        jobDataMap.putAll(parameterMap);
    }
    return jobDataMap;
}
Copy the code

In fact, any task Trigger or change will directly cover the corresponding JobDetail and Trigger, so as to ensure that the task content and Trigger are new and the next round of scheduling will take effect.

AbstractScheduleTask Task class AbstractScheduleTask task class AbstractScheduleTask task class AbstractScheduleTask task class AbstractScheduleTask

@DisallowConcurrentExecution
public abstract class AbstractScheduleTask implements Job {

    protected Logger logger = LoggerFactory.getLogger(getClass());

    @Autowired(required = false)
    private List<ScheduleTaskExecutionPostProcessor> processors;

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        String scheduleTask = context.getMergedJobDataMap().getString("scheduleTask");
        ScheduleTask task = JsonUtils.X.parse(scheduleTask, ScheduleTask.class);
        ScheduleTaskInfo info = ScheduleTaskInfo.builder()
                .taskId(task.getTaskId())
                .taskClass(task.getTaskClass())
                .taskDescription(task.getTaskDescription())
                .taskExpression(task.getTaskExpression())
                .taskGroup(task.getTaskGroup())
                .taskType(task.getTaskType())
                .build();
        long start = System.currentTimeMillis();
        info.setStart(start);
        // Add traceId to MDC for tracing call chains
        MappedDiagnosticContextAssistant.X.processInMappedDiagnosticContext(() -> {
            try {
                if (enableLogging()) {
                    logger.info("Task [{}]-[{}]-[{}] start execution......", task.getTaskId(), task.getTaskClass(), task.getTaskDescription());
                }
                // Processor callback before execution
                processBeforeTaskExecution(info);
                // Subclass implementation of task execution logic
                executeInternal(context);
                // A successful handler callback is executed
                processAfterTaskExecution(info, ScheduleTaskExecutionStatus.SUCCESS);
            } catch (Exception e) {
                info.setThrowable(e);
                if (enableLogging()) {
                    logger.info("Task [{}]-[{}]-[{}] Execution exception", task.getTaskId(), task.getTaskClass(),
                            task.getTaskDescription(), e);
                }
                // Execute the exception handler callback
                processAfterTaskExecution(info, ScheduleTaskExecutionStatus.FAIL);
            } finally {
                long end = System.currentTimeMillis();
                long cost = end - start;
                info.setEnd(end);
                info.setCost(cost);
                if (enableLogging() && null == info.getThrowable()) {
                    logger.info("Task [{}]-[{}]-[{}] Completed, Time :{} ms......", task.getTaskId(), task.getTaskClass(),
                            task.getTaskDescription(), cost);
                }
                // Execute the completed processor callbackprocessAfterTaskCompletion(info); }}); }protected boolean enableLogging(a) {
        return true;
    }

    /** * internal execution methods - subclass implementation **@param context context
     */
    protected abstract void executeInternal(JobExecutionContext context);

    /** * Copy the task information */
    private ScheduleTaskInfo copyScheduleTaskInfo(ScheduleTaskInfo info) {
        return ScheduleTaskInfo.builder()
                .cost(info.getCost())
                .start(info.getStart())
                .end(info.getEnd())
                .throwable(info.getThrowable())
                .taskId(info.getTaskId())
                .taskClass(info.getTaskClass())
                .taskDescription(info.getTaskDescription())
                .taskExpression(info.getTaskExpression())
                .taskGroup(info.getTaskGroup())
                .taskType(info.getTaskType())
                .build();
    }
    
    // Callback before task execution
    void processBeforeTaskExecution(ScheduleTaskInfo info) {
        if (null! = processors) {for(ScheduleTaskExecutionPostProcessor processor : processors) { processor.beforeTaskExecution(copyScheduleTaskInfo(info)); }}}// Call back when the task is completed
    void processAfterTaskExecution(ScheduleTaskInfo info, ScheduleTaskExecutionStatus status) {
        if (null! = processors) {for(ScheduleTaskExecutionPostProcessor processor : processors) { processor.afterTaskExecution(copyScheduleTaskInfo(info), status); }}}// Call back when the task is done
    void processAfterTaskCompletion(ScheduleTaskInfo info) {
        if (null! = processors) {for(ScheduleTaskExecutionPostProcessor processor : processors) { processor.afterTaskCompletion(copyScheduleTaskInfo(info)); }}}}Copy the code

The target scheduling task class that needs to execute simply inherits AbstractScheduleTask to obtain these capabilities. In addition, the post processor ScheduleTaskExecutionPostProcessor reference the task scheduling in the Spring BeanPostProcessor and TransactionSynchronization design.

public interface ScheduleTaskExecutionPostProcessor {
    
    default void beforeTaskExecution(ScheduleTaskInfo info) {}default void afterTaskExecution(ScheduleTaskInfo info, ScheduleTaskExecutionStatus status) {}default void afterTaskCompletion(ScheduleTaskInfo info) {}}Copy the code

Various functions such as task warning and task execution log persistence can be accomplished through the afterprocessor. The author has achieved the built-in through ScheduleTaskExecutionPostProcessor warning function, abstracts a warning strategies interface AlarmStrategy:

public interface AlarmStrategy {

    void process(ScheduleTaskInfo scheduleTaskInfo);
}

// The default implementation is no warning policy
public class NoneAlarmStrategy implements AlarmStrategy {

    @Override
    public void process(ScheduleTaskInfo scheduleTaskInfo) {}}Copy the code

A custom alert policy can be obtained by overriding the Bean configuration of AlarmStrategy, such as:

@Slf4j
@Component
public class LoggingAlarmStrategy implements AlarmStrategy {

    @Override
    public void process(ScheduleTaskInfo scheduleTaskInfo) {
        if (null! = scheduleTaskInfo.getThrowable()) { log.error("Abnormal task execution, task content :{}", JsonUtils.X.format(scheduleTaskInfo), scheduleTaskInfo.getThrowable()); }}}Copy the code

Through the customized reality of this interface, the author printed all the early warning to the internal team of the pin group, printed the task execution time, status, time and other information, once abnormal will be timely @ everyone, so as to facilitate the timely monitoring of task health and subsequent tuning.

Using kit projects

The quartz Web-UI-Kit project structure is as follows:

Mysql5.x: mysql5.x: mysql5.x: mysql5.x: mysql5.x: mysql5.x: mysql5.x: mysql5.x: mysql5.x: mysql5.x Mysql8.x -example mysql8.xCopy the code

If just want to experience the function of the kit, then download the program directly, start the h2 – example module of the club. Throwable. H2. Example. H2App, Then visit http://localhost:8081/quartz/kit/task/list.

Based on the application of MySQL instance, here choose the current more users of mysql5.x example to briefly explain. Because the wheel has just been built and has not passed the test of time, it has not been submitted to Maven’s repository.

git clone https://github.com/zjcscut/quartz-web-ui-kit
cd quartz-web-ui-kit
mvn clean compile install
Copy the code

Introduce dependencies (just introduce Quartz – web-UI-kit-core, Moreover, Quartz web-uI-kit-core relies on spring-boot-starter-web, spring-boot-starter-web, spring-boot-starter- JDBC, and spring-boot-starter- Freemarker and HikariCP) :

<dependency>
    <groupId>club.throwable</groupId>
    <artifactId>quartz-web-ui-kit-core</artifactId>
    <version>1.0 the SNAPSHOT</version>
</dependency>
<! MySQL driver package -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.48</version>
</dependency>
Copy the code

Add a configuration implementation QuartzWebUiKitConfiguration:

@Configuration
public class QuartzWebUiKitConfiguration implements EnvironmentAware {

    private Environment environment;

    @Override
    public void setEnvironment(Environment environment) {
        this.environment = environment;
    }

    @Bean
    public QuartzWebUiKitPropertiesProvider quartzWebUiKitPropertiesProvider(a) {
        return () -> {
            QuartzWebUiKitProperties properties = new QuartzWebUiKitProperties();
            properties.setDriverClassName(environment.getProperty("spring.datasource.driver-class-name"));
            properties.setUrl(environment.getProperty("spring.datasource.url"));
            properties.setUsername(environment.getProperty("spring.datasource.username"));
            properties.setPassword(environment.getProperty("spring.datasource.password"));
            returnproperties; }; }}Copy the code

Here because of the quartz – web – UI – kit – the core design when considering the loading sequence, parts used ImportBeanDefinitionRegistrar hook interface, so cannot be achieved by @ the Value or the @autowired attribute injection, If you run MyBatis’s MapperScannerConfigurer, you will understand the problem. A DDL script has been compiled for the Quartz – web-UI-kit-core dependency:

scripts
  - quartz-h2.sql
  - quartz-web-ui-kit-h2-ddl.sql
  - quartz-mysql-innodb.sql
  - quartz-web-ui-kit-mysql-ddl.sql
Copy the code

Mysql > execute motion-mysql-innodb. SQL and Motion-web-UI-kit-mysql-ddl.sql on target database. A relatively standard configuration file, application.properties, looks like this:

spring.application.name=mysql-5.x-example server.port=8082 spring.datasource.driver-class-name=com.mysql.jdbc.Driver # This local is the local database that was built in advance spring.datasource.url=jdbc:mysql://localhost:3306/local?characterEncoding=utf8&useUnicode=true&useSSL=false Spring. The datasource. The username = root spring. The datasource. The password = root # freemarker configuration spring.freemarker.template-loader-path=classpath:/templates/ spring.freemarker.cache=false spring.freemarker.charset=UTF-8 spring.freemarker.check-template-location=true spring.freemarker.content-type=text/html spring.freemarker.expose-request-attributes=true spring.freemarker.expose-session-attributes=true spring.freemarker.request-context-attribute=request spring.freemarker.suffix=.ftlCopy the code

Then you need to add a class scheduling tasks, only needs to inherit club. Throwable. Quartz. Kit. Support. AbstractScheduleTask:

@Slf4j
public class CronTask extends AbstractScheduleTask {

    @Override
    protected void executeInternal(JobExecutionContext context) {
        logger.info("CronTask trigger, TriggerKey: {}", context.getTrigger().getKey().toString()); }}Copy the code

Then start the SpringBoot start class, then go to http://localhost:8082/quartz/kit/task/list:

Add a scheduled task by clicking the button on the left:

Current task expressions support two types:

  • CRONExpression: the format isCron = your cron expression, such ascron=*/20 * * * * ?.
  • Simple periodic execution expression: the format isIntervalInMilliseconds = millisecond value, such asintervalInMilliseconds=10000Is executed once in 10000 milliseconds.

Other optional parameters are:

  • repeatCount: indicates the number of tasks that are repeated periodically. The default value isInteger.MAX_VALUE.
  • startAt: Timestamp of the first execution of the task.

As for the task expression parameters, there is no consideration for very strict verification, nor does the trim of the string, and you need to enter specific expressions that conform to the convention format, such as:

cron=*/20 * * * * ?

intervalInMilliseconds=10000

intervalInMilliseconds=10000,repeatCount=10
Copy the code

Scheduling tasks also support input of user’s custom parameters, which are currently simply agreed to be JSON strings. This string is processed once by Jackson and stored in the JobDataMap of the task, which is actually persisted to the database by Quartz:

{"key":"value"}
Copy the code

This is obtained from JobExecutionContext#getMergedJobDataMap(), for example:

@Slf4j
public class SimpleTask extends AbstractScheduleTask {

    @Override
    protected void executeInternal(JobExecutionContext context) {
        JobDataMap jobDataMap = context.getMergedJobDataMap();
        String value = jobDataMap.getString("key"); }}Copy the code

other

As for Kit, the author has made two specific designs based on the scenarios of the project maintained in the team:

  1. AbstractScheduleTaskUsing the@DisallowConcurrentExecutionNote that concurrent execution of tasks is disabled, that is, in the case of multiple nodes, only one service node can schedule tasks at the same trigger time.
  2. CRONType of task is disabledMisfireStrategy, that isCRONType of tasks do not do anything if they miss the trigger timeQuartztheMisfirePolicy).

If you cannot tolerate both, do not use the kit directly in production.

summary

This paper briefly introduces the author’s creation of a lightweight distributed scheduling service wheel with the help of Quartz, which is easy to use and cost saving. Is not enough, because of considering the current team project scheduling tasks in demand of internal services are Shared services, the author didn’t spend a lot of energy to improve authentication, monitoring module, it is also from the current business scenario to consider, if too many design, can degenerate into a heavyweight scheduling framework such as Elastic – Job, That defeats the purpose of saving on deployment costs.

  • quartz-web-ui-kitprojectGithubWarehouse:Github.com/zjcscut/qua…

(C-14-D E-A-20200410 recently too busy this article hold for a long time……)

Technical official account (Throwable Digest), push the author’s original technical articles from time to time (never plagiarize or reprint) :