万字剖析XXL-job源码和原理

1.技术背景

明明已经有成熟的任务调度框架了比如Quartz为什么不直接使用?

  • Quartz是一个功能强大的任务调度框架,它提供了丰富的调度方法和灵活的应用方式,支持任务和调度的多种组合方式,以及多种存储方式。Quartz的核心元素包括JobDetail(任务)、Trigger(触发器)和Scheduler(调度器)。它支持集群部署,通过数据库锁来保证任务的高可用性。
  • Quartz的不足 :Quartz作为开源任务调度框架中的佼佼者,是任务调度的首选。但是在集群环境中Quartz采用API的方式对任务进行管理,这样存在以下问题:
    • 通过调用Api的方式操作任务,不人性化
    • 需要持久化业务的QuartzJobBean到底层数据表中,系统侵入性相当严重
    • 调度逻辑和QuartzJobBean耦合在同一个项目中,导致调度任务数量逐渐增多,同时调度任务逻辑逐渐加重的情况下,此时调度系统的性能将大大受限于业务。
    • Quartz底层以'抢占式'获取Db锁并由抢占成功节点负责运行任务,会导致节点负载悬殊过大,而XXL-JOB通过执行器实现"协同分配式"运行任务,充分发挥集群优势,负载各节点均衡

XXL-JOB弥补了Quartz的上述不足之处,并充分展现他的优势,我们一起来看看

官方介绍

XXL------JOB官方文档地址

  • XXL---JOB是一个由大众点评员工徐雪里开发的轻量级分布式任务调度框架,其核心设计目标是开发迅速,学习简单,轻量级,简单扩展。XXl-job支持多种任务类型,如内置Java任务。Glue脚本任务等,并且支持在线任务处理和日志白屏化。它使用数据库锁来保证在多台调度段同时工作时,仅有一台机器提供调度工作,但不支持工作流,并且其报警监控相对有限,只支持邮件

  • 通俗来说 :xxl-job是一个任务调度框架,通过引入XXL-JOB相关依赖,按照相关格式撰写代码后,可在其可视化界面进行任务的启动,执行,中止以及包含了日志记录 与查询和任务状态监控

    如果将XXl------job形容成一个人的话,将每一个引入xxl-job的微服务就相当于一个独立的人(执行器),而按照相关约定格式撰写的Handler为餐桌上的食物,可视化界面则可以决定哪个执行器(人),吃东西或者不吃某个东西(定时任务),在什么时间吃(Corn表达式控制或者执行或终止或即开始)

2.XXl-job底层实现

2.1设计模式

调度中心负责发起调度请求,将任务抽象成分散的JobHandler,交由执行器统一管理,发挥集群的优势

  • 调度模块(调度中心)

    • 基于Mysql的集群方案,集群分布式并发环境中进行定时任务调度时,会在各个节点上报任务,存在数据库中
    • 执行时会从数据库中取出触发器来执行,如果触发器的名称和执行时间相同,则只有一个节点去执行此任务
  • 执行模块(执行器)

    • 线程池并行,在多线程调度的情况下,调度模块被阻塞的几率很低,大大提升了调度系统的承载量
    • 不同任务之间并行调度,并行执行
    • 单个任务,针对单个执行器是串行执行的,针对多个执行器是并行运行的

2.2XXL-JOB的原理

2.2.1执行器的注册和发现

  • 执行器的注册和发现主要关系两张表:
    • XXL_JOB_REGISETRY:执行器的实例表,保存实例信息和心跳信息
    • XXL_JOB_GROUP:每个服务注册的实例列表 执行器启动线程每隔30秒向注册表XXL_JOB_REGISETRY请求一次,更新执行器的心跳信息,调度中心启动线程每隔30秒检测一次XXL_JOB_REGISETRY,将超过90秒还没有收到心跳的实例信息从XXL_JOB_REGISETRY删除,并更新XXL_JOB_GROUP服务的实例列表信息

2.2.2 调度中心调用执行器

调度中心通过循环不停的

  1. 关闭自动提交事务
  2. 利用Mysql的悲观锁,其他事务无法进入
SQL 复制代码
Select * from xxl_job_lock where lock_name = 'schedule_lock' for update
  1. XXlJobScheduler读取数据库中的xxl_job_info:记录定时任务的相关信息,该表中有 trigger_next_time字段表示下一次任务的触发时间。拿到距离当前时间5s内的任务列表,分为三种情况处理:
    • 对于当前时间﹣任务的下一次触发时间>5,直接跳过不执行,重置trigger_next_time的时间,(超过5s)
    • 对于任务的下一次触发时间<当前时间<任务的下一次触发时间+5的任务(不超过5s的)
    • 对于任务的下一次触发时间>当前时间,将其放入时间轮中,根据任务下一次触发时间更新下下一次任务(未到时间的)
  2. commit提交事务,同时释放排他锁

2.2.3 任务触发器负责分布式调度

JobTriiggerPoolHelper负责将调度中心的任务触发到对应执行器,相当于一个中间件

  • 初始化快速任务线程池和慢速任务线程池
    • 1分钟内超时次数超过10次的任务定义为慢任务。这个统计使用的是1分钟的滑动窗口,每次调度大于500ms算超时,使用慢速线程池
    • 否则使用快速线程池
java 复制代码
fastTriggerPool = new ThreadPoolExecutor(
        10,
        XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax(),
        60L,
        TimeUnit.SECONDS,
        new LinkedBlockingQueue<Runnable>(2000),
        new ThreadFactory() {
            @Override
            public Thread newThread(Runnable r) {
                return new Thread(r, "xxl-job, admin JobTriggerPoolHelper-fastTriggerPool-" + r.hashCode());
            }
        },
        new RejectedExecutionHandler() {
            @Override
            public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
                logger.error(">>>>>>>>>>> xxl-job, admin JobTriggerPoolHelper-fastTriggerPool execute too fast, Runnable="+r.toString() );
            }
        });

slowTriggerPool = new ThreadPoolExecutor(
        10,
        XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax(),
        60L,
        TimeUnit.SECONDS,
        new LinkedBlockingQueue<Runnable>(5000),
        new ThreadFactory() {
            @Override
            public Thread newThread(Runnable r) {
                return new Thread(r, "xxl-job, admin JobTriggerPoolHelper-slowTriggerPool-" + r.hashCode());
            }
        },
        new RejectedExecutionHandler() {
            @Override
            public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
                logger.error(">>>>>>>>>>> xxl-job, admin JobTriggerPoolHelper-slowTriggerPool execute too fast, Runnable="+r.toString() );
            }
        });

高可用:通过统计识别任务类型,通过线程池隔离将风险进行隔离,尽量保证快速任务的正常执行

选取路由策略:(分发执行器逻辑)

  1. FIRST(第一个):固定选择第一个机器执行。
  2. LAST(最后一个):固定选择最后一个机器执行。
  3. ROUND(轮询):按照执行器注册地址轮询分配任务。
  4. RANDOM(随机):随机选择在线的机器执行任务。
  5. CONSISTENT_HASH(一致性 HASH):每个任务按照 Hash 算法固定选择某一台机器,且所有任务均匀散列在不同机器上。
  6. 最不经常使用(LEAST_FREQUENTLY_USED):优先选择使用频率最低的那台机器。
  7. 最近最久未使用(LEAST_RECENTLY_USED):基于机器的使用时间进行选择,优先选择最近最久未使用的机器。
  8. 故障转移(FAILOVER):当首选机器出现故障时,任务会自动转移到其他可用的机器上执行。
  9. 忙碌转移(BUSYOVER):当首选机器处于忙碌状态时,任务会转移到其他机器上执行。
  10. 分片广播:这是一种针对执行时间长的任务的策略,通过将任务分散到各个节点上执行,加快完成速度。

选取阻塞策略:(任务进入执行器后处理逻辑)

  1. 单机串行(默认): 当调度请求进入单机执行器后,它们会进入 FIFO 队列并以串行方式运行。这种策略按照顺序执行任务,不会并行处理多个任务。
  2. 丢弃后续调度:当调度请求进入单机执行器时,如果发现执行器已经存在正在运行的调度任务,本次请求将会被丢弃并标记为失败。这种策略会优先处理当前任务,而忽略后续到达的任务。
  3. 覆盖之前调度:当调度请求进入单机执行器时,如果发现执行器存在运行的调度任务,该策略会终止正在运行的调度任务并清空队列,然后运行新的本地调度任务。

完整任务执行流程

3.调度中心细节

3.1XxlJobScheduler

XxlJobScheduler(定时任务触发器)是 admin sever 初始化的一个bean ,在spring生命周期中的InititlizingBean的 afterPropertiseSet() 方法里初始化, 其中XxlJobScheduler 的init()方法初始化了一个JobScheduleHelper帮助定时出发在admin页面配置的Job

java 复制代码
public void init() throws Exception {
    // init i18n
    initI18n();

    // admin trigger pool start
    JobTriggerPoolHelper.toStart();

    // admin registry monitor run
    JobRegistryHelper.getInstance().start();

    // admin fail-monitor run
    JobFailMonitorHelper.getInstance().start();

    // admin lose-monitor run ( depend on JobTriggerPoolHelper )
    JobCompleteHelper.getInstance().start();

    // admin log report start
    JobLogReportHelper.getInstance().start();

    // start-schedule  ( depend on JobTriggerPoolHelper )
    JobScheduleHelper.getInstance().start();

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

进入到 JobScheduleHelper的 start() 方法, start()方法初始化了2个守护线程:

  • scheduleThread:读取xxl_job_info的status 为 1 的所有任务并通过 pushTimeRing (int ringSecond, int jobId) 执行时间,也就是2.2.2的刷新逻辑是它执行的。 方法将 JobId 和下次执行时间放入到时间轮里,同时根据 cron 表达式刷新下次执行时间,也就是2.2.2 的刷新逻辑是它执行的。
java 复制代码
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) );
}
  • ringThread:轮询时间轮,取出JobId和下次执行时间,触发Trigger
java 复制代码
// second data
List<Integer> ringItemData = new ArrayList<>();
int nowSecond = Calendar.getInstance().get(Calendar.SECOND);   // 避免处理耗时太长,跨过刻度,向前校验一个刻度;
for (int i = 0; i < 2; i++) {
    List<Integer> tmpData = ringData.remove( (nowSecond+60-i)%60 );
    if (tmpData != null) {
        ringItemData.addAll(tmpData);
    }
}

// ring trigger
logger.debug(">>>>>>>>>>> xxl-job, time-ring beat : " + nowSecond + " = " + Arrays.asList(ringItemData) );
if (ringItemData.size() > 0) {
    // do trigger
    for (int jobId: ringItemData) {
        // do trigger
        JobTriggerPoolHelper.trigger(jobId, TriggerTypeEnum.CRON, -1, null, null, null);
    }
    // clear
    ringItemData.clear();
}

ringData是通过时间戳的取余计算出来的,以一分钟为刻度,每一秒可以作为一个key,如果有相同的key,那么计算出来的值会放在map的value,即List里

3.2 发起任务

  • 可以由admin手动发起Http请求给调度中心走trigger流程
java 复制代码
@RequestMapping("/trigger")
@ResponseBody
public ReturnT<String> triggerJob(HttpServletRequest request,
                          @RequestParam("id") int id,
                          @RequestParam("executorParam") String executorParam,
                          @RequestParam("addressList") String addressList) {

   // login user
   XxlJobUser loginUser = PermissionInterceptor.getLoginUser(request);
   // trigger
   return xxlJobService.trigger(loginUser, id, executorParam, addressList);
}

也可以由上述时间环线程发起trigger流程

java 复制代码
// do trigger
JobTriggerPoolHelper.trigger(jobId, TriggerTypeEnum.CRON, -1, null, null, null);

接着进入到JobTriggerPoolHelper的addTrigger()方法,这里使用了线程池异步执行trigger动作。

java 复制代码
/**
 * add trigger
 */
public void addTrigger(final int jobId,
                       final TriggerTypeEnum triggerType,
                       final int failRetryCount,
                       final String executorShardingParam,
                       final String executorParam,
                       final String addressList) {

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

    // trigger
    triggerPool_.execute(new Runnable() {
        @Override
        public void run() {

            long start = System.currentTimeMillis();

            try {
                // do trigger
                XxlJobTrigger.trigger(jobId, triggerType, failRetryCount, executorShardingParam, executorParam, addressList);
            } catch (Throwable e) {
                logger.error(e.getMessage(), e);
            } finally {

                // check timeout-count-map
                long minTim_now = System.currentTimeMillis()/60000;
                if (minTim != minTim_now) {
                    minTim = minTim_now;
                    jobTimeoutCountMap.clear();
                }

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

            }

        }
        @Override
        public String toString() {
            return "Job Runnable, jobId:"+jobId;
        }
    });
}

处理重试策略,路由策略,执行器地址等参数进入processTrigger方法

看看processTrigger 主要做了哪些事情吧

1.保存任务ID,任务组,执行时间等日志信息 2. init trigger-param,创建一个 TriggerParam 实例。

  1. 获取 executor (执行器)的 address,是从 xx1_job_group 表里(根据路由策略)读取出来的一个address,该 address 可自动注册也可在admin 后台手动录入。
  2. 将TriggerParam 和 address 组合,执行 runExecutor (triggerParam, address) 方法,此时调用执行器,发起Post请求将相关信息请求给执行器
java 复制代码
@Override
public ReturnT<String> run(TriggerParam triggerParam) {
    return XxlJobRemotingUtil.postBody(addressUrl + "run", accessToken, timeout, triggerParam, String.class);
}
  1. 执行器中实现类是 ExecutorBizImpl 进入其 run()方法,执行jobThread 的实例化, 如果有 JobId对应了旧的Thread,那么需要用新线程去替换。

run()方法里面是怎么执行的?执行器是怎么实现这个 http 请求到任务执行的调度的呢?

4.客户端运行细节

执行器的initEmbedServer 方法中创建了内置服务器 EmbedServer(底层是由Netty实现的),用于接受请求

java 复制代码
case "/run":
    TriggerParam triggerParam = GsonTool.fromJson(requestData, TriggerParam.class);
    return executorBiz.run(triggerParam);

分析一下run方法里面做了什么

  1. 找个这个任务对应的 JobThread,如果没有,则会注册新的 JobThfead
  2. 找到这个 JobThread 对应的 jobHandler,如果没有,就从 jobHandlerRepository 中获取 jobHandler
  3. jobThread 线程会一直死循环, 从 triggerQueue 中获取参数,执行我们都任务代码

核心是一个 jobId 对应一个 jobHandle 对应一个 jobThread

4.1 注解方式注册 JobHandler

Job处理器是 XxlJob 中调度的单位,也是最终调用目标的任务的载体,所有的 Job 处理器注册在了一个 ConcurrentHashMap 里,其中 map 的 key 为@XxIJob (value='")的 value 值,map的 value 是由一个IJobHandler 接口的实例实现。

生命周期任务示例:任务初始化与销毁时,支持自定义相关逻辑

java 复制代码
/**
 * 5、生命周期任务示例:任务初始化与销毁时,支持自定义相关逻辑;
 */
@XxlJob(value = "demoJobHandler2", init = "init", destroy = "destroy")
public void demoJobHandler2() throws Exception {
    XxlJobHelper.log("XXL-JOB, Hello World.");
}

执行器会维护特定线程扫描注解获取bean,注册到jobHandlerRepository\

java 复制代码
// ---------------------- job handler repository ----------------------
private static ConcurrentMap<String, IJobHandler> jobHandlerRepository = new ConcurrentHashMap<String, IJobHandler>();
public static IJobHandler loadJobHandler(String name){
    return jobHandlerRepository.get(name);
}
public static IJobHandler registJobHandler(String name, IJobHandler jobHandler){
    logger.info(">>>>>>>>>>> xxl-job register jobhandler success, name:{}, jobHandler:{}", name, jobHandler);
    return jobHandlerRepository.put(name, jobHandler);
}
Handler名称 描述
GlueJobHandler 提供Glue任务的处理器,平台脚本
MethodJobHandler 提供常规Bean模式方法Job处理器,整合Spring工程
ScriptJobHandler 提供脚本处理器

核心是MethodJobHandler能完成大部分需求,新版本支持自定义生命周期initMethoddestroyMethod方法重写

4.2 注册 JobThread

JobThread是运行job的线程,可以看作Job线程载体,存放在XxlJobExecutor类里的JobThreadResitory,它也是concurrentMap来做并发安全

java 复制代码
private static ConcurrentMap<String, IJobHandler> jobHandlerRepository = new ConcurrentHashMap<String, IJobHandler>();

注册JobThread方法,每次注册时会将JobId和JobHanler作为参数实例化一个JobThread

java 复制代码
public static JobThread registJobThread(int jobId, IJobHandler handler, String removeOldReason){
    JobThread newJobThread = new JobThread(jobId, handler);
    newJobThread.start();
    logger.info(">>>>>>>>>>> xxl-job regist JobThread success, jobId:{}, handler:{}", new Object[]{jobId, handler});

    JobThread oldJobThread = jobThreadRepository.put(jobId, newJobThread); // putIfAbsent | oh my god, map's put method return the old value!!!
    if (oldJobThread != null) {
        oldJobThread.toStop(removeOldReason);
        oldJobThread.interrupt();
    }

    return newJobThread;
}

避免重复进行:校验 jobThreadRepository 的返回值,如果已经存在便停掉旧线程,这样能始终保证只有一个线程为job服务,避免有些情况下回出现任务重复执行,发生定时错乱问题。

线程注册完了,最终的逻辑是在线程的run方法执行的,再看看JobThread的run方法里面做了什么吧

  1. 如果注解中有init方法,先执行init方法

  2. 从triggerQueue 队列中取出任务执行的参数,最多阻塞3s

  3. 记录任务执行日志

  4. 执行jobHandler任务逻辑

    • 如果任务执行失败,就记录失败信息到日志中
    • 最后把执行结果放在TriggerCallBackThread线程的callBackQueue队列中,TriggerCallBackThread线程回调这些结果
  5. 如果任务停止的话,就调用任务的destory方法

4.3 任务达成机制

核心是xx1_job_1og

  1. 调度中心(Scheduler):负责触发任务,更新任务的触发状态。当任务满足执行条件时,调度中心会更新trigger_code字段为200,表示任务已经触发。
  2. 执行器(Executor):当执行器接收到调度中心的任务执行请求时,它会开始执行任务。在任务执行过程中, 执行器会周期性地或在任务执行结束时更新xx1_job_log表中的 handle_code字段:
    • 如果任务执行中,可能会暂时设置handle_code为0。
    • 当任务执行成功时,执行器会将handle_code更新为200。
    • 如果任务执行失败,执行器会将handle_code 更新为500或其他表示失败的值。
  3. 回调机制:执行器在任务执行结束后,会向调度中心发送回调信息,告知任务执行的结果。调度中心根据回调信息更新xx1_job_log表中的相关字段。
  4. 监控线程(Monitor Thread):xxl-job 内部的监控线程会定期检查任务执行情况,如果发现任务长时间未完成或存在异常,可能会更新任务状态或进行相应的处理。
  5. 日志记录:在任务执行过程中,执行器会记录日志信息。这些日志信息可能包括任务开始、执行进度、异常信息和任务结束等,但实际的字段更新是由执行器和调度中心的交互完成的。
  6. 失败处理:如果任务执行失败,xxl-job 提供了失败重试机制,重试成功后会更新handle_code为200,否则会保留失败的状态码。

任务执行判断属于系统自动执行,可以通过SQL查询执行失败任务,盘点失败率

相关推荐
Asthenia04123 小时前
浏览器缓存机制深度解析:电商场景下的性能优化实践
后端
databook4 小时前
『Python底层原理』--Python对象系统探秘
后端·python
超爱吃士力架5 小时前
MySQL 中的回表是什么?
java·后端·面试
追逐时光者5 小时前
Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
后端·.net
苏三说技术6 小时前
10亿数据,如何迁移?
后端
bobz9656 小时前
openvpn 显示已经建立,但是 ping 不通
后端
customer087 小时前
【开源免费】基于SpringBoot+Vue.JS个人博客系统(JAVA毕业设计)
java·vue.js·spring boot·后端·开源
qq_459238497 小时前
SpringBoot整合Redis和Redision锁
spring boot·redis·后端
灰色人生qwer7 小时前
SpringBoot 项目配置日志输出
java·spring boot·后端
阿华的代码王国7 小时前
【从0做项目】Java搜索引擎(6)& 正则表达式鲨疯了&优化正文解析
java·后端·搜索引擎·正则表达式·java项目·从0到1做项目