任务调度之 Elastic Job

认识 Elastic-Job:

  任务调度高级需求,Quartz 的不足:

  1. 作业只能通过 DB 抢占随机负载,无法协调
  2. 任务不能分片——单个任务数据太多了跑不完,消耗线程,负载不均
  3. 作业日志可视化监控、统计

发展历史:

  官网:http:://elasticjob.io/docs/elastic-job-lite/00-overview/

  Elastic-Job 是怎么来的?在当当的 ddframe 框架中,需要一个任务调度系统(作业系统)。实现的话有两种思路,一个是修改开源产品,一种是基于开源产品搭建(封装),当当选择了后者,最开始这个调度系统叫做 dd-job。它是一个无中心化的分布式调度框架。因为数据库缺少分布式协调功能(比如选主),替换为 Zookeeper 后,增加了弹性扩容和数据分片的功能。Elastic-Job 是 ddframe 中的 dd-job 作业模块分离出来的作业框架,基于 Quartz和 Curator 开发,在 2015 年开源。轻量级,无中心化解决方案。为什么说是去中心化呢?因为没有统一的调度中心。集群的每个节点都是对等的,节点之间通过注册中心进行分布式协调。E-Job 存在主节点的概念,但是主节点没有调度的功能,而是用于处理一些集中式任务,如分片,清理运行时信息等。

  Elastic-Job 最开始只有一个 elastic-job-core 的项目,在 2.X 版本以后主要分为Elastic-Job-Lite 和 Elastic-Job-Cloud 两个子项目。其中,Elastic-Job-Lite 定位为轻量级 无 中 心 化 解 决 方 案 , 使 用 jar 包 的 形 式 提 供 分 布 式 任 务 的 协 调 服 务 。 而Elastic-Job-Cloud 使用 Mesos + Docker 的解决方案,额外提供资源治理、应用分发以及进程隔离等服务(跟 Lite 的区别只是部署方式不同,他们使用相同的 API,只要开发一次)。

功能特性:

  • 分布式调度协调:用 ZK 实现注册中心
  • 错过执行作业重触发(Misfire)
  • 支持并行调度(任务分片)
  • 作业分片一致性,保证同一分片在分布式环境中仅一个执行实例
  • 弹性扩容缩容:将任务拆分为 n 个任务项后,各个服务器分别执行各自分配到的任务项。一旦有新的服务器加入集群,或现有服务器下线,elastic-job 将在保留本次任务执行不变的情况下,下次任务开始前触发任务重分片。
  • 失效转移 failover:弹性扩容缩容在下次作业运行前重分片,但本次作业执行的过程中,下线的服务器所分配的作业将不会重新被分配。失效转移功能可以在本次作业运行中用空闲服务器抓取孤儿作业分片执行。同样失效转移功能也会牺牲部分性能。
  • 支持作业生命周期操作(Listener)
  • 丰富的作业类型(Simple、DataFlow、Script)
  • Spring 整合以及命名空间提供
  • 运维平台

项目架构:

  应用在各自的节点执行任务,通过 ZK 注册中心协调。节点注册、节点选举、任务分片、监听都在 E-Job 的代码中完成。下图是官网提供得架构图。

任务类型:

  接下去就针对 Elastic-Job 的三种任务类型进行切入。来体会一下 Elastic-Job的开发流程。任务类型分为:SimpleJob、DataflowJob、ScriptJob

1.导入 pom 依赖:

<dependency>
  <groupId>org.apache.curator</groupId>
  <artifactId>curator-client</artifactId>
  <version>2.11.1</version>
</dependency>
<dependency>
  <groupId>com.dangdang</groupId>
  <artifactId>elastic-job-lite-core</artifactId>
  <version>2.1.5</version>
</dependency>
<dependency>
  <groupId>mysql</groupId>
  <artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
  <groupId>com.alibaba</groupId>
  <artifactId>druid</artifactId>
  <version>1.0.9</version>
</dependency>

2.SimpleJob :简单实现,未经任何封装的类型。需实现 SimpleJob 接口.

public class MySimpleJob implements SimpleJob {

    public void execute(ShardingContext context) {
        System.out.println(
                String.format("分片项 ShardingItem: %s | 运行时间: %s | 线程ID: %s | 分片参数: %s ",
                context.getShardingItem(),
                new SimpleDateFormat("HH:mm:ss").format(new Date()),
                Thread.currentThread().getId(),
                context.getShardingParameter())
        );
    }
}

  测试类:

public class SimpleJobTest {
    // TODO 如果修改了代码,跑之前清空ZK
    public static void main(String[] args) {
        // ZK注册中心
        CoordinatorRegistryCenter regCenter = new ZookeeperRegistryCenter(
                new ZookeeperConfiguration("192.168.1.101:2181", "ejob-standalone")
        );
        regCenter.init();

        // 数据源 , 事件执行持久化策略
        DruidDataSource dataSource =new DruidDataSource();
        dataSource.setDriverClassName("com.mysql.jdbc.Driver");
        dataSource.setUrl("jdbc:mysql://192.168.1.101:3306/study?useUnicode=true&characterEncoding=utf-8");
        dataSource.setUsername("root");
        dataSource.setPassword("123456");
        JobEventConfiguration jobEventConfig = new JobEventRdbConfiguration(dataSource);

        // 定义作业核心配置
        JobCoreConfiguration coreConfig = JobCoreConfiguration
                .newBuilder("MySimpleJob", "0/20 * * * * ?", 4)
                .shardingItemParameters("0=RDP, 1=CORE, 2=SIMS, 3=ECIF").failover(true).build();
        
        // 定义SIMPLE类型配置
        SimpleJobConfiguration simpleJobConfig = new SimpleJobConfiguration(
                coreConfig, MySimpleJob.class.getCanonicalName());

        // 作业分片策略
        // 基于平均分配算法的分片策略
        String jobShardingStrategyClass = AverageAllocationJobShardingStrategy.class.getCanonicalName();

        // 定义Lite作业根配置
        // LiteJobConfiguration simpleJobRootConfig = LiteJobConfiguration.newBuilder(simpleJobConfig).jobShardingStrategyClass(jobShardingStrategyClass).build();
        LiteJobConfiguration simpleJobRootConfig = LiteJobConfiguration.newBuilder(simpleJobConfig).overwrite(true).build();

        // 构建Job
//        new JobScheduler(regCenter, simpleJobRootConfig).init();
         new JobScheduler(regCenter, simpleJobRootConfig, jobEventConfig).init();
    }
}

3.DataflowJob:Dataflow 类型用于处理数据流,必须实现 fetchData()和processData()的方法,一个用来获取数据,一个用来处理获取到的数据。

public class MyDataFlowJob implements DataflowJob<String> {
    private boolean flag = false;

    public List<String> fetchData(ShardingContext shardingContext) {
        System.out.println("开始获取数据");
        if (flag) {
            return null;
        }
        return Arrays.asList("qingshan", "jack", "seven");
    }

    public void processData(ShardingContext shardingContext, List<String> data) {
        for (String val : data) {
            // 处理完数据要移除掉,不然就会一直跑,处理可以在上面的方法里执行。这里采用 flag
            System.out.println("开始处理数据:" + val);
        }
        flag = true;
    }
}

  测试的话同 SimpleJob,只需要把任务类型修改为  DataflowJobConfiguration

4.ScriptJob:Script 类型作业意为脚本类型作业,支持 shell,python,perl 等所有类型脚本。我这边直接在 D盘下 新增一个  test.bat  脚本,内容如下:

@echo ------[Script Task]Sharding Context: %*

  测试类:

public class ScriptJobTest {
    // 如果修改了代码,跑之前清空ZK
    public static void main(String[] args) {
        // ZK注册中心
        CoordinatorRegistryCenter regCenter = new ZookeeperRegistryCenter(
                new ZookeeperConfiguration("192.168.1.101:2181", "ejob-standalone"));
        regCenter.init();

        // 定义作业核心配置
        JobCoreConfiguration scriptJobCoreConfig = JobCoreConfiguration
                .newBuilder("MyScriptJob", "0/4 * * * * ?", 2)
                .build();
        // 定义SCRIPT类型配置
        ScriptJobConfiguration scriptJobConfig = new ScriptJobConfiguration(
                scriptJobCoreConfig,"D:/test.bat");

        // 作业分片策略
        // 基于平均分配算法的分片策略
        String jobShardingStrategyClass = AverageAllocationJobShardingStrategy.class.getCanonicalName();

        // 定义Lite作业根配置
        // LiteJobConfiguration scriptJobRootConfig = LiteJobConfiguration.newBuilder(scriptJobConfig).jobShardingStrategyClass(jobShardingStrategyClass).build();
        LiteJobConfiguration scriptJobRootConfig = LiteJobConfiguration.newBuilder(scriptJobConfig).overwrite(true).build();

        // 构建Job
        new JobScheduler(regCenter, scriptJobRootConfig).init();
        // new JobScheduler(regCenter, scriptJobRootConfig, jobEventConfig).init();
    }
}

  通过以上的三个 demo 我们对于 elastic-job 有了新的认识,可以看到要创建一个新的job需要经历一系列的配置,那么接下去就来看看这些配置都是怎么定义的。

Elastic-Job 配置:

  配置手册: http://elasticjob.io/docs/elastic-job-lite/02-guide/config-manual/
  配置的过程大概分为以下几个步骤:

  1. 由于 Elastic-Job依赖于 ZK,所以首先是注册中心的配置。
  2. 如果想对事件的执行链路持久化,还需要配置相关JobEventConfiguration。
  3. 作业核心配置:JobCoreConfiguration,配置作业名称、CRON 表达式、分片总数等。
  4. 作业类型配置,如SimpleJobConfiguration,配置自己的实现等。
  5. 定义Lite作业根配置 LiteJobConfiguration,配置 作业分片策略
  6. 最后构建 Job,JobScheduler

  作业核心配置分为 3 级,分别是 JobCoreConfiguration,JobTypeConfiguration 和LiteJobConfiguration 。 LiteJobConfiguration 使 用 JobTypeConfiguration ,JobTypeConfiguration 使用 JobCoreConfiguration,层层嵌套。JobTypeConfiguration 根 据 不 同 实 现 类 型 分 为 SimpleJobConfiguration ,DataflowJobConfiguration 和 ScriptJobConfiguration。E-Job 使用 ZK 来做分布式协调,所有的配置都会写入到 ZK 节点。

Zookeeper 注册中心数据结构:

  一个任务一个二级节点。这里面有些节点是临时节点,只有任务运行的时候才能看到。

  注意:修改了任务重新运行任务不生效,是因为 ZK 的信息不会更新, 除非把overwrite 修改成 true。

  接下去我们先将三类的任务都跑一遍,然后保留 SimpleJob 把其他两个关了。这个时候可以查看 ZK节点如下:

  从这里我们可以得出结论  Elastic-Job是以 jobName 来区分他们的。点开正在运行的  SimpleJob 节点可以看到如下信息:

config 节点:

  JSON 格式存储。存储任务的配置信息,包含执行类,cron 表达式,分片算法类,分片数量,分片参数等等。

{
    "jobName": "MySimpleJob",
    "jobClass": "com.wuzz.demo.job.demo.simple.MySimpleJob",
    "jobType": "SIMPLE",
    "cron": "0/20 * * * * ?",
    "shardingTotalCount": 4,
    "shardingItemParameters": "0\u003dRDP, 1\u003dCORE, 2\u003dSIMS, 3\u003dECIF",
    "jobParameter": "",
    "failover": true,
    "misfire": true,
    "description": "",
    "jobProperties": {
        "job_exception_handler": "com.dangdang.ddframe.job.executor.handler.impl.DefaultJobExceptionHandler",
        "executor_service_handler": "com.dangdang.ddframe.job.executor.handler.impl.DefaultExecutorServiceHandler"
    },
    "monitorExecution": true,
    "maxTimeDiffSeconds": -1,
    "monitorPort": -1,
    "jobShardingStrategyClass": "",
    "reconcileIntervalMinutes": 10,
    "disabled": false,
    "overwrite": true
}

  config节点的数据是通过ConfigService持久化到zookeeper中去的。默认状态下,如果你修改了 Job 的配置比如 cron 表达式、分片数量等是不会更新到 zookeeper 上去的,除非你在 Lite 级别的配置把参数 overwrite 修改成 true。

instances 节点:

  同一个 Job 下的 elastic-job 的部署实例。一台机器上可以启动多个 Job 实例,也就是 Jar 包。instances 的命名是 IP+@-@+PID。只有在运行的时候能看到。

leader 节点:

  任务实例的主节点信息,通过 zookeeper 的主节点选举,选出来的主节点信息。在elastic job 中,任务的执行可以分布在不同的实例(节点)中,但任务分片等核心控制,需要由主节点完成。因此,任务执行前,需要选举出主节点。下面有三个子节点:

  1. election:主节点选举
  2. sharding:分片
  3. failover:失效转移,这里没有显示是未发生失效转移

  election 下面的 instance 节点显示了当前主节点的实例 ID:jobInstanceId。election 下面的 latch 节点也是一个永久节点用于选举时候的实现分布式锁。sharding 节点下面有一个临时节点,necessary,是否需要重新分片的标记。如果分片总数变化,或任务实例节点上下线或启用/禁用,以及主节点选举,都会触发设置重分片标记,主节点会进行分片计算。

servers 节点:

  任务实例的信息,主要是 IP 地址,任务实例的 IP 地址。跟 instances 不同,如果多个任务实例在同一台机器上运行则只会出现一个 IP 子节点。可在 IP 地址节点写入DISABLED 表示该任务实例禁用。

sharding 节点:

  任务的分片信息,子节点是分片项序号,从 0 开始。分片个数是在任务配置中设置的。分片项序号的子节点存储详细信息。每个分片项下的子节点用于控制和记录分片运行状态。最主要的子节点就是 instance。

运维平台:

  github下载源码 https://github.com/elasticjob/elastic-job-lite.

  对 elastic-job-lite-console 打包得到安装包。解压缩 elastic-job-lite-console-${version}.tar.gz 并执行 bin\start.sh(Windows运行.bat)。打开浏览器访问 http://localhost:8899/即可访问控制台。8899 为默认端口号,可通过启动脚本输入-p 自定义端口号。默认管理员用户名和密码是 root/root。右上角可以切换语言。

  第一步,添加注册中心,输入 ZK 地址和命名空间,并连接。

  运维平台和 elastic-job-lite 并无直接关系,是通过读取作业注册中心数据展现作业状态,或更新注册中心数据修改全局配置。控制台只能控制作业本身是否运行,但不能控制作业进程的启动,因为控制台和作业本身服务器是完全分离的,控制台并不能控制作业服务器。

  修改作业:

事件追踪:

  http://elasticjob.io/docs/elastic-job-lite/02-guide/event-trace/

  Elastic-Job 提供了事件追踪功能,可通过事件订阅的方式处理调度过程的重要事件,用于查询、统计和监控。

  Elastic-Job-Lite 在配置中提供了 JobEventConfiguration,目前支持数据库方式配置。在上文的 SimpleJobTest 中已经贴出代码。

  事件追踪的 event_trace_rdb_url 属性对应库自动创建 JOB_EXECUTION_LOG 和 JOB_STATUS_TRACE_LOG 两张表以及若干索引

  需要在运维平台中添加数据源信息,并且连接:

   提交后点击连接,就可以在作业历史中查看到历史执行事件:

springboot 集成 Elastic-Job:

1.导入依赖:

<!-- https://mvnrepository.com/artifact/com.dangdang/elastic-job-lite-spring -->
<dependency>
    <groupId>com.dangdang</groupId>
    <artifactId>elastic-job-lite-spring</artifactId>
    <version>2.1.5</version>
</dependency>
<dependency>
    <groupId>com.dangdang</groupId>
    <artifactId>elastic-job-lite-core</artifactId>
    <version>2.1.5</version>
</dependency>

2. 定义配置类和任务类中要用到的参数

server.port=${random.int[10000,19999]}
regCenter.serverList = 192.168.1.101:2181
regCenter.namespace = ejob-springboot

wuzzJob.cron = 0/5 * * * * ?
wuzzJob.shardingTotalCount = 2
wuzzJob.shardingItemParameters = 0=0,1=1

3.创建任务

@Component
public class SimpleJobDemo implements SimpleJob {
    public void execute(ShardingContext shardingContext) {
        System.out.println(String.format("------Thread ID: %s, %s,任务总片数: %s, " +
                        "当前分片项: %s.当前参数: %s," +
                        "当前任务名称: %s.当前任务参数 %s",
                Thread.currentThread().getId(),
                new SimpleDateFormat("HH:mm:ss").format(new Date()),
                shardingContext.getShardingTotalCount(),
                shardingContext.getShardingItem(),
                shardingContext.getShardingParameter(),
                shardingContext.getJobName(),
                shardingContext.getJobParameter()
        ));
    }
}

4.注册中心配置,Bean 的 initMethod 属性用来指定 Bean 初始化完成之后要执行的方法,用来替代继承 InitializingBean 接口,以便在容器启动的时候创建注册中心。

@Configuration
public class ElasticRegCenterConfig {
    @Bean(initMethod = "init")
    public ZookeeperRegistryCenter regCenter(
            @Value("${regCenter.serverList}") final String serverList,
            @Value("${regCenter.namespace}") final String namespace) {
        return new ZookeeperRegistryCenter(new ZookeeperConfiguration(serverList, namespace));
    }
}

5.作业三级配置:Core——Type——Lite

@Configuration
public class ElasticJobConfig {

    //注册中心
    @Autowired
    private ZookeeperRegistryCenter regCenter;

    @Bean(initMethod = "init")
    public JobScheduler simpleJobScheduler(final SimpleJobDemo simpleJob,
                                           @Value("${wuzzJob.cron}") final String cron,
                                           @Value("${wuzzJob.shardingTotalCount}") final int shardingTotalCount,
                                           @Value("${wuzzJob.shardingItemParameters}") final String
                                                   shardingItemParameters) {
        //参数依次是 自定义任务类型实现类、cron表达式、分片数、分片参数
        LiteJobConfiguration liteJobConfiguration =
                getLiteJobConfiguration(simpleJob.getClass(), cron, shardingTotalCount, shardingItemParameters);

        return new SpringJobScheduler(simpleJob, regCenter, liteJobConfiguration);
    }

    /**
     * 功能描述: <br>
     * LiteJobConfiguration 配置
     * @Param: [jobClass, cron, shardingTotalCount, shardingItemParameters]
     * @Return: com.dangdang.ddframe.job.lite.config.LiteJobConfiguration
     * @Author: wuzhenzhao
     * @Date: 2020/7/14 17:03
     */
    private LiteJobConfiguration getLiteJobConfiguration(final Class<? extends SimpleJob> jobClass,
                                                         final String cron,
                                                         final int shardingTotalCount,
                                                         final String shardingItemParameters) {
        JobCoreConfiguration coreConfig = JobCoreConfiguration
                //任务名称、cron表达式、分片数
                .newBuilder(jobClass.getName(), cron, shardingTotalCount)
                .shardingItemParameters(shardingItemParameters).build();

        // 作业分片策略
        // 基于平均分配算法的分片策略
        String jobShardingStrategyClass = AverageAllocationJobShardingStrategy.class.getCanonicalName();

        return LiteJobConfiguration.newBuilder(
                new SimpleJobConfiguration(coreConfig, jobClass.getCanonicalName()))
                .jobShardingStrategyClass(jobShardingStrategyClass)
                //允许重写配置
                .overwrite(true).build();
    }
}

6.启动主类即可。

分片策略:

  分片项与分片参数:

  任务分片,是为了实现把一个任务拆分成多个子任务,在不同的 ejob 示例上执行。例如 100W 条数据,在配置文件中指定分成 10 个子任务(分片项),这 10 个子任务再按照一定的规则分配到 5 个实际运行的服务器上执行。除了直接用分片项 ShardingItem获取分片任务之外,还可以用 item 对应的 parameter 获取任务。

  定义几个分片项,一个任务就会有几个线程去运行它。

  注意:分片个数和分片参数要一一对应。通常把分片项设置得比 E-Job 服务器个数大一些,比如 3 台服务器,分成 9 片,这样如果有服务器宕机,分片还可以相对均匀。

  分片验证:

  运行上述 Springboot集成项目。SimpleJob 的分片项改成 2。

  打成 jar 包:mvn package -Dmaven.test.skip=true

  多实例运行(单机):

  • 多运行一个点,任务不会重跑(两个节点各获得一个分片项)
  • 关闭一个节点,任务不会漏跑

  分片策略  http://elasticjob.io/docs/elastic-job-lite/02-guide/job-sharding-strategy/  分片项如何分配到服务器?这个跟分片策略有关。

  • AverageAllocationJobShardingStrategy:基于平均分配算法的分片策略,也是默认的分片策略。如果分片不能整除,则不能整除的多余分片将依次追加到序号小的服务器。如果有 3 台服务器,分成 9 片,则每台服务器 分 到 的 分 片 是 : 1=[0,1,2], 2=[3,4,5],3=[6,7,8]。如果有 3 台服务器,分成 8 片,则每台服务器分到的分片是:1=[0,1,6], 2=[2,3,7], 3=[4,5]。如果有 3 台服务器,分成 10 片,则每台服务器 分 到 的 分 片 是 : 1=[0,1,2,9], 2=[3,4,5],3=[6,7,8]。AverageAllocationJobShardingStrategy 的缺点是,一旦分片数小于作业服务器数,作业将永远分配至 IP 地址靠前的服务器,导致 IP 地址靠后的服务器空闲。而 OdevitySortByNameJobShardingStrategy 则可以根据作业名称重新分配服务器负载。如:如果有 3 台服务器,分成 2 片,作业名称的哈希值为奇数,则每台服务器分到的分片是:1=[0], 2=[1], 3=[]。如果有 3 台服务器,分成 2 片,作业名称的哈希值为偶数,则每台服务器分到的分片是:3=[0], 2=[1], 1=[]
  • OdevitySortByNameJobShardingStrategy:根据作业名的哈希值奇偶数决定 IP 升降序算法的分片策略。根据作业名的哈希值奇偶数决定 IP 升降序算法的分片策略。1.作业名的哈希值为奇数则 IP 升序。2.作业名的哈希值为偶数则 IP 降序。用于不同的作业平均分配负载至不同的服务器。
  • RotateServerByNameJobShardingStrategy:根据作业名的哈希值对服务器列表进行轮转的分片策略。
  • 自定义分片策略:实现 JobShardingStrategy 接口并实现 sharding 方法,接口方法参数为作业服务器 IP 列表和分片策略选项,分片策略选项包括作业名称,分片总数以及分片序列号和个性化参数对照表,可以根据需求定制化自己的分片策略。

  设置分片策略:

// 作业分片策略
// 基于平均分配算法的分片策略
String jobShardingStrategyClass = AverageAllocationJobShardingStrategy.class.getCanonicalName();
// 定义Lite作业根配置
LiteJobConfiguration simpleJobRootConfig = LiteJobConfiguration.newBuilder(simpleJobConfig).jobShardingStrategyClass(jobShardingStrategyClass).build();

分片方案:

  获取到分片项 shardingItem 之后,怎么对数据进行分片嗯?

  1. 对业务主键进行取模,获取余数等于分片项的数据,举例:获取到的 sharding item 是 0,1在 SQL 中加入过滤条件:where mod(id, 4) in (1, 2)。这种方式的缺点:会导致索引失效,查询数据时会全表扫描。解决方案:在查询条件中在增加一个索引条件进行过滤。
  2. 在表中增加一个字段,根据分片数生成一个 mod 值。取模的基数要大于机器数。否则在增加机器后,会导致机器空闲。例如取模基数是 2,而服务器有 5 台,那么有三台服务器永远空闲。而取模基数是 10,生成 10 个 shardingItem,可以分配到 5 台服务器。当然,取模基数也可以调整。
  3. 如果从业务层面,可以用 ShardingParamter 进行分片。例如 0=RDP, 1=CORE, 2=SIMS, 3=ECIF,List<users> = SELECT * FROM user WHERE status = 0 AND SYSTEM_ID ='RDP' limit 0, 100。

  在 Spring Boot 中要 Elastic-Job 要配置的内容太多了,有没有更简单的添加任务的方法呢?比如在类上添加一个注解?这个时候我们就要用到 starter 了。

Elastic Job 原理:

启动:

  由 new JobScheduler(regCenter, simpleJobRootConfig, jobEventConfig).init(); 进入启动流程。

/**
* 初始化作业.
*/
public void init() {
  LiteJobConfiguration liteJobConfigFromRegCenter = schedulerFacade.updateJobConfiguration(liteJobConfig);
  // 设置分片数
  JobRegistry.getInstance().setCurrentShardingTotalCount(liteJobConfigFromRegCenter.getJobName(), liteJobConfigFromRegCenter.getTypeConfig().getCoreConfig().getShardingTotalCount());
  // 构建任务,创建调度器
  JobScheduleController jobScheduleController = new JobScheduleController(
    createScheduler(), createJobDetail(liteJobConfigFromRegCenter.getTypeConfig().getJobClass()), liteJobConfigFromRegCenter.getJobName());
  // 在 ZK 上注册任务
  JobRegistry.getInstance().registerJob(liteJobConfigFromRegCenter.getJobName(), jobScheduleController, regCenter);
  // 添加任务信息并进行节点选举
  schedulerFacade.registerStartUpInfo(!liteJobConfigFromRegCenter.isDisabled());
  // 启动调度器
  jobScheduleController.scheduleJob(liteJobConfigFromRegCenter.getTypeConfig().getCoreConfig().getCron());
}

  registerStartUpInfo 方法

/**
* 注册作业启动信息.
* 
* @param enabled 作业是否启用
*/
public void registerStartUpInfo(final boolean enabled) {
  // 启动所有的监听器、监听器用于监听 ZK 节点信息的变化。
  listenerManager.startAllListeners();
  // 节点选举
  leaderService.electLeader();
  // 服务信息持久化(写到 ZK)
  serverService.persistOnline(enabled);
  // 实例信息持久化(写到 ZK)
  instanceService.persistOnline();
  // 重新分片
  shardingService.setReshardingFlag();
  // 监控信息监听器
  monitorService.listen();
  // 自诊断修复,使本地节点与 ZK 数据一致
  if (!reconcileService.isRunning()) {
    reconcileService.startAsync();
  }
}

  启动的时候进行主节点选举:

/**
* 选举主节点.
*/
public void electLeader() {
  log.debug("Elect a new leader now.");
  jobNodeStorage.executeInLeader(LeaderNode.LATCH, new LeaderElectionExecutionCallback());
  log.debug("Leader election completed.");
}

  Latch 是一个分布式锁,选举成功后在 instance 写入服务器信息。

  启动调度任务则是:

/**
* 调度作业.
* 
* @param cron CRON表达式
*/
public void scheduleJob(final String cron) {
  try {
    if (!scheduler.checkExists(jobDetail.getKey())) {
      scheduler.scheduleJob(jobDetail, createTrigger(cron));
    }
    //调用 Quartz 一样的类进行启动     scheduler.start();   }
catch (final SchedulerException ex) {     throw new JobSystemException(ex);   } }

任务执行与分片:

  关注两个问题:

  1. LiteJob 是怎么被执行的?
  2. 分片项是怎么分配给不同的服务实例的?

  在创建 Job 的时候(createJobDetail),创建的是实现了 Quartz 的 Job 接口的 LiteJob 类,LiteJob 类实现了 Quartz 的 Job 接口。在 LiteJob 的 execute 方法中获取对应类型的执行器,调用 execute()方法。

/**
* 获取作业执行器.
*
* @param elasticJob 分布式弹性作业
* @param jobFacade 作业内部服务门面服务
* @return 作业执行器
*/
@SuppressWarnings("unchecked")
public static AbstractElasticJobExecutor getJobExecutor(final ElasticJob elasticJob, final JobFacade jobFacade) {
        if (null == elasticJob) {
            return new ScriptJobExecutor(jobFacade);
        }
        if (elasticJob instanceof SimpleJob) {
            return new SimpleJobExecutor((SimpleJob) elasticJob, jobFacade);
        }
        if (elasticJob instanceof DataflowJob) {
            return new DataflowJobExecutor((DataflowJob) elasticJob, jobFacade);
        }
        throw new JobConfigurationException("Cannot support job type '%s'", elasticJob.getClass().getCanonicalName());
}

  EJOB 提供管理任务执行器的抽象类 AbstractElasticJobExecutor,核心动作在execute()方法中执行。

/**
* 执行作业.
*/
public final void execute() {
  // .....省略代码
  execute(shardingContexts, JobExecutionEvent.ExecutionSource.NORMAL_TRIGGER);
  // .....省略代码
}

  然后回走到一个分片执行的 process 方法内:

private void process(final ShardingContexts shardingContexts, final JobExecutionEvent.ExecutionSource executionSource) {
        Collection<Integer> items = shardingContexts.getShardingItemParameters().keySet();
        if (1 == items.size()) {// 只有一个分片项时,直接执行
            int item = shardingContexts.getShardingItemParameters().keySet().iterator().next();
            JobExecutionEvent jobExecutionEvent =  new JobExecutionEvent(shardingContexts.getTaskId(), jobName, executionSource, item);
            process(shardingContexts, item, jobExecutionEvent);
            return;
        }
        final CountDownLatch latch = new CountDownLatch(items.size());
     // 本节点遍历执行相应的分片信息
        for (final int each : items) {
            final JobExecutionEvent jobExecutionEvent = new JobExecutionEvent(shardingContexts.getTaskId(), jobName, executionSource, each);
            if (executorService.isShutdown()) {
                return;
            }
            executorService.submit(new Runnable() {
                @Override
                public void run() {
                    try {
                        process(shardingContexts, each, jobExecutionEvent);
                    } finally {
                        latch.countDown();
                    }
                }
            });
        }
        try {// 等待所有的分片项任务执行完毕
            latch.await();
        } catch (final InterruptedException ex) {
            Thread.currentThread().interrupt();
        }
}

  这里会一直走到 AbstractElasticJobExecutor 的 process 方法

  交给具体的实现类(SimpleJobExecutor、DataflowJobExecutor、ScriptJobExecutor)去处理。这里使 SimpleJobExecutor,最后调到我们实现类的  execute 方法。

public final class SimpleJobExecutor extends AbstractElasticJobExecutor {
    
    private final SimpleJob simpleJob;
    
    public SimpleJobExecutor(final SimpleJob simpleJob, final JobFacade jobFacade) {
        super(jobFacade);
        this.simpleJob = simpleJob;
    }
    
    @Override
    protected void process(final ShardingContext shardingContext) {
        simpleJob.execute(shardingContext);
    }
}

失效转移:

  所谓失效转移,就是在执行任务的过程中发生异常时,这个分片任务可以在其他节点再次执行。

  FailoverListenerManager 监听的是 zk 的 instance 节点删除事件。如果任务配置了 failover 等于 true,其中某个 instance 与 zk 失去联系或被删除,并且失效的节点又不是本身,就会触发失效转移逻辑。Job 的失效转移监听来源于 FailoverListenerManager 中内部类JobCrashedJobListener 的 dataChanged 方法。当节点任务失效时会调用 JobCrashedJobListener 监听器,此监听器会根据实例 id获取所有的分片,然后调用 FailoverService 的 setCrashedFailoverFlag 方法,将每个分片 id 写到/jobName/leader/failover/items 下,例如原来的实例负责 1、2 分片项,那么 items 节点就会写入 1、2,代表这两个分片项需要失效转移。

class JobCrashedJobListener extends AbstractJobListener {
        
        @Override
        protected void dataChanged(final String path, final Type eventType, final String data) {
            if (isFailoverEnabled() && Type.NODE_REMOVED == eventType && instanceNode.isInstancePath(path)) {
                String jobInstanceId = path.substring(instanceNode.getInstanceFullPath().length() + 1);
                if (jobInstanceId.equals(JobRegistry.getInstance().getJobInstance(jobName).getJobInstanceId())) {
                    return;
                }
          // 获取到失效的分片集合 List
<Integer> failoverItems = failoverService.getFailoverItems(jobInstanceId); if (!failoverItems.isEmpty()) { for (int each : failoverItems) {
               // 设置失效的分片项标记 failoverService.setCrashedFailoverFlag(each); failoverService.failoverIfNecessary(); } }
else { for (int each : shardingService.getShardingItems(jobInstanceId)) { failoverService.setCrashedFailoverFlag(each); failoverService.failoverIfNecessary(); } } } } }

  然后接下来调用 FailoverService 的 failoverIfNessary 方法,首先判断是否需要失败转移,如果可以需要则只需作业失败转移。

/**
* 如果需要失效转移, 则执行作业失效转移.
*/
public void failoverIfNecessary() {
  if (needFailover()) {
    jobNodeStorage.executeInLeader(FailoverNode.LATCH, new FailoverLeaderExecutionCallback());
  }
}
private boolean needFailover() {
  return jobNodeStorage.isJobNodeExisted(FailoverNode.ITEMS_ROOT) 
      && !jobNodeStorage.getJobNodeChildrenKeys(FailoverNode.ITEMS_ROOT).isEmpty()       && !JobRegistry.getInstance().isJobRunning(jobName); }

  条件一:${JOB_NAME}/leader/failover/items/${ITEM_ID} 有失效转移的作业分片项。条件二:当前作业不在运行中。

   在主节点执行操作
/**
* 在主节点执行操作.
* 
* @param latchNode 分布式锁使用的作业节点名称
* @param callback 执行操作的回调
*/
public void executeInLeader(final String latchNode, final LeaderExecutionCallback callback) {
        try (LeaderLatch latch = new LeaderLatch(getClient(), jobNodePath.getFullPath(latchNode))) {
            latch.start();
            latch.await();
            callback.execute();
        //CHECKSTYLE:OFF
        } catch (final Exception ex) {
        //CHECKSTYLE:ON
            handleException(ex);
        }
}

  紧接着调用  FailoverLeaderExecutionCallback 的 execute方法:

class FailoverLeaderExecutionCallback implements LeaderExecutionCallback {
        
        @Override
        public void execute() {
            //再次判断是否需要失效转移;
            if (JobRegistry.getInstance().isShutdown(jobName) || !needFailover()) {
                return;
            }
            //从注册中心获得一个 `${JOB_NAME}/leader/failover/items/${ITEM_ID}` 作业分片项;
            int crashedItem = Integer.parseInt(jobNodeStorage.getJobNodeChildrenKeys(FailoverNode.ITEMS_ROOT).get(0));
            log.debug("Failover job '{}' begin, crashed item '{}'", jobName, crashedItem);
       // 在注册中心节点`${JOB_NAME}/sharding/${ITEM_ID}/failover` 注册作业分片项为当前作业节点;
            jobNodeStorage.fillEphemeralJobNode(FailoverNode.getExecutionFailoverNode(crashedItem), JobRegistry.getInstance().getJobInstance(jobName).getJobInstanceId());
        // 然后移除任务转移分片项
            jobNodeStorage.removeJobNodeIfExisted(FailoverNode.getItemsNode(crashedItem));
            // TODO 不应使用triggerJob, 而是使用executor统一调度
            JobScheduleController jobScheduleController = JobRegistry.getInstance().getJobScheduleController(jobName);
            if (null != jobScheduleController) {
          //最后调用执行,提交任务。
                jobScheduleController.triggerJob();
            }
        }
    }

  

posted @ 2020-07-14 15:49  吴振照  阅读(5295)  评论(0编辑  收藏