揭开 Xxl-job 的神秘面纱

xxl-job

前世今生

定时任务的场景

1、信用卡花呗账单通知、手机号余额不足提醒(定时)

2、数据跑批

Quartz的诞生

Quartz在20001年发布,使用 Java 语言编写,它的诞生让任务调度更加简单,开发人员只需要关注业务。

工作模型

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-O4VhF1cp-1629453207943)(/Users/xiaodian/Desktop/xxl-job文档图/Quartz.png)]

优劣势

优点:

  • 精确毫秒级别的调度
  • 支持集群部署
  • 支持事务
  • 支持持久化

劣势:

  • 调度和任务集成在一起,随着调度任务和逻辑增加,整个调度性能会受到影响
  • 集群之间负载结果是随机的,谁抢到数据库锁就由谁去执行任务,可能会出现负载不均衡,发挥不了机器的性能
  • 不支持动态调度
  • 执行数据统计、监控不完善

xxl-job的发展历史

xxl-job是2015年点评员工 许雪里 业余开源之作,2016年点评内部接入xxl-job,据统计,自2016-01-21接入至2017-12-01期间,该系统已调度约100万次,表现优异。

经过数十个版本的更新,系统的任务模型、UI交互模型以及底层调度通讯模型都有了较大的优化和提升,核心功能更加稳定高效。

目前接入登记使用的企业已经有几百家,算上没有登记的企业,几千家是有的。接入场景:如电商业务,O2O业务和大数据作业等。

版本变更

在早期的xxl-job中,调度依赖是Quartz,直到2019年7月发布的7.27版本才移除Quartz依赖。重构代码虽移除了Quartz,但是仍然少不了 调度器、触发器、任务的设计。

特性

1、调度中心和执行任务解耦

2、调度任务支持多种不同场景的路由策略、容错策略、触发策略

3、事件全异步执行

4、运维更加便捷

架构设计

架构图

在这里插入图片描述

架构组成

调度中心

解耦

负责管理任务调度,按照调度配置发出调度请求,不承担任何业务代码。

调度系统和任务解耦,提高了系统可用性和稳定性,同时调度系统性能不再受限于任务执行。

动态调度

支持动态管理调度信息,包括任务新建、更新、删除,GLUE脚本和任务报警等

可视化运维

支持可视化查看监控调度结果报表和执行日志

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PmwAEeOQ-1629453207955)(/Users/xiaodian/Desktop/xxl-job文档图/xxl-job运维.png)]

执行器

负责接受调度请求并执行任务逻辑,专注于任务的执行操作。

设计思想

调度与执行解耦

调度和执行任务解耦,可以带来提升各自系统的稳定性和扩展性。

全异步化 & 轻量级

全异步化

  • 异步调度

    调度和任务解耦后,调度触发变成了一个异步的触发,会先把调度请求放入到 ”调度队列“ 里,任务的执行不再消耗调度中心的资源。

  • 异步执行

    执行器受到调度请求后,会把调度请求放到 执行队列 里异步消费运行,并返回执行结果。

轻量级

XXL-JOB调度中心中每个JOB逻辑非常 “轻”,在全异步化的基础上,单个JOB一次运行平均耗时基本在 “10ms” 之内(基本为一次请求的网络开销);因此,可以保证使用有限的线程支撑大量的JOB并发运行。

得益于上述两点优化,理论上默认配置下的调度中心,单机能够支撑 5000 任务并发运行稳定运行;

实际场景中,由于调度中心与执行器网络ping延迟不同、DB读写耗时不同、任务调度密集程度不同,会导致任务量上限会上下波动。

如若需要支撑更多的任务量,可以通过 “调大调度线程数” 、”降低调度中心与执行器ping延迟” 和 “提升机器配置” 几种方式优化。

均衡调度

调度中心在集群部署时会自动进行任务平均分配,触发组件每次获取与线程池数量(调度中心支持自定义调度线程池大小)相关数量的任务,避免大量任务集中在单个调度中心集群节点。

调度线程池配置:

xxl.job.triggerpool.fast.max=200
xxl.job.triggerpool.slow.max=100

应用

下载

文档地址

源码仓库地址

源码仓库地址Release Download
https://github.com/xuxueli/xxl-jobDownload
http://gitee.com/xuxueli0323/xxl-jobDownload

中央仓库地址

<!-- http://repo1.maven.org/maven2/com/xuxueli/xxl-job-core/ -->
<dependency>    
  <groupId>com.xuxueli</groupId>    
  <artifactId>xxl-job-core</artifactId>    
  <version>${最新稳定版本}</version>
</dependency>

环境

  • Maven3+
  • Jdk1.8+
  • Mysql5.7+

初始化数据库

“调度数据库初始化SQL脚本” 位置为: /xxl-job/doc/db/tables_xxl_job.sql

调度中心支持集群部署,集群情况下各节点务必连接同一个mysql实例;

如果 Mysql 做主从,调度中心集群节点务必强制走主库。

配置调度中心

调度中心是任务的指挥中心,支持集群部署

/xxl-job/xxl-job-admin/src/main/resources/application.properties

配置数据库、用户密码、token

spring:
  datasource:
    url: jdbc:mysql://xxx
    username: xxx
    password: xxx
    driver-class-name: com.mysql.cj.jdbc.Driver
    type: com.zaxxer.hikari.HikariDataSource


xxl:
  job:
  	login:
  		username: admin
  		password: 123456
    accessToken: 3d10c87f4be94095b2b0890ea655655e
    i18n: zh_CN
    triggerpool:
      fast:
        max: 200
      slow:
        max: 100
    logretentiondays: 30

配置执行器

配置连接的调度中心的地址、通信的token、执行端口、日志存储路径

xxl:
  job:
    enabled: true
    accessToken: 3d10c87f4be94095b2b0890ea655655e
    admin-addresses: http://localhost:8080/xxl-job-admin
    executor-address:
    executor-ip:
    executor-port: 9999
    log-path: /home/admin/logs/${spring.application.name}/jobhandler
    log-retention-days: 30
    appname: ${spring.application.name}

添加执行器

调度器和执行器都启动好后,将执行器添加下执行器组

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cN9v8IqV-1629453207969)(/Users/xiaodian/Desktop/xxl-job新增执行器.png)]

appName 和执行器配置的 xxl.job.appname 要保持一致,如果是自动注册,执行器向调度器注册成功后,调度器会根据配置的AppName判断获取IP地址。在执行定时任务调度时,会根据注册地址和路由策略进行任务调度通信。

编写任务代码

1、加上@Component注解。xxl-job从spring bean容器中扫描带有 @XxlJob 方法的bean实例,存储在一个Map中,key是 @XxlJob 的 value 属性名称,value是当前bean实例。

2、继承 IJobHandler 抽象类

3、execute方法加 @XxlJob ,value是当前JobHandler的名称,init是当前任务的初始化方法,destroy是容器销毁方法,

@Component
public class SimpleJobHandler extends IJobHandler {

    @XxlJob(value = "simpleJobHandler")
    @Override
    public ReturnT<String> execute(String param) throws Exception {
        XxlJobLogger.log("Hello XXL-JOB");
        return ReturnT.SUCCESS;
    }

}

添加定时任务

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NZ7jx1QJ-1629453207973)(/Users/xiaodian/Desktop/xxl-job文档图/xxl-job添加任务.png)]

路由策略
  • FIRST 第一个

    固定选择第一个机器

  • LAST 最后一个

    固定选择最后一个机器

  • ROUND 轮询

    轮询选择在线的机器

  • RANDOM 随机

    随机选择在线的机器

  • CONSISTENT_HASH 一致性HASH

    每个任务按照Hash算法固定选择某一台机器,且所有任务均匀散列在不同机器上

  • LFU 最不经常使用

    使用频率最低的机器优先被选举

  • LRU 最近最久未使用

    最久未使用的机器优先被选举

  • FAILOVER 故障转移

    按照顺序依次进行心跳检测,第一个心跳检测成功的机器选定为目标执行器并发起调度

  • BUSYOVER 忙碌转移

    按照顺序依次进行空闲检测,第一个空闲检测成功的机器选定为目标执行器并发起调度

  • SHARDING_BROADCAST 分片广播

    广播触发对应集群中所有机器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务

分片广播

分片数和执行器机器数量是一致的,编号从 0 开始。这里是数据分片,不是任务分片。

这里就是遍历执行器地址,将分片参数往下传。index是当前执行器下标编号,total是执行器总数。

if (ExecutorRouteStrategyEnum.SHARDING_BROADCAST == ExecutorRouteStrategyEnum.match(jobInfo.getExecutorRouteStrategy(), null)
    && group.getRegistryList() != null && !group.getRegistryList().isEmpty()
    && shardingParam == null) {
  // 分片广播
  for (int i = 0; i < group.getRegistryList().size(); i++) {
    processTrigger(group, jobInfo, finalFailRetryCount, triggerType, 
                   // 分片参数:index
                   i, 
                   // 分片参数:total
                   group.getRegistryList().size());
  }
} else {
  if (shardingParam == null) {
    shardingParam = new int[]{0, 1};
  }
  processTrigger(group, jobInfo, finalFailRetryCount, triggerType, 
                 shardingParam[0], shardingParam[1]);
}

调度器会将分片参数发送给执行器,执行器会放在 ThreadLocal 中。

com.xxl.job.core.thread.JobThread#run

ShardingUtil.setShardingVo(new ShardingVO(
					triggerParam.getBroadcastIndex(), triggerParam.getBroadcastTotal()));

可以直接在业务 JobHandler 中获取分片参数。

@Component
public class SimpleJobHandler extends BaseJobHandler {

    @XxlJob(value = "simpleJobHandler")
    @Override
    public ReturnT<String> execute(String param) throws Exception {
        XxlJobLogger.log("Hello XXL-JOB");
        
        ShardingUtil.ShardingVO shardingVo = ShardingUtil.getShardingVo();
        int index = shardingVo.getIndex();
        int total = shardingVo.getTotal();
        
        return ReturnT.SUCCESS;
    }

}

如何使用分片参数呢?比如每个节点处理指定分片范围的数据,通过 id 取余 total,得出id结果只要等于index,就是当前节点要处理的数据了。

SELECT	* FROM	content_video WHERE	deleted = 0	AND MOD(id, #{total}) = {index} LIMIT 0, 100
故障转移

遍历执行器所有地址,发送心跳检测,如果心跳检测异常,则继续下一个地址。如果心跳检测成功,就使用当前执行器地址。

public class ExecutorRouteFailover extends ExecutorRouter {

    @Override
    public ReturnT<String> route(TriggerParam triggerParam, List<String> addressList) {

        StringBuffer beatResultSB = new StringBuffer();
        for (String address : addressList) {
            // beat
            ReturnT<String> beatResult = null;
            try {
                ExecutorBiz executorBiz = XxlJobScheduler.getExecutorBiz(address);
                beatResult = executorBiz.beat();
            } catch (Exception e) {
                logger.error(e.getMessage(), e);
                beatResult = new ReturnT<String>(ReturnT.FAIL_CODE, ""+e );
            }
            beatResultSB.append( (beatResultSB.length()>0)?"<br><br>":"")
                    .append(I18nUtil.getString("jobconf_beat") + ":")
                    .append("<br>address:").append(address)
                    .append("<br>code:").append(beatResult.getCode())
                    .append("<br>msg:").append(beatResult.getMsg());

            // beat success
            if (beatResult.getCode() == ReturnT.SUCCESS_CODE) {

                beatResult.setMsg(beatResultSB.toString());
                beatResult.setContent(address);
                return beatResult;
            }
        }
        return new ReturnT<String>(ReturnT.FAIL_CODE, beatResultSB.toString());

    }
}
一致性哈希
运行模式

在xxl-job中,不仅支持运行预编译好的类,也支持支持输入脚本代码运行。

BEAN模式

运行Java任务类,在执行器端编写,就是Bean模式。

GLUE模式

运行脚本代码,在调度器控台编写输入,就是GLUE模式。

阻塞处理策略

执行器接收到任务调度请求后,会根据阻塞处理策略和当前任务运行状态,判断调度请求是等待、丢失还是覆盖。

单机串行(默认)

调度请求进入单机执行器后,调度请求进入FIFO队列并以串行方式运行

丢弃后续调度

调度请求进入单机执行器后,发现执行器存在运行的调度任务,本次请求将会被丢弃并标记为失败

覆盖之前调度

调度请求进入单机执行器后,发现执行器存在运行的调度任务,将会终止运行中的调度任务并清空队列,然后运行本地调度任务

子任务ID

在一些数据跑批场景中,有些任务是相互依赖的,比如需要先下载文件,再解析入库,入库后再进行一些数据分析统计。也就是任务串行执行。

![[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eqRuwLyR-1629453207976)(/Users/xiaodian/Desktop/xxl-job文档图/xxl-job子任务场景.png)]](https://img-blog.csdnimg.cn/7ac9e6b9afab4599b7ac64ac365eb1a2.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,

以上可以将每个流程做成一个任务,它的下游流程设置为一个子任务,当前任务执行完毕后,触发它的子任务执行。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ShSrhyJy-1629453207979)(/Users/xiaodian/Desktop/xxl-job文档图/xxl-job子任务.png)]

任务超时

执行任务在指定时间内返回结果,就不再等待。

任务重试

如果任务执行失败,调度器收到任务执行结果,会写入到任务日志中。

后台监控任务线程,会定时遍历查询执行失败的任务,设置了任务重试次数,会再次发次重试,直到重试次数使用完或执行成功。

原理分析

调度器

启动配置类

com.xxl.job.admin.core.conf.XxlJobAdminConfig

它实现了 InitializingBean 接口,在初始化的时候会调用 afterPropertiesSet 方法

private XxlJobScheduler xxlJobScheduler;

@Override
public void afterPropertiesSet() throws Exception {
    adminConfig = this;

    xxlJobScheduler = new XxlJobScheduler();
    xxlJobScheduler.init();
}

XxlJobScheduler的 init 初始化方法

public void init() throws Exception {
    // init i18n
    initI18n();

    // 1 admin registry monitor run 用来更新执行器注册表
    JobRegistryMonitorHelper.getInstance().start();

    // 2 admin fail-monitor run 任务执行失败处理
    JobFailMonitorHelper.getInstance().start();

    // 3 admin lose-monitor run 任务结果丢失处理
    JobLosedMonitorHelper.getInstance().start();

    // 4 admin trigger pool start 创建了两个调度线程池
    JobTriggerPoolHelper.toStart();

    // 5 admin log report start 报表线程
    JobLogReportHelper.getInstance().start();

    // 6 start-schedule 启动调度器
    JobScheduleHelper.getInstance().start();

    logger.info(">>>>>>>>> init xxl-job admin success.");
}

JobRegistryMonitorHelper

每 30s 执行一次更新注册表,将心跳最后更新时间晚于当前时间 90s 的执行器剔除

JobFailMonitorHelper

每隔 10s 将执行失败的任务触发告警

JobLosedMonitorHelper

每隔 60s 将丢失的任务主动标记为执行失败(执行器心跳注册不在线,调度状态仍是“运行中”)

JobTriggerPoolHelper

创建调度线程池(快慢线程池)

JobLogReportHelper

报表线程池

JobScheduleHelper

启动调度器线程,主要是创建启动调度线程和时间轮线程

调度器线程

随机睡眠4~5秒,尽量避免调度器集群环境下的资源竞争

TimeUnit.MILLISECONDS.sleep(5000 - System.currentTimeMillis()%1000 );

计算预读取任务数,默认6000

int preReadCount = (getAdminConfig().getTriggerPoolFastMax()                           + getAdminConfig().getTriggerPoolSlowMax()) * 20;

接下来就是一个 while 循环,也就是调度器重复在做的事情。

// 只要调度线程不停止,就不断地调度
while (!scheduleThreadToStop) {
    ...
}
获取任务锁

获取数据的排他锁,集群环境下的调度器服务,在同一时间只能有一个调度器能获取任务信息。

如果加锁没有成功,说明有其它调度服务在处理任务了,只能等事务提交或回滚释放锁后,才能继续获取锁。

preparedStatement = conn.prepareStatement(  
    "select * from xxl_job_lock where lock_name = 'schedule_lock' for update" );
preparedStatement.execute();

获取锁后,查询 6000条 下次触发时间在 5秒 内的任务

List<XxlJobInfo> scheduleList = getXxlJobInfoDao()
			 .scheduleJobQuery(nowTime + PRE_READ_MS, preReadCount);
调度任务

任务的触发时间分为了三种情况。

比如当前时间是上午 9:00 00,任务每隔5秒执行一次。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gLNbne0W-1629453207983)(/Users/xiaodian/Desktop/xxl-job文档图/触发时间端.png)]

第一种情况:触发时间过期5秒以上

触发时间在上午 8:59 55:00 之前的任务

任务触发时间过期5秒以上,这时候就不进行调度处理了,而是刷新它的下次调度时间。

什么场景会出现触发时间超时呢?比如数据库查询非常慢等情况

if (nowTime > jobInfo.getTriggerNextTime() + PRE_READ_MS) {
  // 2.1、trigger-expire > 5s:pass && make next-trigger-time
  // fresh next
  refreshNextValidTime(jobInfo, new Date());
}

第二种情况:触发时间过期5秒内

触发时间在上午 8:59 55:00 到 9:00 之间的任务

已经到了触发时间,但是过期时间在5秒内,正常触发调度。

else if (nowTime > jobInfo.getTriggerNextTime()) {
  // 2.2、trigger-expire < 5s:direct-trigger && make next-trigger-time

  // 1、trigger
  JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.CRON, -1, null, null, null);
  
  // 2、fresh next
  refreshNextValidTime(jobInfo, new Date());

  // next-trigger-time in 5s, pre-read again
  if (jobInfo.getTriggerStatus() == 1 && 
      					nowTime + PRE_READ_MS > jobInfo.getTriggerNextTime()) {

    // 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()));

  }
}

第三种情况:没有到触发时间

触发时间在上午 9:00 之后的任务

对还没有到触发时间的任务,加入到时间轮,并刷新下次触发时间。

else {
  // 2.3、trigger-pre-read:time-ring trigger && make next-trigger-time

  // 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()));
}
任务触发

从 JobTriggerPoolHelper#addTrigger 开始触发调度任务,这里看到会先有一个快慢线程池的选择。

如果当前任务的历史执行耗时超过 500毫秒 ,并且次数大于 10次,那么将调度触发线程池替换为 慢线程池

这样做将调度效率不同的任务进行线程隔离分组,充分利用线程池资源。

ThreadPoolExecutor triggerPool_ = fastTriggerPool;
AtomicInteger jobTimeoutCount = jobTimeoutCountMap.get(jobId);
if (jobTimeoutCount!=null && jobTimeoutCount.get() > 10) {      
  // job-timeout 10 times in 1 min
  triggerPool_ = slowTriggerPool;
}

执行结束的代码中,进行任务耗时判断,递增耗时次数。

} finally {
  // incr timeout-count-map
  long cost = System.currentTimeMillis()-start;
  if (cost > 500) {       // ob-timeout threshold 500ms
    AtomicInteger timeoutCount = jobTimeoutCountMap.putIfAbsent(jobId, 
                                                                new AtomicInteger(1));
    if (timeoutCount != null) {
      timeoutCount.incrementAndGet();
    }
  }
}

线程池选择好后,就开始调度触发任务啦!

com.xxl.job.admin.core.trigger.XxlJobTrigger#trigger

1、获取任务服务组

根据任务ID获取任务服务组信息

XxlJobGroup group = XxlJobAdminConfig.getAdminConfig()
  .getXxlJobGroupDao().load(jobInfo.getJobGroup());

2、是否广播分片执行

判断是否任务的路由策略是否 广播分片,如果是 广播分片,所有执行器服务都要参与负载,否则就根据策略获取执行器地址。

if (ExecutorRouteStrategyEnum.SHARDING_BROADCAST == ExecutorRouteStrategyEnum.match(jobInfo.getExecutorRouteStrategy(), null)
    && group.getRegistryList() != null && !group.getRegistryList().isEmpty()
    && shardingParam == null) {
  // 遍历执行器所有地址发起调度请求
  for (int i = 0; i < group.getRegistryList().size(); i++) {
    processTrigger(group, jobInfo, finalFailRetryCount, triggerType, 
                   i,  group.getRegistryList().size());
  }
} else {
  if (shardingParam == null) {
     shardingParam = new int[]{0, 1};
  }
  processTrigger(group, jobInfo, finalFailRetryCount, triggerType, 
                 shardingParam[0], shardingParam[1]);
}

3、执行器负载均衡

如果任务路由策略不是 广播分片,则根据路由策略获取执行器地址。

routeAddressResult = executorRouteStrategyEnum.getRouter().route(
  																			triggerParam, group.getRegistryList());
if (routeAddressResult.getCode() == ReturnT.SUCCESS_CODE) {
  address = routeAddressResult.getContent();
}

发起调度请求

拼装触发请求参数,发起HTTP请求

public static ReturnT<String> runExecutor(TriggerParam triggerParam, String address) {
  ReturnT<String> runResult = null;
  try {
    ExecutorBiz executorBiz = XxlJobScheduler.getExecutorBiz(address);
    runResult = executorBiz.run(triggerParam);
  } catch (Exception e) {
    logger.error(">>>>>>>>>>> xxl-job trigger error, please check if the executor[{}] is running.", address, e);
    runResult = new ReturnT<String>(ReturnT.FAIL_CODE, ThrowableUtil.toString(e));
  }

  StringBuffer runResultSB = new StringBuffer(I18nUtil.getString("jobconf_trigger_run") + ":");
  runResultSB.append("<br>address:").append(address);
  runResultSB.append("<br>code:").append(runResult.getCode());
  runResultSB.append("<br>msg:").append(runResult.getMsg());

  runResult.setMsg(runResultSB.toString());
  return runResult;
}


public ReturnT<String> run(TriggerParam triggerParam) {
  return XxlJobRemotingUtil.postBody(this.addressUrl + "run", this.accessToken, this.timeout, triggerParam, String.class);
}

以上的总结:如果有任务需要处理,选择执行器调度执行。预读的任务和没有触发时间的任务,加入到时间轮。

执行器

启动

入口 XxlJobSpringExecutor 实现了 SmartInitializingSingleton 接口,在对象初始化的时候,会调用afterSingletonsInstantiated 方法。

public void afterSingletonsInstantiated() {
  if (this.isEnabled()) {
    // 初始化 JobHandler
    this.initJobHandlerMethodRepository(applicationContext);
    GlueFactory.refreshInstance(1);

    try {
      // 启动后台处理线程
      super.start();
    } catch (Exception var2) {
      throw new RuntimeException(var2);
    }
  }
}

初始化 JobHandler

initJobHandlerMethodRepository

初始化 JobHandler,通过扫描 @XxlJob 注解,将实例存储在一个Map中,key是配置的JobHandler名称,value就是bean实例接口。

当调度请求过来,根据请求的 JobHandler名称 找到对应的实例接口,最后执行execute方法。

初始化后台线程

XxlJobSpringExecutor继承了XxlJobExecutor,在afterSingletonsInstantiated调用了父类的 start 方法

public void start() throws Exception {
  // 初始化日志文件目录
  XxlJobFileAppender.initLogPath(this.logPath);
  
  // 初始化调度通信客户端
  this.initAdminBizList(this.adminAddresses, this.accessToken);
  
  // 初始化日志清理线程
  JobLogFileCleanThread.getInstance().start((long)this.logRetentionDays);
  
  // 初始化结果回调线程
  TriggerCallbackThread.getInstance().start();
  
  // 初始化执行器服务器(重点)
  this.initEmbedServer(this.address, this.ip, this.port, this.appname, this.accessToken);
}

初始化内置服务器

private void initEmbedServer(String address, String ip, int port, 
                             String appname, String accessToken) throws Exception {
   ...
   this.embedServer = new EmbedServer();
   this.embedServer.start(address, port, appname, accessToken);
}

初始化业务线程池

该线程池用于处理任务调度、心跳请求

final ThreadPoolExecutor bizThreadPool = new ThreadPoolExecutor(0, 200, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(2000), ...);

初始化Netty服务

EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();

ServerBootstrap bootstrap = new ServerBootstrap();
 ((ServerBootstrap)bootstrap
  .group(bossGroup, workerGroup)
  .channel(NioServerSocketChannel.class))
	.childHandler(new ChannelInitializer<SocketChannel>() {
    public void initChannel(SocketChannel channel) throws Exception {
      channel.pipeline()
        .addLast(new ChannelHandler[]{new IdleStateHandler(0L, 0L, 90L, TimeUnit.SECONDS)})
        .addLast(new ChannelHandler[]{new HttpServerCodec()})
        .addLast(new ChannelHandler[]{new HttpObjectAggregator(5242880)})
        // 处理调度服务请求的Handler
        .addLast(new ChannelHandler[]{new EmbedServer.EmbedHttpServerHandler(
          EmbedServer.this.executorBiz, accessToken, bizThreadPool)});
    }
  })
  .childOption(ChannelOption.SO_KEEPALIVE, true);

ChannelFuture future = bootstrap.bind(port).sync();
EmbedServer.this.startRegistry(appname, address);
future.channel().closeFuture().sync();
注册

将执行器的地址注册到调度中心

public void startRegistry(String appname, String address) {
  ExecutorRegistryThread.getInstance().start(appname, address);
}

调度中心接口到注册请求后,会将执行器名称和地址保存到 xxl_job_registry。

另外调度中心有 JobRegistryMonitorHelper#registryThread 线程,定时探活执行器服务地址,将不可用的执行器服务删除。

任务处理

执行器的 Netty Server 接收到请求后,会由 EmbedHttpServerHandler 进行业务处理。channelRead0 方法拿到请求数据后,会将请求扔给 业务线程池 异步处理。

public static class EmbedHttpServerHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
    private static final Logger logger = LoggerFactory.getLogger(EmbedServer.EmbedHttpServerHandler.class);
    private ExecutorBiz executorBiz;
    private String accessToken;
    private ThreadPoolExecutor bizThreadPool;

    public EmbedHttpServerHandler(ExecutorBiz executorBiz, String accessToken, ThreadPoolExecutor bizThreadPool) {
        this.executorBiz = executorBiz;
        this.accessToken = accessToken;
        this.bizThreadPool = bizThreadPool;
    }

    protected void channelRead0(final ChannelHandlerContext ctx, FullHttpRequest msg) throws Exception {
        final String requestData = msg.content().toString(CharsetUtil.UTF_8);
        final String uri = msg.uri();
        final HttpMethod httpMethod = msg.method();
        final boolean keepAlive = HttpUtil.isKeepAlive(msg);
        final String accessTokenReq = msg.headers().get("XXL-JOB-ACCESS-TOKEN");
        this.bizThreadPool.execute(new Runnable() {
            public void run() {
                Object responseObj = EmbedHttpServerHandler.this.process(httpMethod, uri, requestData, accessTokenReq);
                String responseJson = GsonTool.toJson(responseObj);
                EmbedHttpServerHandler.this.writeResponse(ctx, keepAlive, responseJson);
            }
        });
    }
  
  	 private Object process(HttpMethod httpMethod, String uri, String requestData, 
                            String accessTokenReq) {
       ...
     }
}

最终根据请求的 URI 调用不同的处理,调度请求的 URI 是 /run,所以执行 executorBiz#run方法

private Object process(HttpMethod httpMethod, String uri, String requestData, 
                       String accessTokenReq) {
  if (HttpMethod.POST != httpMethod) {
    return new ReturnT(500, "invalid request, HttpMethod not support.");
  } else if (uri != null && uri.trim().length() != 0) {
    if (this.accessToken != null && this.accessToken.trim().length() > 0 && !this.accessToken.equals(accessTokenReq)) {
      return new ReturnT(500, "The access token is wrong.");
    } else {
      
      try {
        if ("/beat".equals(uri)) {
          return this.executorBiz.beat();
        } else if ("/idleBeat".equals(uri)) {
          IdleBeatParam idleBeatParam = (IdleBeatParam)GsonTool.fromJson(requestData, IdleBeatParam.class);
          return this.executorBiz.idleBeat(idleBeatParam);
        } else if ("/run".equals(uri)) {
          TriggerParam triggerParam = (TriggerParam)GsonTool.fromJson(requestData, TriggerParam.class);
          return this.executorBiz.run(triggerParam);
        } else if ("/kill".equals(uri)) {
          KillParam killParam = (KillParam)GsonTool.fromJson(requestData, KillParam.class);
          return this.executorBiz.kill(killParam);
        } else if ("/log".equals(uri)) {
          LogParam logParam = (LogParam)GsonTool.fromJson(requestData, LogParam.class);
          return this.executorBiz.log(logParam);
        } else {
          return new ReturnT(500, "invalid request, uri-mapping(" + uri + ") not found.");
        }
      } catch (Exception var6) {
        logger.error(var6.getMessage(), var6);
        return new ReturnT(500, "request error:" + ThrowableUtil.toString(var6));
      }
      
    }
  } else {
    return new ReturnT(500, "invalid request, uri-mapping empty.");
  }
}

根据调度请求 JobHandler 名称从Map找到对应的 JobHandler 实例,然后包装成一个 JobThread 线程。接着调度请求数据被放入 JobThread 线程的异步队列中,由 JobThread 消费异步队列。

public static JobThread registJobThread(int jobId, IJobHandler handler, 
                                        String removeOldReason) {
  JobThread newJobThread = new JobThread(jobId, handler);
  newJobThread.start();

  JobThread oldJobThread = (JobThread)jobThreadRepository.put(jobId, newJobThread);
  if (oldJobThread != null) {
    oldJobThread.toStop(removeOldReason);
    oldJobThread.interrupt();
  }

  return newJobThread;
}


public class JobThread extends Thread {
    private static Logger logger = LoggerFactory.getLogger(JobThread.class);
    private int jobId;
    private IJobHandler handler;
    private LinkedBlockingQueue<TriggerParam> triggerQueue;
    private Set<Long> triggerLogIdSet;
    private volatile boolean toStop = false;
    private String stopReason;
    private boolean running = false;
    private int idleTimes = 0;

    public JobThread(int jobId, IJobHandler handler) {
        this.jobId = jobId;
        this.handler = handler;
        this.triggerQueue = new LinkedBlockingQueue();
        this.triggerLogIdSet = Collections.synchronizedSet(new HashSet());
    }
  
    public void run() {
      while(!this.toStop) {
        ...
        triggerParam = null;
        
        triggerParam = (TriggerParam)this.triggerQueue.poll(3L, TimeUnit.SECONDS);
        // 执行 JobHandler 业务代码
        executeResult = this.handler.execute(triggerParam.getExecutorParams());
        
        
        if (triggerParam != null) {
          if (!this.toStop) {
            // 将执行结果放入回调队列
            TriggerCallbackThread.pushCallBack(
              new HandleCallbackParam(triggerParam.getLogId(), triggerParam.getLogDateTime(), executeResult));
          } else {
            stopResult = new ReturnT(500, this.stopReason + " [job running, killed]");
            TriggerCallbackThread.pushCallBack(new HandleCallbackParam(triggerParam.getLogId(), triggerParam.getLogDateTime(), stopResult));
          }
        }
      }
   }
  
    ...
}

结果回调

执行器接收到调度请求后,是放到异步队列进行消费处理的,所以执行结果也是异步返回调度中心的。调度中心接收到请求后,更新任务日志的执行结果。

com.xxl.job.core.thread.TriggerCallbackThread#start

while(!TriggerCallbackThread.this.toStop) {
  try {
    // 阻塞获取回调队列的数据
    HandleCallbackParam callback = (HandleCallbackParam) TriggerCallbackThread.getInstance()
      									.callBackQueue.take();
    if (callback != null) {
      // 如果回调队列数据不为空,则将队列数据一次性拿出。
      List<HandleCallbackParam> callbackParamListx = new ArrayList();
      int drainToNumx = TriggerCallbackThread.getInstance()
        								.callBackQueue.drainTo(callbackParamListx);
      callbackParamListx.add(callback);
      if (callbackParamListx != null && callbackParamListx.size() > 0) {
        // 向调度中心发起回调请求
        TriggerCallbackThread.this.doCallback(callbackParamListx);
      }
    }
  } catch (Exception var4) {
    if (!TriggerCallbackThread.this.toStop) {
      TriggerCallbackThread.logger.error(var4.getMessage(), var4);
    }
  }
}

时间轮

什么是时间轮?

先思考一个问题,现在有1000个任务,都是在不同的时间执行,执行时间精确到秒,如何实现对所有的任务调度?

第一种思路(集合)

启动一个线程,每秒种对所有的任务进行遍历,找出执行时间跟当前时间匹配的任务,然后执行。但如果任务数量很大,遍历和比较所有任务会比较浪费时间。

第二种思路(堆)

按照任务执行时间进行排序,执行时间近的放在前面。这种可以用【小顶堆】实现,也就是优先队列。

一个while循环不断拿当前时间和第一个任务的执行时间对比,如果时间到了看这个任务是不是周期性任务,如果是更新下次执行时间,如果不是则将任务从优先队列移除,然后执行任务。

在JDK中就提供了 java.util.Timer 定时任务工具类,核心实现就是优先队列。

public class Timer {
    /**
     * The timer task queue.  This data structure is shared with the timer
     * thread.  The timer produces tasks, via its various schedule calls,
     * and the timer thread consumes, executing timer tasks as appropriate,
     * and removing them from the queue when they're obsolete.
     */
    private final TaskQueue queue = new TaskQueue();

    /**
     * The timer thread.
     */
    private final TimerThread thread = new TimerThread(queue);
    
	...    
}

但 Timer 是单线程的,在很多场景不能满足业务需求。

在 JDK1.5 以后,引入了支持多线程的任务调度工具 ScheduledThreadPoolExecutor 用来替代 Timer。构造函数可以看到是一个延迟队列DelayedWorkQueue

public ScheduledThreadPoolExecutor(int corePoolSize) {
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
          new DelayedWorkQueue());
}

但是优先队列的插入和删除时间复杂度是O(logn),当数据量大的时候,频繁的入堆出堆性能不是很好。

第三种思路(数组)

可以考虑将所有任务进行分组,把相同时刻的任务放在一起,精确到1秒种。比如下图,数组的一个下标就代表1秒钟,相同时刻的任务形成链表。这样分组后遍历和比较的时间会减少很多。

但是这样还是有一个问题,如果任务数量很大,而且时间都不同,或者执行时间相差很远,那这个数组长度是不是要非常长?比如有个任务是1个月后执行,按秒为单元的数组得创建多大内存存储?
所以数组的长度肯定不能是无限的,只能是固定长度的。

最终思路(循环数组-时间轮)

比如数组固定长度是60,一个下标代表1秒,一个数组就可以代表60秒了。线程只要遍历数组获取任务执行就可以了。

在这里插入图片描述

固定长度的数组怎么用来表示超出最大长度的时间呢?可以使用循环数组。

比如一个循环数组长度60,可以表示60秒,这样时间轮的概念就出来了

多层时间轮

60秒以后的任务怎么执行呢?只要模以60,用得到的余数,放入到对应的数组下标即可。

比如 90 % 60 = 30,就放在数组下标 30 的位置,这里就有了轮次的概念。第 90 秒的任务是第二轮才执行。

在xxl-job中的时间轮是怎么实现的呢?

1、计算当前时间的秒数,ringSecond 是 0~59 的秒数

 // 1、make ring second
int ringSecond = (int) ((jobInfo.getTriggerNextTime() / 1000) % 60);

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

2、放入时间轮,key是秒,value是任务id列表

private volatile static Map<Integer, List<Integer>> ringData = new ConcurrentHashMap<>();

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

放入到 ringData 后,时间轮线程是如何拿出来执行呢?

时间轮线程

初始化对其秒数,休眠时间为:1000 - 当前秒数 % 1000 的余数,也就是等到下一个整秒运行。

TimeUnit.MILLISECONDS.sleep(1000 - System.currentTimeMillis() % 1000);

进入 while 循环,获取当前时间秒数

// 避免处理耗时太长,跨过刻度,向前校验一个刻度;
int nowSecond = Calendar.getInstance().get(Calendar.SECOND);   

获取 当前秒数 和 上一个秒数 的时间轮任务。

(nowSecond + 60 - i) % 60 和 nowSecond - i 的结果是一样的,如果当前秒数是 30 秒,那么这里就是获取 30 秒 和 29 秒的任务。

List<Integer> ringItemData = new ArrayList<>();
for (int i = 0; i < 2; i++) {
  List<Integer> tmpData = ringData.remove((nowSecond + 60 - i) % 60);
  if (tmpData != null) {
    ringItemData.addAll(tmpData);
  }
}

将获取到的时间轮任务遍历调度执行

 if (ringItemData.size() > 0) {
   // do trigger
   for (int jobId : ringItemData) {
     // do trigger
     JobTriggerPoolHelper.trigger(jobId, TriggerTypeEnum.CRON, -1, null, null, null);
   }
   // clear
   ringItemData.clear();
 }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

子津

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值