从优秀开源框架中来看分布式调度的实现

284 阅读5分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第2篇文章,点击查看活动详情

工作当中经常会使用到定时调度功能,不管是web应用还是大数据中用到的离线任务计算。如在web应用中如果一个应用部署了多个实例,则需要考虑到调度的一致性,就是保证任务不重复执行。今天会介绍几种在工作中接触到的开源框架是如何实现的。

Spring

在web开发中,经常会使用到定时任务。基于SpringBoot开发一个定时任务很简单

@Configuration    
@EnableScheduling
public class ScheduleTask {
   
    @Scheduled(cron = "0 0 0 ? * *")
    //外部传参 
    //@Scheduled(cron = "${task.cron}")
    private void task() {
        //todo 具体执行内容
    }
}

但是在多实例部署的情况下,上面这种方式任务会重复执行。

解决方案可以使用ShedLock:

github.com/lukas-kreca…

使用方式很简单只需要引入对应的依赖,并且创建一个表即可

<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-spring</artifactId>
    <version>4.41.0</version>
</dependency>

<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-provider-jdbc-template</artifactId>
    <version>4.41.0</version>
</dependency>


# MySQL, MariaDB,需要使用的表结构
CREATE TABLE shedlock(name VARCHAR(64) NOT NULL, 
lock_until TIMESTAMP(3) NOT NULL,
locked_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), l
ocked_by VARCHAR(255) NOT NULL, 
PRIMARY KEY (name)
);

说明: 

基于ShedLock解决上面的问题,本质上其实是多个实例依赖同一个数据库,基于数据库来实现了锁的抢占. shedlock表中的主键是name代表每个任务的名称。 

获取锁的大体逻辑就是:如果根据name查询锁数据不存在,则执行insert操作,成功的就说明获取到了锁, 可以执行任务. 如果已经存在锁记录,则走update的逻辑获取锁。 根据name和lock_until来更新。

 UPDATE shedlock SET lock_until = :lockUntil WHERE name = :lockName AND lock_until <= :now If the update succeeded (1 updated row), we have the lock. If the update failed (0 updated rows) somebody else holds the lock   

具体可以参考源码上的注释说明:
 /**
 * Lock provided by plain JDBC. It uses a table that contains lock_name and locked_until.
 * <ol>
 * <li>
 * Attempts to insert a new lock record. Since lock name is a primary key, it fails if the record already exists. As an optimization,
 * we keep in-memory track of created  lock records.
 * </li>
 * <li>
 * If the insert succeeds (1 inserted row) we have the lock.
 * </li>
 * <li>
 * If the insert failed due to duplicate key or we have skipped the insertion, we will try to update lock record using
 * UPDATE tableName SET lock_until = :lockUntil WHERE name = :lockName AND lock_until <= now()
 * </li>
 * <li>
 * If the update succeeded (1 updated row), we have the lock. If the update failed (0 updated rows) somebody else holds the lock
 * </li>
 * <li>
 * When unlocking, lock_until is set to now.
 * </li>
 * </o>

XXL-JOB

当我们不满足于spring自带的schedule,并且需要一套页面去维护我们的定时任务的时候,xxl-job是一个不错的选择,xxl-job是一个分布式任务调度平台。

github.com/xuxueli/xxl…

对于使用xxl-job,只需要在其Web页面对任务进行CRUD即可。但是前提是需要开发一个我们自己任务的执行器,在页面进行配置就可以。  具体的大家可以自行去安装部署使用一下,相信很快就可以上手使用了。

同样的问题,我们来看看在XXL-JOB中是如何避免多个调度中心重复调度相同任务的。

XXL-JOB使用了自研调度组件(早期调度组件基于Quartz),主要原因是一方面是为了精简系统降低冗余依赖,另一方面是为了提供系统的可控度与稳定性。

通过阅读源码,可以发现XXL-JOB也是基于数据库加锁来解决了上面提到的问题:

既然用到了数据库,那必然需要建表了:

CREATE TABLE `xxl_job_lock` (  
`lock_name` varchar(50) NOT NULL COMMENT '锁名称',  
PRIMARY KEY (`lock_name`)) 
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO `xxl_job_lock` ( `lock_name`) VALUES ( 'schedule_lock');
表里其实就一条记录。


 
整个调度的核心类JobScheduleHelper
 // 调度启动后,会有两个线程启动
 private Thread scheduleThread;
 private Thread ringThread;
 //时间轮的数据结构
 private volatile static Map<Integer, List<Integer>> ringData = new ConcurrentHashMap<>();

scheduleThread线程主要是用来获取最近5s中需要执行的调度任务,并将其写入到ringData的数据结构中.
ringThread则是利用时间轮的思想从ringData中获取到当前时刻需要执行的任务。最终调用JobTriggerPoolHelper.trigger
方法将任务提交到线程池中。

在scheduleThread线程中获取任务的时候利用了数据库的锁.
基于select for update 行锁将select 语句变为当前读,其他事务会被阻塞。直到当前事务提交。
preparedStatement = conn.prepareStatement(  "select * from xxl_job_lock where lock_name = 'schedule_lock' for update" );
这里的select 会对查询的记录加上行锁,当其他节点也在执行此sql查询的时候,因为当前查询语句的事物还没有结束,其他节点的查询就会进行阻塞,直到当前事物提交。
XXL-JOB就是利用了些特性来实现了自己的调度功能,感兴趣的可以看一下源码com.xxl.job.admin.core.thread.JobScheduleHelper

比如如何添加任务到时间轮:
// 1、make ring second,计算出需要运行的时刻
int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);

// 2、push time ring
pushTimeRing(ringSecond, jobInfo.getId());

// 3、fresh next
refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));


private void pushTimeRing(int ringSecond, int jobId){
       // push async ring
       List<Integer> ringItemData = ringData.get(ringSecond);
       if (ringItemData == null) {
            ringItemData = new ArrayList<Integer>();
            ringData.put(ringSecond, ringItemData);
       }
       ringItemData.add(jobId);

       logger.debug(">>>>>>>>>>> xxl-job, schedule push time-ring : " + ringSecond + " = " + Arrays.asList(ringItemData) );
}

Quartz

在XXL-JOB中提到了quartz, 虽然XXL-JOB使用了自研的组件,但是quartz是一款优秀开源的调度框架,他可以很方便的集成在Spring中,也可以单独使用。

 依赖
    <dependency>
         <groupId>org.quartz-scheduler</groupId>
         <artifactId>quartz</artifactId>
         version>${quartz.version}</version>
    </dependency>

quartz也是支持多个节点部署的,同样也是依赖数据库来实现任务不重复在多个节点重复调用。

使用数据库实现多节点的时候需要修改配置文件,指定存储为jdbc。
org.quartz.jobStore.class: org.quartz.impl.jdbcjobstore.JobStoreTX

关于Quartz这里不做过多的介绍,因为很多人肯定都使用了解过了。

Dolphinscheduler

工作中会使用到Dolphinscheduler做离线任务的调度,是一款大数据的调度框架平台。它的特点是分布式易扩展的可视化DAG工作流任务调度系统。如果你也从事大数据平台方便的开发,肯定也清楚一个优秀的调度系统是多么重要。

github.com/apache/dolp…

dolphinscheduler简单来说是一个分布式去中心化多Master和多Worker服务对等架构。

Master负责任务的调度,Worker负责任务的执行。Master和Worker直接基于netty来进行通信。

这里我们还是来关注一下调度相关的实现

dolphinscheduler中其实就引入了quartz来帮它完成定时任务的调度,在其官方的架构图中可以看到Quartz这个组件.,并默认使用了jdbc作为存储。

官方介绍: DistributedQuartz分布式调度组件,主要负责定时任务的启停操作,当quartz调起任务后,Master内部会有线程池具体负责处理任务的后续操作;

quartz:
    auto-startup: false
    job-store-type: jdbc
    jdbc:
      initialize-schema: never
    properties:
      org.quartz.threadPool:threadPriority: 5
      org.quartz.jobStore.isClustered: true
      org.quartz.jobStore.class: org.quartz.impl.jdbcjobstore.JobStoreTX
      org.quartz.scheduler.instanceId: AUTO
      org.quartz.jobStore.tablePrefix: QRTZ_
      org.quartz.jobStore.acquireTriggersWithinLock: true
      org.quartz.scheduler.instanceName: DolphinScheduler
      org.quartz.threadPool.class: org.quartz.simpl.SimpleThreadPool
      org.quartz.jobStore.useProperties: false
      org.quartz.threadPool.makeThreadsDaemons: true
      org.quartz.threadPool.threadCount: 25
      org.quartz.jobStore.misfireThreshold: 60000
      org.quartz.scheduler.makeSchedulerThreadDaemon: true
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
      org.quartz.jobStore.clusterCheckinInterval: 5000

quartz定时需要执行的任务,会存入到DB中.

代码在ProcessScheduleJob类中的executeInternal方法内 ,它继承了class QuartzJobBean implements org.quartz.Job。

        Command command = new Command();
        command.setCommandType(CommandType.SCHEDULER);
        command.setExecutorId(schedule.getUserId());
        command.setFailureStrategy(schedule.getFailureStrategy());
        command.setProcessDefinitionCode(schedule.getProcessDefinitionCode());
        command.setScheduleTime(scheduledFireTime);
        command.setStartTime(fireTime);
        command.setWarningGroupId(schedule.getWarningGroupId());
        String workerGroup = StringUtils.isEmpty(schedule.getWorkerGroup()) ? Constants.DEFAULT_WORKER_GROUP : schedule.getWorkerGroup();
        command.setWorkerGroup(workerGroup);
        command.setWarningType(schedule.getWarningType());
        command.setProcessInstancePriority(schedule.getProcessInstancePriority());
        command.setProcessDefinitionVersion(processDefinition.getVersion());

        processService.createCommand(command);

到这里quartz帮我们完成了定时的调度,将需要调度的任务写入了DB中,后面在master节点还会有单独的线程来根据机器资源线程资源去执行这些commend。 让我们来看看后面的操作吧。

早期1.x的版本: org.apache.dolphinscheduler.server.master.runner.MasterSchedulerService 当前线程启动后,获取当前需要调度的任务,是利用了ZK分布式锁来实现的。

scheduleProcess()方法

private void scheduleProcess() throws Exception {
        InterProcessMutex mutex = null;
        try {
         
                    mutex = zkMasterClient.blockAcquireMutex();

                    int activeCount = masterExecService.getActiveCount();
                    // make sure to scan and delete command  table in one transaction
                    Command command = processService.findOneCommand();
                    if (command != null) {
                        logger.info("find one command: id: {}, type: {}", command.getId(),command.getCommandType());

                        try{

                            ProcessInstance processInstance = processService.handleCommand(logger,
                                    getLocalAddress(),
                                    this.masterConfig.getMasterExecThreads() - activeCount, command);
                            if (processInstance != null) {
                                logger.info("start master exec thread , split DAG ...");
                                masterExecService.execute(
                                new MasterExecThread(
                                        processInstance
                                        , processService
                                        , nettyRemotingClient
                                        ));
                            }
                        }catch (Exception e){
                            logger.error("scan command error ", e);
                            processService.moveToErrorCommand(command, e.toString());
                        }
                    } else{
                        //indicate that no command ,sleep for 1s
                        Thread.sleep(Constants.SLEEP_TIME_MILLIS);
                    }
            } finally{
                zkMasterClient.releaseMutex(mutex);
            }
        }

核心就是获取要执行任务的Command 时候,先获取了ZK分布式锁。

等处理完Command的时候,会在一个事务里面删除这个Command,最后才会释放锁,其他节点调度的时候就不会获取到相同的Command。

在最新的一些版本如3.x已经考虑到性能的原因去除调了ZK分布式锁,而是基于DB划分slot的思想来进行了实现。

scheduleProcess()方法,你会看到已经没有获取ZK锁相关的操作了

 private void scheduleProcess() throws Exception {
        List<Command> commands = findCommands();
        if (CollectionUtils.isEmpty(commands)) {
            //indicate that no command ,sleep for 1s
            Thread.sleep(Constants.SLEEP_TIME_MILLIS);
            return;
        }

        List<ProcessInstance> processInstances = command2ProcessInstance(commands);
        if (CollectionUtils.isEmpty(processInstances)) {
            return;
        }

        for (ProcessInstance processInstance : processInstances) {
            if (processInstance == null) {
                continue;
            }

            WorkflowExecuteThread workflowExecuteThread = new WorkflowExecuteThread(
                    processInstance
                    , processService
                    , nettyExecutorManager
                    , processAlertManager
                    , masterConfig
                    , stateWheelExecuteThread);

            this.processInstanceExecCacheManager.cache(processInstance.getId(), workflowExecuteThread);
            if (processInstance.getTimeout() > 0) {
                stateWheelExecuteThread.addProcess4TimeoutCheck(processInstance);
            }
            workflowExecuteThreadPool.startWorkflow(workflowExecuteThread);
        }
    }

此时所有的核心逻辑都在findCommand方法中了

 private List<Command> findCommands() {
        int pageNumber = 0;
        int pageSize = masterConfig.getFetchCommandNum();
        List<Command> result = new ArrayList<>();
        if (Stopper.isRunning()) {
            //获取当前master的slot,和master的数量
            int thisMasterSlot = ServerNodeManager.getSlot();
            int masterCount = ServerNodeManager.getMasterSize();
            if (masterCount > 0) {
                result = processService.findCommandPageBySlot(pageSize, pageNumber, masterCount, thisMasterSlot);
            }
        }
        return result;
    }

最终执行的SQL条件 id % #{masterCount} = #{thisMasterSlot},这样就筛选出了每个master调度节点可以调度的command。

   <select id="queryCommandPageBySlot" resultType="org.apache.dolphinscheduler.dao.entity.Command">
        select *
        from t_ds_command
        where id % #{masterCount} = #{thisMasterSlot}
        order by process_instance_priority, id asc
            limit #{limit} offset #{offset}
    </select>

当前master对应的slot是在这里计算出来的,同步并且获取当前master对应的index,也就是MASTER_SLOT

private void syncMasterNodes(Collection<String> nodes, List<Server> masterNodes) {
        masterLock.lock();
        try {
            String addr = NetUtils.getAddr(NetUtils.getHost(), masterConfig.getListenPort());
            this.masterNodes.addAll(nodes);
            this.masterPriorityQueue.clear();
            this.masterPriorityQueue.putList(masterNodes);
            int index = masterPriorityQueue.getIndex(addr);
            if (index >= 0) {
                MASTER_SIZE = nodes.size();
                MASTER_SLOT = index;
            } else {
                logger.warn("current addr:{} is not in active master list", addr);
            }
            logger.info("update master nodes, master size: {}, slot: {}, addr: {}", MASTER_SIZE, MASTER_SLOT, addr);
        } finally {
            masterLock.unlock();
        }
    }

elastic-job-lite

github.com/apache/shar…

elastic-job-lite 也是一个优秀的分布式的定时任务框架,在工作的时候也接触过,当时没有使用的原因是因为不想引入ZK组件。

它的每个任务都会创建一个quartz的JobScheduler。分布式主要体现在其分片的能力上。

执行分片任务的时候每个Job都会先去基于ZK选一个主节点。

注意不是全局一个主节点,而是每个任务一个主节,主节点用于分配分片的信息,分配完分片信息后,才会继续执行任务,每个分片多线程方式启动。

这里就不详细介绍了,大家可以自行了解一下。

总结

上面都是在平时工作中所接触到的一些有定时任务相关功能的开源框架,如果哪里有不对或者不完善的地方,可以一起讨论下。