分布式定时任务XXL-Job源码阅读

712 阅读35分钟

首先什么是分布式

分布式:把一个大业务拆分成多个子业务,每个子业务都是一套独立的系统,子业务之间相互协作最终完成整体的大业务。
集群:把处理同一个业务的系统部署多个节点 。部署在不同服务器上的相同系统必然要做负载均衡。 集群主要是简单加机器解决问题,对于问题本身不做任何分解

集群和分布式都是由多个节点组成,但集群中各节点间基本不需要通信协调,而分布式中各个节点的通信协调是必不可少的

比如我们一个用户服务集群之间都没啥通信,但是用户服务和订单服务构成一个分布式系统经常要相互调用

但其实往往说分布式系统都有做集群,因为高可用也是分布式集群的特性。比如分布式数据库都有对数据进行副本处理。

分布式数据库:其实也符合上面的定义,首先分布式数据库要对数据进行切分,毕竟单机能存储和处理的数据有限,同时某一份数据还需要拷贝副本到其他机器保证高可用,这就是集群的体现。
其实分布式数据库要比分布式web系统要复杂的多,不仅仅是简单的拆分。还要考虑分布式事务,网络故障和数据一致性问题,Raft就常用在分布式数据库中解决网络分区故障和数据一致性问题。但WEB系统是没有状态的,状态就是数据(数据都在数据库中),连数据都没有哪里来的数据一致性。数据库解决数据一致性现在都是通过Raft协议来解决的,详细可以看相关论文。

但有些特殊操作会让WEB系统有状态,比如将数据缓存到WEB系统内存中,这种既要保证WEB系统和数据库的一致性还要保证WEB系统集群的一致性,前者容易后者就麻烦了。比如casbin就有这样的问题。但实质上WEB系统集群没有必要保证一致性,最好就是不存储数据库或者都通过最终一致性和数据库保持一致就行了

[分布式系统]具体如下基本特点:
1、分布性:每个部分都可以独立部署,服务之间交互通过网络进行通信,比如:订单服务、商品服务。
2、伸缩性:每个部分都可以集群方式部署,并可针对部分结点进行硬件及软件扩容,具有一定的伸缩能力。
3、高可用:每个部分都可以集群部分,保证高可用。

# 什么是分布式数据库?我不信,看完这篇你还不懂!

什么是分布式定时任务?为啥需要分布式定时任务?

普通的定时任务在单机环境下确实没啥问题,集群环境下普通的定时任务就不能很好解决下面的问题:

  1. 当我们希望集群环境下只有一个机器执行定时任务
  2. 当我们希望集群环境下多个机器分摊定时任务
  3. 以及对于SpringBoot的Schedule不能动态增减等

有的人会说普通的定时任务没有高可用,这个分布式定时任务也会有,本质是集群副本问题

以SpringBoot的Schedule为例,在集群环境下多个机器会执行一样的任务。
对于【问题1】会导致重复执行
对于【问题2】会导致发挥不出集群性能,难以胜任
有的人会说我们可以从代码上做处理:

  1. 取模:这样web服务就引入状态了,而且服务增减就有遗漏任务的风险了
  2. 加锁抢占:使用Spring cron+数据库锁,其实抢占锁也可以看做服务间通信的一种方式,但这种方式编码有些麻烦,特别对于异常情况可能处理不好,而且也不灵活,不能动态增加定时任务

对应场景1:好比给A,B,C分配一个任务,让他们一起合作抄一本书
如果将A,B,C分别关在一个房间里不让他们交流就会导致A,B,C都只能从头抄,工作大量重复,可能会觉得奇怪,怎么会不能交流呢?但实际中一个集群的多个实例之间也是基本没任何通信的

如果直接让他们互相交流呢?此时人多了就会比较混乱,而且还要抢占和协调抄写任务,为了做到高可用甚至每个人都要监听其他人的信息以及每个人是否异常。比如A广播说我抄写第1页,B广播说我抄写第2页,C广播说我抄写第2页....

这种场景的最优解就是由一个领导者服务LeaderService来统筹管理分配任务,监听A,B,C的健康状态,分配任务并记录任务完成情况失败情况以及重新分配。这个时候大家只用听领导者分配,只用和领导者交互就可以了

单独整一个服务来管理分配任务以及监听工作服务健康状态,和工作服务构成了一个分布式系统。如果给LeaderService再加上定时逻辑,那他就是一个分布式的定时任务系统。 其中这个领导者LeaderService我们叫做分布式定时任务器(对应到后面的XXL-JOB就叫调度中心,A/B/C都叫执行器)

总的来说分布式定时任务可以带来如下收益:与具体业务分离,通用性,高性能,弹性扩容,高可用,任务管理与监测,避免任务重复执行,完备的异常处理机制

为什么要选择XXL-Job以及为什么要读XXL-JOB源码

选择使用它的原因:用的人多,锻炼充足。依赖少仅仅依赖MySQL,同时源码不多逻辑清晰方便二开。同时其核心源码1w左右,相比Spring以及其他动辄几十万行源码的框架源码更适合学习。其实也涉及很多知识点,比如Netty使用,时间轮,自研RPC,服务注册发现,Spring扩展点,看完后发现这些东西实现也不是很难,而且项目中作者有很多巧妙的设计。

如何去实现一个分布式定时任务器

看源码的一些心得:
先知道需求场景,然后想想自己做会如何实现?分哪几个部分?然后再去看看源码架构,对比下和自己实现是否一样?以及为啥不一样,然后再具体看源码细节。

XXL-JOB架构图已经很清晰了。但始终是大佬写的,自己照着博客看每个部分都感觉很合理,但总感觉整体把握差了些东西。所以我们先构思下如果自己去按照【对应场景1】自己写如何实现

这里我虽然提前看过可能下面说的一些有先入为主的成分,但基本没看过的人可以推出来的功能

按照【对应场景1】先看看需要哪几个部分以及哪些功能:
首先需要一个领导者来分配任务,那对应就是一个领导者的WEB服务。根据分布式系统的设计原则各个业务操作自己业务数据库,所以定时器和具体定时业务处理逻辑应该是分离的。也就是分布式定时任务只关注定时处理逻辑具体怎么处理交给具体执行服务完成。那么这个领导者和执行者我们分别叫做【分布式定时任务器】和【执行器】需要做哪些事情:

  • 最基础的定时任务的增删改查

    提供相应任务增删改查API,任务执行时间,以及要执行的任务,还有参数。这里任务如何表示?
    最简单的实现就是接口且通用性较好。填入执行时间和接口地址eg:0 */1 * * * ? 用户积分定时发放接口user/integral/send

  • 记录执行服务的存活情况

    这里就涉及到服务注册通信了,领导者需要知道有哪几个执行服务存活。便于分配任务,这里就涉及到服务注册通信如何实现的问题了?最简单的还是接口,领导者提供/service/register服务注册接口和保活接口

  • 定时任务分配给业务服务执行以及记录任务执行情况

    这块应该最复杂,主要问题如下:

    1. 定时任务如何高效编排触发?
      最简单的就是遍历计算存储的任务和当前时间是否匹配,但多了效率低下。业界普遍采用时间轮,添加任务时算出下次执行时间,然后取的时候排序,依次遍历

    2. 如果任务暴露为接口的形式,那分配就是定时任务器调用执行服务的接口

    3. 同时执行任务服务可能出现异常或者网络不通或者运行超时,这些都要记录下来

    4. 如果失败了是否需要重试逻辑?是否需要报警?
      这里可以让用户配置具体重试逻辑和次数

    5. 可能出现上次任务没完成这次又来了这种情况如何应对?
      交给用户配置

    6. 如果执行任务过长过多导致后续任务延期了如何处理?
      交给用户配置,也需要记录未执行原因。而且当任务太多堆积了也需要报警

    7. 如何处理重复调用?调用成功了执行成功了但返回时网络异常这种怎么防重?
      首先业务端

  • 分布式定时任务器的高可用

    高可用就是集群,对分布式定时任务器做集群。那么集群后任务如何分配?如何在保证不漏的基础上防止重复执行

XXL-JOB实现

XXL-JOB也是采用定时器和具体定时处理业务应该是分离的
源码中xxl-job-admin模块为XXL-JOB【调度中心】也就是【分布式定时任务器】,负责管理分配任务,同时xxl-job-admin也依赖了xxl-job-core模块,xxl-job-core就像client包一样包含了调用所需的很多公共实体类等

源码模块xxl-job-core模块为执行器,哪个服务要执行定时任务就要引入xxl-job-core的jar包。这个jar包可以完成服务注册,心跳维持,任务执行以及执行情况上报。这些都是通过内嵌的Netty实现的,其本质就是接口调用,这里为啥要内嵌Netty实现接口调用之后讨论

admin和core之间的远程调用,心跳维持,负载均衡的实现依赖了作者的另外一个项目XXl-RPC,其中作者对XXL-RPC的定义为:
XXL-RPC 是一个分布式服务框架,提供稳定高性能的RPC远程服务调用功能。拥有”高性能、分布式、注册中心、负载均衡、服务治理”等特性

看到这里也发现RPC没那么神秘,RPC全称是Remote Procedure Call Protocol 远程过程调用,调用远程服务就像调用本地服务,在提供远程调用能力时不损失本地调用的语义简洁性,不同的RPC实现都是围绕性能和可用性做优化

先看下表设计就大致知道工作逻辑了

XXL-JOB数据库表设计,总共8张表:

  • xxl_job_group

    执行器分组表,维护任务执行器信息。一个执行器组可能会有多个执行器和xxl_job_registry为1对n关系

    image.png

  • xxl_job_registry

    执行器注册表,维护在线的执行器和调度中心机器地址信息,记录心跳时间,多个实例就有多份

    执行器启动线程每隔30秒向注册表xxl_job_registry请求一次,更新执行器的心跳信息
    调度中心启动线程每隔30秒检测一次xxl_job_registry,将超过90秒还没有收到心跳的实例信息从xxl_job_registry删除,并更新xxl_job_group服务的实例列表信息

    image.png

  • xxl_job_info(重要)

    任务信息表,重点中的重点!如:任务所属执行器,任务名,入参,调度时间等待如下

  • xxl_job_lock

    锁表,用于避免多个调度中心集群重复执行任务

  • xxl_job_log(重要)

    调度日志表: 用于保存XXL-JOB任务调度的历史信息,如调度结果、执行结果、调度入参、调度机器和执行器等等

  • xxl_job_log_report

    调度日志报表:用户存储XXL-JOB任务调度日志的报表,调度中心报表功能页面会用到

  • xxl_job_logglue(重要)

    任务GLUE日志:用于保存GLUE更新历史,用于支持GLUE的版本回溯功能

  • xxl_job_user

    系统用户表

其中最重要且复杂点的就只有两张表:xxl_job_infoxxl_job_log,表详情如下:

   -- 任务表
   CREATE TABLE `xxl_job_info` (
    `id` int NOT NULL AUTO_INCREMENT,
    `job_group` int NOT NULL COMMENT '执行器主键ID',
    `job_desc` varchar(255) NOT NULL,
    `add_time` datetime DEFAULT NULL,
    `update_time` datetime DEFAULT NULL,
    `author` varchar(64) DEFAULT NULL COMMENT '作者',
    `alarm_email` varchar(255) DEFAULT NULL COMMENT '报警邮件',
    `schedule_type` varchar(50) NOT NULL DEFAULT 'NONE' COMMENT '调度类型',
    `schedule_conf` varchar(128) DEFAULT NULL COMMENT '调度配置,值含义取决于调度类型',
    `misfire_strategy` varchar(50) NOT NULL DEFAULT 'DO_NOTHING' COMMENT '调度过期策略',
    `executor_route_strategy` varchar(50) DEFAULT NULL COMMENT '执行器路由策略',
    `executor_handler` varchar(255) DEFAULT NULL COMMENT '执行器任务handler',
    `executor_param` varchar(512) DEFAULT NULL COMMENT '执行器任务参数',
    `executor_block_strategy` varchar(50) DEFAULT NULL COMMENT '阻塞处理策略',
    `executor_timeout` int NOT NULL DEFAULT '0' COMMENT '任务执行超时时间,单位秒',
    `executor_fail_retry_count` int NOT NULL DEFAULT '0' COMMENT '失败重试次数',
    `glue_type` varchar(50) NOT NULL COMMENT 'GLUE类型',
    `glue_source` mediumtext COMMENT 'GLUE源代码',
    `glue_remark` varchar(128) DEFAULT NULL COMMENT 'GLUE备注',
    `glue_updatetime` datetime DEFAULT NULL COMMENT 'GLUE更新时间',
    `child_jobid` varchar(255) DEFAULT NULL COMMENT '子任务ID,多个逗号分隔',
    `trigger_status` tinyint NOT NULL DEFAULT '0' COMMENT '调度状态:0-停止,1-运行',
    `trigger_last_time` bigint NOT NULL DEFAULT '0' COMMENT '上次调度时间',
    `trigger_next_time` bigint NOT NULL DEFAULT '0' COMMENT '下次调度时间',
    PRIMARY KEY (`id`)
  ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

xxl_job_info表中可以看到我们之前规划功能对应的字段,比如:执行任务参数,阻塞策略(同一个任务前面没执行完又来了),报警邮件,调度过期策略(负载高了可能会出现不能按时执行),任务执行超时时间,失败重试次数。

但还有些我们没见过的,比如:executor_handler,glue_source类型。我们之前的设计是调度中心调度执行器是通过RestApi的形式,但XXL-JOB采用了如下两种模式:BEANGLUE。但他们的入口都是调度中心调用执行器的接口/run,然后根据执行方式和参数进行派发到具体处理逻辑,有点类似原来servet处理模式
1. BEAN模式

image.png

BEAN模式下根据任务填写的JobHandler名称(必填)找到对应名称@XxlJob("jobHandlerName")的Job处理器执行,这种模式需要提前写好JobHandler定时任务处理逻辑,适合处理复杂逻辑的定时任务

@Component
public class SampleXxlJob {
    //定时任务方法加上@XxlJob注解
    @XxlJob("demoJobHandler")
    public void demoJobHandler() throws Exception {
        System.out.println("XXL-JOB, Hello World.");
        for (int i = 0; i < 5; i++) {
            TimeUnit.SECONDS.sleep(2);
        }
        // default success
    }

2. GLUE模式

image.png

在新增任务时可以选择GLUE模式,此时JobHandler不用填写,新增后需要在线编写任务代码脚本。支持Java,Python,Shell,PHP,Nodejs,PowerShell,除了Java其他几个都属于脚本,需要安装对应环境依赖

GLUE模式下调度中心会将【代码脚本作为参数传递给执行器!!!】

若是Java代码被GroovyClassLoader加载到Jvm,然后注入到Spring中

若是其他脚本则是通过Process process = Runtime.getRuntime().exec(scriptContent)执行

GLUE模式只是相对灵活一点点!做不了复杂任务。拿GLUE下的Java脚本代码来说首先要确保当前脚本依赖的Jar包存在,你不可能脱离项目在XXL-JOB提供的在线IDE写很复杂的逻辑吧

可以看到GLUE java代码被作为参数传递到了执行器 image.png

GLUE模式Java代码

package com.xxl.job.service.handler;

import com.xxl.job.core.context.XxlJobHelper;
import com.xxl.job.core.handler.IJobHandler;

import com.test.things.service.impl.sys.SysUserServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;

public class DemoGlueJobHandler extends IJobHandler {
  
    @Autowired
    private ApplicationContext applicationContext;
    
    @Autowired
    private SysUserServiceImpl userService;

	@Override
	public void execute() throws Exception {
		XxlJobHelper.log("XXL-JOB, Hello World.");
      
      System.out.println(applicationContext);
        System.out.println(userService.getClass().getName());
    }

}

GLUE模式Python代码

import requests
import time
 
def xxl_glue_task():
    while True:
        # 调用接口
        call_interface()
 
        # 定时任务间隔,这里设置为1分钟
        time.sleep(60)
 
def call_interface():
    url = 'http://example.com/api'
    headers = {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer xxxxxxxx' # 填写你的接口的身份验证信息
    }
    data = {
        'param1': 'value1',
        'param2': 'value2'
    }
 
    try:
        response = requests.post(url, json=data, headers=headers)
        if response.status_code == 200:
            print('接口调用成功')
        else:
            print('接口调用失败')
    except requests.exceptions.RequestException as e:
        print('接口调用出现异常:', str(e))
 
if __name__ == '__main__':
    xxl_glue_task()

xxl_job_info表主要记录任务执行情况

 -- 执行日志表
        CREATE TABLE `xxl_job_log` (
      `id` bigint NOT NULL AUTO_INCREMENT,
      `job_group` int NOT NULL COMMENT '执行器主键ID',
      `job_id` int NOT NULL COMMENT '任务,主键ID',
      `executor_address` varchar(255) DEFAULT NULL COMMENT '执行器地址,本次执行的地址',
      `executor_handler` varchar(255) DEFAULT NULL COMMENT '执行器任务handler',
      `executor_param` varchar(512) DEFAULT NULL COMMENT '执行器任务参数',
      `executor_sharding_param` varchar(20) DEFAULT NULL COMMENT '执行器任务分片参数,格式如 1/2',
      `executor_fail_retry_count` int NOT NULL DEFAULT '0' COMMENT '失败重试次数',
      `trigger_time` datetime DEFAULT NULL COMMENT '调度-时间',
      `trigger_code` int NOT NULL COMMENT '调度-结果',
      `trigger_msg` text COMMENT '调度-日志',
      `handle_time` datetime DEFAULT NULL COMMENT '执行-时间',
      `handle_code` int NOT NULL COMMENT '执行-状态',
      `handle_msg` text COMMENT '执行-日志',
      `alarm_status` tinyint NOT NULL DEFAULT '0' COMMENT '告警状态:0-默认、1-无需告警、2-告警成功、3-告警失败',
      PRIMARY KEY (`id`),
      KEY `I_trigger_time` (`trigger_time`),
      KEY `I_handle_code` (`handle_code`)
    ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

结合XXL-JOB架构图看源码

image.png XXL-JOB官方文档 XXL-JOB主要分为调度中心执行器,在项目代码结构上分别是: xxl-job-admin和xxl-job-core,其实前者里面还包含后者

XXL-JOB调度中心

XXL-JOB调度中心中最重要的是任务管理 其次 执行器管理,其中还包括WEB管理相关接口,如:报表查看,任务管理,调度日志,执行器管理等接口,这些增删改查逻辑基本可以暂时忽略不看,唯一值得看的就是【新增任务】部分

image.png

  • 任务管理

    任务管理负责任务的增删改查,任务调度【重要】,任务执行情况监控,任务完成回调记录等功能。任务会被调度到执行器执行。其中会涉及到时间轮,调度策略,分布式任务抢占

  • 执行器管理

    负责管理注册到调度中心的执行器,通过心跳时间来维护执行器存活状态

image.png 【任务管理】和【执行器管理】最重要的代码如上图所示,其中Helper结尾的类都包含start方法,负责初始化响应功能

【任务管理】部分重要源码

XxlJobScheduler: 基本无实质功能代码,主要是初始化任务管理和执行器管理

public class XxlJobScheduler  {
    private static final Logger logger = LoggerFactory.getLogger(XxlJobScheduler.class);

    public void init() throws Exception {
        // init i18n
        initI18n();
        //最核心
        JobTriggerPoolHelper.toStart();
        JobRegistryHelper.getInstance().start();
        JobFailMonitorHelper.getInstance().start();
        JobLogReportHelper.getInstance().start();
        //下面两个都依赖JobTriggerPoolHelper
        JobCompleteHelper.getInstance().start();
        JobScheduleHelper.getInstance().start();
       
        logger.info(">>>>>>>>> init xxl-job admin success.");
    }

    //  缓存了执行器,避免每次new
    private static ConcurrentMap<String, ExecutorBiz> executorBizRepository = new ConcurrentHashMap<String, ExecutorBiz>();
    public static ExecutorBiz getExecutorBiz(String address) throws Exception {
        if (address==null || address.trim().isEmpty()) {
            return null;
        }
        address = address.trim();
        ExecutorBiz executorBiz = executorBizRepository.get(address);
        if (executorBiz != null) {
            return executorBiz;
        }
        executorBiz = new ExecutorBizClient(address, XxlJobAdminConfig.getAdminConfig().getAccessToken());
        executorBizRepository.put(address, executorBiz);
        return executorBiz;
    }
}

JobScheduleHelper(下面代码使用卫语句优化了但逻辑一致):整个调度中心最核心部分!!!分为任务读取和时间轮部分 任务读取大致流程:预读 now+5s 之前执行的任务将其分为三类

  1. 执行时间已经过了5s的,根据配置过期策略选择立刻执行还是丢弃
  2. 过了但在5s内的立刻执行,并判断是否下次执行时间小于now+5s如果是的再次预读该任务,如果不是此任务执行完成
  3. 如果执行时间大于now放入时间轮中

另外所有情况都会刷新下次执行时间,不管是否丢弃,运行还是放入时间轮中的
任务读取在正常情况是1s进行一次,开始时刻一般为1s的起点
时间轮也是1s运行一次,每次取出当前时间秒刻度需要执行的任务执行

image.png

理想状态下每s读取一次任务表,那么会不会出现任务太多,导致一次处理超过1s,确实可能。 但问题不大已经预读了5s的,就算超过5s也不会丢失任务。因为他会读取的是now+5s之前的,但确实会延迟,对于长期来说是瓶颈所在

public class JobScheduleHelper {
    private static final Logger logger = LoggerFactory.getLogger(JobScheduleHelper.class);

    private static final JobScheduleHelper instance = new JobScheduleHelper();
    // 预读5s内的
    public static final long PRE_READ_MS = 5000;

    //定时读取调度任务线程
    private Thread scheduleThread;

    //时间轮线程
    private Thread ringThread;

    //控制是否停止任务读取
    private volatile boolean scheduleThreadToStop = false;

    //控制是否停止时间轮
    private volatile boolean ringThreadToStop = false;

    //时间轮实现,分别放入 key为1-60 每个key代表当前s要执行的任务集合
    private static final Map<Integer, List<Integer>> ringData = new ConcurrentHashMap<>();

    public void start() {

        // 定时读取调度任务线程
        scheduleThread = new Thread(() -> {
            try {
                // 启动时候随机休眠4s到5s,为啥启动要休眠?
                TimeUnit.MILLISECONDS.sleep(5000 - System.currentTimeMillis() % 1000);
            } catch (InterruptedException e) {}

            // 预读数量:按照平均每个任务50ms来计算qps为20,然后乘以:快任务线程池和慢任务线程池数量之和
            int preReadCount = (XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax() + XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax()) * 20;
            // 运行期间无限循环
            while (!scheduleThreadToStop) {
                long start = System.currentTimeMillis();
                Connection conn = null;
                Boolean connAutoCommit = null;
                PreparedStatement preparedStatement = null;

                boolean preReadSuc = true;
                try {
                    conn = XxlJobAdminConfig.getAdminConfig().getDataSource().getConnection();
                    connAutoCommit = conn.getAutoCommit();
                    //开启事务
                    conn.setAutoCommit(false);
                    //加锁(表级别读锁!!!)保持此时只有一个调度器扫描任务!!!防止重复扫描
                    preparedStatement = conn.prepareStatement("select * from xxl_job_lock where lock_name = 'schedule_lock' for update");
                    preparedStatement.execute();

                    long nowTime = System.currentTimeMillis();
                    //预读nextTime < currentTime + 5s 任务,
                    List<XxlJobInfo> scheduleList = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleJobQuery(nowTime + PRE_READ_MS, preReadCount);
                    if (scheduleList == null || scheduleList.isEmpty()) {
                        preReadSuc = false;
                        sleep(start, false);
                        continue;
                    }
                    // 获取任务不为空
                    for (XxlJobInfo jobInfo : scheduleList) {
                        // 1. 如果当前时间已经大于 任务计划执行时间+5s了,就是超时时间超过5s了
                        if (nowTime > jobInfo.getTriggerNextTime() + PRE_READ_MS) {
                            // 查看任务配置策略:是否立即执行或者丢弃
                            MisfireStrategyEnum misfireStrategyEnum = MisfireStrategyEnum.match(jobInfo.getMisfireStrategy(), MisfireStrategyEnum.DO_NOTHING);
                            if (MisfireStrategyEnum.FIRE_ONCE_NOW == misfireStrategyEnum) {
                                // 立即执行
                                JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.MISFIRE, -1, null, null, null);
                            }
                            // 刷新下次执行时间,以当前时间为基准计算下次时间
                            refreshNextValidTime(jobInfo, new Date());
                            continue;
                        }

                        // 2. 如果当前时间大于 任务计划执行时间 但大的不多没超过5s
                        if (nowTime > jobInfo.getTriggerNextTime()) {
                            //立即执行
                            JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.CRON, -1, null, null, null);
                            //刷新下次执行时间
                            refreshNextValidTime(jobInfo, new Date());
                            //同时若触发成功 且刷新后的下次执行时间在未来5s之内,再次预读进来
                            if (jobInfo.getTriggerStatus() == 1 && nowTime + PRE_READ_MS > jobInfo.getTriggerNextTime()) {
                                //计算出在时间轮中的位置放入对应刻度,1s一个刻度总共60个
                                int ringSecond = (int) ((jobInfo.getTriggerNextTime() / 1000) % 60);
                                pushTimeRing(ringSecond, jobInfo.getId());
                                //再次刷新下次执行时间
                                refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
                            }
                            continue;
                        }

                        // 3. 当前时间大于 任务计划执行时间 直接放入时间轮
                        int ringSecond = (int) ((jobInfo.getTriggerNextTime() / 1000) % 60);
                        pushTimeRing(ringSecond, jobInfo.getId());
                        // 刷新下次执行时间,以下次时间为基准计算下次时间
                        refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
                    }
                    
                    //更新任务执行状态,上次执行时间,下次执行时间
                    //这里无论何种情况都会更新!!!
                    for (XxlJobInfo jobInfo : scheduleList) {
                        XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleUpdate(jobInfo);
                    }
                } catch (Exception e) {
                    if (!scheduleThreadToStop) {
                        logger.error(">>>>>>>>>>> xxl-job, JobScheduleHelper#scheduleThread error:", e);
                    }
                } finally {
                    //提交事务释放读锁,让其他调度器执行,省略
                }
                // 看情况适当休眠
                sleep(start, preReadSuc);
            }
        });
        scheduleThread.setDaemon(true);
        scheduleThread.setName("xxl-job, admin JobScheduleHelper#scheduleThread");
        scheduleThread.start();
        
        
        // 时间轮线程 主要处理在5s内执行的任务, 但轮刻度是60
        ringThread = new Thread(() -> {
            while (!ringThreadToStop) {
                try {
                    // 休眠到下一秒开始。如果后面代码1s之内运行完成,这里能保证每s循环一次,执行完也都是每s开始毫秒
                    // 如果后面代码超过1s可能会导致遗漏,但很少因为整个处理都在内存中,提交任务也只是交给线程池了,所以每s处理量可以很大百万级别
                    TimeUnit.MILLISECONDS.sleep(1000 - System.currentTimeMillis() % 1000);
                } catch (InterruptedException e) {
                    if (!ringThreadToStop) {
                        logger.error(e.getMessage(), e);
                    }
                }

                try {
                    List<Integer> ringItemData = new ArrayList<>();
                    // 避免太多了处理耗时太长,跨过刻度,向前校验一个刻度。即读取当前这秒和上1秒的,为啥是上一个刻度不是下一个刻度?
                    // 情况1:1s的刻度运行完成到了0s900ms,结果0s920ms又有任务进环了那还是落在0s刻度内,但当前处理的是刻度1s如果不向前可能漏掉
                    // 情况2:时间轮0刻度处理任务太多了,导致处理花了1.5s,0+1.5 休眠 0.5s 直接处理2s的刻录任务了。把1s的刻度漏掉了。此时向前读取也可以处理到
                    // 其实时间轮处理超时和定时获取任务超时1s都会有问题,建议还是不要超过1s
                    int nowSecond = Calendar.getInstance().get(Calendar.SECOND);
                    for (int i = 0; i < 2; i++) {
                        //当前s和当前-1s,-1后可能是负数所以需要先+60,加入当前时间秒数为0
                        //0 + 60 - 1 = 59 运行s数余数59的任务,而刚好新加的任务余数也是59会有这种情况吗?不会,因为只预取5s,轮里老的和最新的跨度不会有60s
                        List<Integer> tmpData = ringData.remove((nowSecond + 60 - i) % 60);
                        if (tmpData != null) {
                            ringItemData.addAll(tmpData);
                        }
                    }

                    if (ringItemData.isEmpty()) {
                        continue;
                    }
                    for (int jobId : ringItemData) {
                        //触发任务
                        JobTriggerPoolHelper.trigger(jobId, TriggerTypeEnum.CRON, -1, null, null, null);
                    }
                    ringItemData.clear();
                } catch (Exception e) {
                    if (!ringThreadToStop) {
                        logger.error(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread error:", e);
                    }
                }
            }
        });
        ringThread.setDaemon(true);
        ringThread.setName("xxl-job, admin JobScheduleHelper#ringThread");
        ringThread.start();
    }


    private void sleep(long start, boolean preReadSuc) {
        long cost = System.currentTimeMillis() - start;
        //花费少于1s休眠,超过1s不休眠
        if (cost < 1000) {
            return;
        }
        try {
            //如果预读成功休眠1s,休眠到下一秒开始。以内保证一次循环为1s
            TimeUnit.MILLISECONDS.sleep((preReadSuc ? 1000 : PRE_READ_MS) - System.currentTimeMillis() % 1000);
        } catch (InterruptedException e) {
            if (!scheduleThreadToStop) {
                logger.error(e.getMessage(), e);
            }
        }
    }

    // 计算下次执行时间
    private void refreshNextValidTime(XxlJobInfo jobInfo, Date fromTime) throws Exception {
        Date nextValidTime = generateNextValidTime(jobInfo, fromTime);
        if (nextValidTime != null) {
            jobInfo.setTriggerLastTime(jobInfo.getTriggerNextTime());
            jobInfo.setTriggerNextTime(nextValidTime.getTime());
        } else {
            jobInfo.setTriggerStatus(0);
            jobInfo.setTriggerLastTime(0);
            jobInfo.setTriggerNextTime(0);
        }
    }

    private void pushTimeRing(int ringSecond, int jobId) {
        //加入时间轮
        List<Integer> ringItemData = ringData.computeIfAbsent(ringSecond, k -> new ArrayList<>());
        ringItemData.add(jobId);
    }

    public void toStop() {
        //省略
    }


    // 生成下次执行时间
    public static Date generateNextValidTime(XxlJobInfo jobInfo, Date fromTime) throws Exception {
        ScheduleTypeEnum scheduleTypeEnum = ScheduleTypeEnum.match(jobInfo.getScheduleType(), null);
        if (ScheduleTypeEnum.CRON == scheduleTypeEnum) {
            Date nextValidTime = new CronExpression(jobInfo.getScheduleConf()).getNextValidTimeAfter(fromTime);
            return nextValidTime;
        } else if (ScheduleTypeEnum.FIX_RATE == scheduleTypeEnum /*|| ScheduleTypeEnum.FIX_DELAY == scheduleTypeEnum*/) {
            return new Date(fromTime.getTime() + Integer.parseInt(jobInfo.getScheduleConf()) * 1000);
        }
        return null;
    }

XxlJobTrigger:具体执行器调用逻辑都在该类中,包括任务的负载均衡,阻塞策略,执行日志记录,执行器调用,调用结果记录等

public class XxlJobTrigger {
   

    public static void trigger(int jobId,
                               TriggerTypeEnum triggerType,
                               int failRetryCount,
                               String executorShardingParam,
                               String executorParam,
                               String addressList) {

        // 根据id获取任务详细信息
        XxlJobInfo jobInfo = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().loadById(jobId);
        if (jobInfo == null) {
            logger.warn(">>>>>>>>>>>> trigger fail, jobId invalid,jobId={}", jobId);
            return;
        }
        // 设置执行参数
        if (executorParam != null) {
            jobInfo.setExecutorParam(executorParam);
        }
        // 失败次数
        int finalFailRetryCount = failRetryCount >= 0 ? failRetryCount : jobInfo.getExecutorFailRetryCount();
        // 获取 执行器分组中的执行器注册地址
        XxlJobGroup group = XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().load(jobInfo.getJobGroup());

        // 界面上可以填写地址, 界面上填写的地址会覆盖xxl_jog_group 中的 address_list, 并将此次类型更改为手动注册
        if (addressList != null && !addressList.trim().isEmpty()) {
            group.setAddressType(1);
            group.setAddressList(addressList.trim());
        }
        // 分片参数作用,应对大量任务设计,其他负载均衡模式每次只有一个被调用,但是分片模式每个都会调用,然后处理器端根据分片参数计算自己需要处理的部分
        // 执行器任务分片参数,格式如 0/2  这里默认为null, 只有重试那传入可能不为空(重试时要记住对应分片), 其他情况这里的shardingParam全部为空
        int[] shardingParam = null;
        if (executorShardingParam != null) {
            String[] shardingArr = executorShardingParam.split("/");
            if (shardingArr.length == 2 && isNumeric(shardingArr[0]) && isNumeric(shardingArr[1])) {
                shardingParam = new int[2];
                shardingParam[0] = Integer.parseInt(shardingArr[0]);
                shardingParam[1] = Integer.parseInt(shardingArr[1]);
            }
        }
        // 读取任务详情配置的负载均衡策略, 如果是分片广播且执行器不为空且有分片参数
        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 {
            //如果不是分片广播, 设置分配参数为0,1. 其中0代表当前调用执行器索引,1代表执行器总数
            // 这里可能执行器都下线了,后面会判断
            if (shardingParam == null) {
                shardingParam = new int[]{0, 1};
            }
            processTrigger(group, jobInfo, finalFailRetryCount, triggerType, shardingParam[0], shardingParam[1]);
        }

    }


    private static void processTrigger(XxlJobGroup group, XxlJobInfo jobInfo, int finalFailRetryCount, TriggerTypeEnum triggerType, int index, int total) {

        // 阻塞时执行策略
        ExecutorBlockStrategyEnum blockStrategy = ExecutorBlockStrategyEnum.match(jobInfo.getExecutorBlockStrategy(), ExecutorBlockStrategyEnum.SERIAL_EXECUTION);  // block strategy
        // 路由策略
        ExecutorRouteStrategyEnum executorRouteStrategyEnum = ExecutorRouteStrategyEnum.match(jobInfo.getExecutorRouteStrategy(), null);    // route strategy
        // 拼接分片参数
        String shardingParam = (ExecutorRouteStrategyEnum.SHARDING_BROADCAST == executorRouteStrategyEnum) ? String.valueOf(index).concat("/").concat(String.valueOf(total)) : null;

        // 1、保存执行日志,失败后可以重试
        XxlJobLog jobLog = new XxlJobLog();
        jobLog.setJobGroup(jobInfo.getJobGroup());
        jobLog.setJobId(jobInfo.getId());
        jobLog.setTriggerTime(new Date());
        XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().save(jobLog);
        logger.debug(">>>>>>>>>>> xxl-job trigger start, jobId:{}", jobLog.getId());

        // 2、初始化触发参数
        TriggerParam triggerParam = new TriggerParam();
        triggerParam.setJobId(jobInfo.getId());
        triggerParam.setExecutorHandler(jobInfo.getExecutorHandler());
        triggerParam.setExecutorParams(jobInfo.getExecutorParam());
        triggerParam.setExecutorBlockStrategy(jobInfo.getExecutorBlockStrategy());
        triggerParam.setExecutorTimeout(jobInfo.getExecutorTimeout());
        triggerParam.setLogId(jobLog.getId());
        triggerParam.setLogDateTime(jobLog.getTriggerTime().getTime());
        triggerParam.setGlueType(jobInfo.getGlueType());
        triggerParam.setGlueSource(jobInfo.getGlueSource());
        triggerParam.setGlueUpdatetime(jobInfo.getGlueUpdatetime().getTime());
        triggerParam.setBroadcastIndex(index);
        triggerParam.setBroadcastTotal(total);

        // 3、获取地址
        String address = null;
        ReturnT<String> routeAddressResult = null;
        if (group.getRegistryList() != null && !group.getRegistryList().isEmpty()) {
            // 分片广播模式
            if (ExecutorRouteStrategyEnum.SHARDING_BROADCAST == executorRouteStrategyEnum) {
                if (index < group.getRegistryList().size()) {
                    address = group.getRegistryList().get(index);
                } else {
                    address = group.getRegistryList().get(0);
                }
            } else {
                //根据负载均衡策略选择不同执行器(返回对应执行器地址)
                //ExecutorRouteBusyover 忙碌转移
                //ExecutorRouteConsistentHash 一致性hash
                //ExecutorRouteFailover 故障转移
                //ExecutorRouteFirst 第一个
                //ExecutorRouteLast 最后一个
                //ExecutorRouteLFU 使用频率最低的优先
                //ExecutorRouteLRU 最久未使用的优先
                //ExecutorRouteRandom 随机
                //ExecutorRouteRound 轮训
                routeAddressResult = executorRouteStrategyEnum.getRouter().route(triggerParam, group.getRegistryList());
                if (routeAddressResult.getCode() == ReturnT.SUCCESS_CODE) {
                    address = routeAddressResult.getContent();
                }
            }
        } else {
            // 没有存活执行器
            routeAddressResult = new ReturnT<>(ReturnT.FAIL_CODE, I18nUtil.getString("jobconf_trigger_address_empty"));
        }

        // 4、调用执行器
        ReturnT<String> triggerResult;
        if (address != null) {
            triggerResult = runExecutor(triggerParam, address);
        } else {
            triggerResult = new ReturnT<>(ReturnT.FAIL_CODE, null);
        }

        // 5、获取执行信息
        StringBuilder triggerMsgSb = new StringBuilder();
        triggerMsgSb.append(I18nUtil.getString("jobconf_trigger_type")).append(":").append(triggerType.getTitle());
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobconf_trigger_admin_adress")).append(":").append(IpUtil.getIp());
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobconf_trigger_exe_regtype")).append(":")
                .append((group.getAddressType() == 0) ? I18nUtil.getString("jobgroup_field_addressType_0") : I18nUtil.getString("jobgroup_field_addressType_1"));
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobconf_trigger_exe_regaddress")).append(":").append(group.getRegistryList());
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobinfo_field_executorRouteStrategy")).append(":").append(executorRouteStrategyEnum.getTitle());
        if (shardingParam != null) {
            triggerMsgSb.append("(").append(shardingParam).append(")");
        }
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobinfo_field_executorBlockStrategy")).append(":").append(blockStrategy.getTitle());
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobinfo_field_timeout")).append(":").append(jobInfo.getExecutorTimeout());
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobinfo_field_executorFailRetryCount")).append(":").append(finalFailRetryCount);

        triggerMsgSb.append("<br><br><span style="color:#00c0ef;" > >>>>>>>>>>>").append(I18nUtil.getString("jobconf_trigger_run")).append("<<<<<<<<<<< </span><br>")
                .append((routeAddressResult != null && routeAddressResult.getMsg() != null) ? routeAddressResult.getMsg() + "<br><br>" : "").append(triggerResult.getMsg() != null ? triggerResult.getMsg() : "");

        // 6、更新执行器结果
        jobLog.setExecutorAddress(address);
        jobLog.setExecutorHandler(jobInfo.getExecutorHandler());
        jobLog.setExecutorParam(jobInfo.getExecutorParam());
        jobLog.setExecutorShardingParam(shardingParam);
        jobLog.setExecutorFailRetryCount(finalFailRetryCount);
        // 设置执行时间
        jobLog.setTriggerCode(triggerResult.getCode());
        jobLog.setTriggerMsg(triggerMsgSb.toString());
        XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateTriggerInfo(jobLog);

        logger.debug(">>>>>>>>>>> xxl-job trigger end, jobId:{}", jobLog.getId());
    }


    public static ReturnT<String> runExecutor(TriggerParam triggerParam, String address) {
        ReturnT<String> runResult;
        try {
            //每个地址一个执行器,每个执行地址的以及token可能都不一样需要类承载数据 这里是ExecutorBizClient实现
            ExecutorBiz executorBiz = XxlJobScheduler.getExecutorBiz(address);
            //这里直接就是接口调用了 XxlJobRemotingUtil.postBody(addressUrl + "run", accessToken, timeout, triggerParam, String.class);
            // 调用执行器run接口,这里并不是同步调用,调用后执行器端只是将任务放入队列成功就返回success了
            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<>(ReturnT.FAIL_CODE, ThrowableUtil.toString(e));
        }

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

        runResult.setMsg(runResultSB);
        //只是简单的触发接口返回状态
        return runResult;
    }

}

XxlJobTrigger 主要负责任务调度,配置有不多调度策略,具体看上方源码注释。但有一个比较特殊的调度策略就是:ExecutorRouteStrategyEnum.SHARDING_BROADCAST 分片广播,分片广播是分布式调度任务的精华

其他的策略每次只能触发一个执行器执行,但分片广播可以触发所有执行器,那么分片广播有什么使用场景:

  1. 需要统一定时调用的,比如清除每个服务的定时缓存
  2. 需要大量计算的(甚至在很有限时间内完成),单节点存在瓶颈的,这个时候分片参数就起作用了

分片的作用

例如:微信运动每天晚上10点左右推送用户当日运动步数,微信有十几亿用户,靠单服务器去推送肯定是不可能完成的,假如单台服务器推送速度10w/s,那么十几亿需要一万秒左右,大概3个小时左右

如果想尽快完成推送,必须要多台服务器同时工作,但多台服务器不能感知对方存在,不能较好完成分配,此时就需要使用分片广播模式

调度中心会维护一个执行器数组addressList,表示注册到调度中心的执行器列表 分片广播模式分片参数0/2
0代表执行器索引,为执行器在addressList的下标
2代表执行器总数,为addressList大小
这个参数在调用时会发送给执行器,执行器拿到这个参数就可以算出自己处理哪个片区的数据
eg:假如总共10个执行器
那么收到0/10的就处理表中id排序后前10%的数据
收到1/10的就处理表中id排序后10%-20%的数据

但是这里可能存在问题,调度中心不能及时感知执行器上下线,可能导致漏处理

所以当定时任务量比较大的时候就要用分片广播的模式,在设计定时任务的逻辑上就要注意了:同一类的百万定时逻辑应该是一个定时任务对应xxl_job_info中一条记录而不是百万个定时任务。例如上面的微信每天晚上10点推送用户几个亿用户当日运动步数的需求,整体是一个定时任务,采用了10000台服务器分片执行,每台服务器收到分片信息,然后去用户表拉取自己负责的用户分片列表进行推送,而不是产生10000个定时任务!!!

JobTriggerPoolHelper:初始化了两个线程池
【fastTriggerPool】负责处理执行时长较短的任务
【slowTriggerPool】负责执行耗时长的任务

JobTriggerPoolHelper调用XxlJobTrigger来调用执行器,JobTriggerPoolHelper只负责记录任务调用时间,记录在jobTimeoutCountMap中如果超过10次执行时间在500ms以上就使用slowTriggerPool执行,否则使用fastTriggerPool执行,同时jobTimeoutCountMap会每1min清空一次。

JobCompleteHelper:初始化了一个线程池【callbackThreadPool】负责处理执行器完成任务回调和一个线程【monitorThread】负责监控处理执行超时失败的任务

JobLogReportHelper:负责生成任务执行报告(基本可以不看)

JobRegisterHelper:初始化了一个线程池【registryOrRemoveThreadPool】负责注册或者移除执行器(只负责了简单的数据库操作)和一个线程【registryMonitorThread】找到长时间没有上报心跳的执行器移除掉,同时刷新JobGroup registryList字段

【执行器管理】部分重要源码

执行器管理主要功能都在JobRegistryHelper中,这里实现了执行器注册,注销以及超时剔除等逻辑,主要涉及两张表:xxl_job_group,xxl_job_registry

public class JobRegistryHelper {
    private static final Logger logger = LoggerFactory.getLogger(JobRegistryHelper.class);

    private static final JobRegistryHelper instance = new JobRegistryHelper();

    private ThreadPoolExecutor registryOrRemoveThreadPool = null;
    private Thread registryMonitorThread;
    private volatile boolean toStop = false;

    public void start() {

        // 负责异步执行sql
        registryOrRemoveThreadPool = new ThreadPoolExecutor(
                2,
                10,
                30L,
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(2000),
                new ThreadFactory() {
                    @Override
                    public Thread newThread(Runnable r) {
                        return new Thread(r, "xxl-job, admin JobRegistryMonitorHelper-registryOrRemoveThreadPool-" + r.hashCode());
                    }
                },
                new RejectedExecutionHandler() {
                    @Override
                    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
                        r.run();
                        logger.warn(">>>>>>>>>>> xxl-job, registry or remove too fast, match threadpool rejected handler(run now).");
                    }
                });

        // 负责定时维护注册执行器列表, 剔除长时间未上报心跳的执行器以及更新执行器组信息
        registryMonitorThread = new Thread(() -> {
            while (!toStop) {
                try {
                    // 找出所有自动注册的任务组
                    List<XxlJobGroup> groupList = XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().findByAddressType(0);
                    if (groupList != null && !groupList.isEmpty()) {
                        // remove dead address (admin/executor) 移除掉90s三个心跳周期没有上报任务的
                        List<Integer> ids = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().findDead(RegistryConfig.DEAD_TIMEOUT, new Date());
                        if (ids != null && !ids.isEmpty()) {
                            // 先移除掉注册表
                            XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().removeDead(ids);
                        }

                        HashMap<String, List<String>> appAddressMap = new HashMap<>();
                        // 再找出三个周期内上报的, 更新到执行器组 address_list 字段
                        List<XxlJobRegistry> list = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().findAll(RegistryConfig.DEAD_TIMEOUT, new Date());
                        if (list != null) {
                            for (XxlJobRegistry item : list) {
                                //客户端注册过来默认就是 EXECUTOR
                                if (RegistryConfig.RegistType.EXECUTOR.name().equals(item.getRegistryGroup())) {
                                    String appname = item.getRegistryKey();
                                    List<String> registryList = appAddressMap.get(appname);
                                    if (registryList == null) {
                                        registryList = new ArrayList<>();
                                    }

                                    if (!registryList.contains(item.getRegistryValue())) {
                                        registryList.add(item.getRegistryValue());
                                    }
                                    appAddressMap.put(appname, registryList);
                                }
                            }
                        }


                        for (XxlJobGroup group : groupList) {
                            List<String> registryList = appAddressMap.get(group.getAppname());
                            String addressListStr = null;
                            if (registryList != null && !registryList.isEmpty()) {
                                Collections.sort(registryList);
                                StringBuilder addressListSB = new StringBuilder();
                                for (String item : registryList) {
                                    addressListSB.append(item).append(",");
                                }
                                addressListStr = addressListSB.toString();
                                addressListStr = addressListStr.substring(0, addressListStr.length() - 1);
                            }
                            group.setAddressList(addressListStr);
                            group.setUpdateTime(new Date());

                            // 更新执行器组主要是执行器列表字段address_list, 调用时取的是address_list而没有查询xxl_job_registry表
                            XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().update(group);
                        }
                    }
                } catch (Exception e) {
                    if (!toStop) {
                        logger.error(">>>>>>>>>>> xxl-job, job registry monitor thread error:", e);
                    }
                }
                try {
                    // 休眠30s
                    TimeUnit.SECONDS.sleep(RegistryConfig.BEAT_TIMEOUT);
                } catch (InterruptedException e) {
                    if (!toStop) {
                        logger.error(">>>>>>>>>>> xxl-job, job registry monitor thread error:", e);
                    }
                }
            }
            logger.info(">>>>>>>>>>> xxl-job, job registry monitor thread stop");
        });
        registryMonitorThread.setDaemon(true);
        registryMonitorThread.setName("xxl-job, admin JobRegistryMonitorHelper-registryMonitorThread");
        registryMonitorThread.start();
    }


    // ---------------------- helper ----------------------
    public ReturnT<String> registry(RegistryParam registryParam) {
        // 接受执行器注册请求
        if (!StringUtils.hasText(registryParam.getRegistryGroup())
                || !StringUtils.hasText(registryParam.getRegistryKey())
                || !StringUtils.hasText(registryParam.getRegistryValue())) {
            return new ReturnT<>(ReturnT.FAIL_CODE, "Illegal Argument.");
        }
        // 异步执行
        registryOrRemoveThreadPool.execute(() -> {
            //先更新
            int ret = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().registryUpdate(registryParam.getRegistryGroup(), registryParam.getRegistryKey(), registryParam.getRegistryValue(), new Date());
            if (ret < 1) {
                // 更新失败则新增
                XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().registrySave(registryParam.getRegistryGroup(), registryParam.getRegistryKey(), registryParam.getRegistryValue(), new Date());
                // 刷新执行器组这里并没未实现交给上面的定时任务实现了
                freshGroupRegistryInfo(registryParam);
            }
        });

        return ReturnT.SUCCESS;
    }

    public ReturnT<String> registryRemove(RegistryParam registryParam) {
        //响应执行器客户端的移除请求
        if (!StringUtils.hasText(registryParam.getRegistryGroup())
                || !StringUtils.hasText(registryParam.getRegistryKey())
                || !StringUtils.hasText(registryParam.getRegistryValue())) {
            return new ReturnT<>(ReturnT.FAIL_CODE, "Illegal Argument.");
        }
        
        registryOrRemoveThreadPool.execute(() -> {
            int ret = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().registryDelete(registryParam.getRegistryGroup(), registryParam.getRegistryKey(), registryParam.getRegistryValue());
            if (ret > 0) {
                freshGroupRegistryInfo(registryParam);
            }
        });

        return ReturnT.SUCCESS;
    }
    private void freshGroupRegistryInfo(RegistryParam registryParam) {
        // Under consideration, prevent affecting core tables
    }
}

执行器注册和注销以及执行器结果回调都是通过接口调用的,接口实现在JobApiController

@Controller
@RequestMapping("/api")
public class JobApiController {

    @Resource
    private AdminBiz adminBiz;

    @RequestMapping("/{uri}")
    @ResponseBody
    @PermissionLimit(limit=false)
    public ReturnT<String> api(HttpServletRequest request, @PathVariable("uri") String uri, @RequestBody(required = false) String data) {

        // valid
        if (!"POST".equalsIgnoreCase(request.getMethod())) {
            return new ReturnT<>(ReturnT.FAIL_CODE, "invalid request, HttpMethod not support.");
        }
        if (uri==null || uri.trim().isEmpty()) {
            return new ReturnT<>(ReturnT.FAIL_CODE, "invalid request, uri-mapping empty.");
        }
        if (XxlJobAdminConfig.getAdminConfig().getAccessToken()!=null
                && !XxlJobAdminConfig.getAdminConfig().getAccessToken().trim().isEmpty()
                && !XxlJobAdminConfig.getAdminConfig().getAccessToken().equals(request.getHeader(XxlJobRemotingUtil.XXL_JOB_ACCESS_TOKEN))) {
            return new ReturnT<>(ReturnT.FAIL_CODE, "The access token is wrong.");
        }

        // services mapping
        if ("callback".equals(uri)) {
            List<HandleCallbackParam> callbackParamList = GsonTool.fromJson(data, List.class, HandleCallbackParam.class);
            return adminBiz.callback(callbackParamList);
        } else if ("registry".equals(uri)) {
            RegistryParam registryParam = GsonTool.fromJson(data, RegistryParam.class);
            return adminBiz.registry(registryParam);
        } else if ("registryRemove".equals(uri)) {
            RegistryParam registryParam = GsonTool.fromJson(data, RegistryParam.class);
            return adminBiz.registryRemove(registryParam);
        } else {
            return new ReturnT<>(ReturnT.FAIL_CODE, "invalid request, uri-mapping("+ uri +") not found.");
        }
    }

}

可能存在问题:

  1. 任务管理器正常运行是每s运行,万一任务数量多,会导致不能每s运行会导致任务丢失吗? 不会,因为预读now+5s之前的,会延迟但不会丢失。如果任务不能丢失需要配置过期立刻执行策略

  2. 时间轮需要每s执行但一个点任务太多了可能会导致某s被跳过 确实会,但概率很小,除非任务数量特别大,因为时间轮里没有IO操作,触发任务也仅仅是提交到线程池所以还是很快的

  3. 时间轮中有任务没来得及执行但是宕机了会导致任务丢失吗?
    会,因为无论何种情况都会更新下一次执行时间,并不是执行后更新下一次执行时间。假如任务被加入了时间轮同时更新了数据库下次执行时间。
    为啥这样设计,可能因为时间轮中也只保留了5s之类的,重启后再执行必要性也不大了。对于过期不需要执行的任务没啥,但对于过期仍需要执行的任务确实就丢了
    所以如果是执行完成后更新下一次时间会不会更好

  4. 调用执行器时,执行器只是将任务放入到队列中,可能导致还没来得及执行结果执行器宕机了导致任务丢失,而且这种情况调度中心认为已经成功不会再重试了

  5. 读取更新任务存在较大瓶颈因为是每个更新,改成批量是不是更好

XXL-JOB执行器

执行器负责接收调度中心任务运行命令,同时定时向调度中心上报心跳等,执行器为啥要内嵌Netty呢?
这部分的源码主要在xxl-job-core中,但xxl-job-admin也引用了core包,看起来可能有些迷,这部分相当于我们暴露给使用者的feign client包,有些代码调用者和被调用者需要共用。其中ExecutorBizClient属于admin调用执行器core的封装实现,ExecutorBizImpl是执行器端core对应的接口实现。 这里要用Netty的一个很重要原因感觉不是因为性能问题,反正都是收到任务都是直接提交到线程池用Tomcat也是一样的。最可能原因是方便通用集成,如果用SpringBoot的Controller实现还需要使用者单独配置扫描范围。而使用Netty更直接更轻量还可以兼顾非SpringBoot项目不过这种情况也很少了

XXL-JOB执行器的阻塞策略
public enum ExecutorBlockStrategyEnum {  
    // 串行
    SERIAL_EXECUTION("Serial execution"),  
    // 线程正在处理任务或者有等待任务直接丢弃新来的
    DISCARD_LATER("Discard Later"),  
    // 直接新建线程运行
    COVER_EARLY("Cover Early");
}

这里有个比较好的设计点,就是将任务和执行器的某个线程固定绑定,这点在MQ顺序消费客户端源码中也有运用:要保证一个topic一个物理分区内的消息路由到固定的消费实例的固定线程,只有这样才能保证顺序消费
XXL-JOB为了保证执行器的高效,采用了多线程模式。而同时又为了任务执行的线程安全在设计上采用了jobId和线程相绑定的方式

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