分布式定时任务(三) Elastic Job 高级使用篇

分布式定时任务(三) Elastic Job 高级使用篇

0. 前言

由于上一篇有关实践的博客更受读者欢迎,本篇将继续分享更多关于 Elastic Job 的一些相对高级用法,以便读者追求更丰富、强大的使用需求。

1.分片策略

1.1. 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]

1.2. OdevitySortByNameJobShardingStrategy

根据作业名的哈希值奇偶数决定IP升降序算法的分片策略。

作业名的哈希值为奇数则IP升序。

作业名的哈希值为偶数则IP降序。

用于不同的作业平均分配负载至不同的服务器。

AverageAllocationJobShardingStrategy 的缺点是,一旦分片数小于作业服务器数,作业将永远分配至IP地址靠前的服务器,导致IP地址靠后的服务器空闲。而 OdevitySortByNameJobShardingStrategy 则可以根据作业名称重新分配服务器负载。如:

  • 如果有3台服务器,分成2片,作业名称的哈希值为奇数,则每台服务器分到的分片是:1=[0], 2=[1], 3=[]

  • 如果有3台服务器,分成2片,作业名称的哈希值为偶数,则每台服务器分到的分片是:3=[0], 2=[1], 1=[]

1.3. RotateServerByNameJobShardingStrategy

根据作业名的哈希值对服务器列表进行轮转的分片策略。

如果任务分片项数量为7,原作业运行实例(运行服务器实例)的列表排序为A,B,C。定时任务名称的hash值为7。则经过计算后的作业运行实例列表为B,C,A。最终得到的分片如下:A=[5, 6],B=[1, 2, 7],C=[3, 4]。

源码如下:

public final class RotateServerByNameJobShardingStrategy implements JobShardingStrategy {
    
    private AverageAllocationJobShardingStrategy averageAllocationJobShardingStrategy = new AverageAllocationJobShardingStrategy();
    
    @Override
    public Map<JobInstance, List<Integer>> sharding(final List<JobInstance> jobInstances, final String jobName, final int shardingTotalCount) {
      	// 调用的平均分配分片策略,但是其参数列表变了
        return averageAllocationJobShardingStrategy.sharding(rotateServerList(jobInstances, jobName), jobName, shardingTotalCount);
    }
    
  	/**
  	 * 返回待分片的实例列表
  	 */
    private List<JobInstance> rotateServerList(final List<JobInstance> shardingUnits, final String jobName) {
        int shardingUnitsSize = shardingUnits.size();
      	// 计算分片任务名称的hash值 除以 作业运行实例数量 取余
        int offset = Math.abs(jobName.hashCode()) % shardingUnitsSize;
        if (0 == offset) {
            return shardingUnits;
        }
      	// 如果偏移量(余数)不为0,则重新排序
        List<JobInstance> result = new ArrayList<>(shardingUnitsSize);
        for (int i = 0; i < shardingUnitsSize; i++) {
            int index = (i + offset) % shardingUnitsSize;
            result.add(shardingUnits.get(index));
        }
        return result;
    }
}
验证一下

任务类

@Log4j2
@Component
public class SpringTask implements SimpleJob {

    @Override
    public void execute(ShardingContext shardingContext) {
        log.info("Spring整合ElasticJob。任务信息:{}", shardingContext.getShardingItem());
    }

}

任务配置类

    @Bean(initMethod = "init")
    public SpringJobScheduler springJobScheduler() {
        LiteJobConfiguration jobConfiguration = createJobConfiguration(SpringTask.class,
                "0/10 * * * * ?",
                6,
                "0=Monday,1=Tuesday,2=Wednesday,3=Thursday,4=Friday,5=Saturday");
        SpringJobScheduler jobScheduler = new SpringJobScheduler(new SpringTask(), registryCenter, jobConfiguration);
        return jobScheduler;
    }

    private LiteJobConfiguration createJobConfiguration(final Class<? extends SimpleJob>
                                                                jobClass,
                                                        final String cron,
                                                        final int shardingTotalCount,
                                                        final String shardingItemParameters) {
        //创建JobCoreConfiguration
        String name = jobClass.getName();
        log.info("jobName hashcode % 3:{}",name.hashCode()%3);
        JobCoreConfiguration.Builder builder = JobCoreConfiguration.newBuilder(name, cron, shardingTotalCount);
        if (StringUtils.isNotEmpty(shardingItemParameters)) {
            builder.shardingItemParameters(shardingItemParameters);
        }
        // 开启失效转移
        JobCoreConfiguration configuration = builder.failover(true).build();
        //创建SimpleJobConfiguration
        SimpleJobConfiguration simpleJobConfiguration = new SimpleJobConfiguration(configuration, jobClass.getCanonicalName());
        // 在此处添加.jobShardingStrategyClass(),参数为分片策略类的全路径名
        LiteJobConfiguration liteJobConfiguration = LiteJobConfiguration.newBuilder(simpleJobConfiguration)
          	.jobShardingStrategyClass(RotateServerByNameJobShardingStrategy.class.getCanonicalName()).overwrite(true).build();
        return liteJobConfiguration;
    }

启动实例

偏移量 offset

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

服务器实例

在这里插入图片描述

各服务器实例运行结果

在这里插入图片描述

获取可分片的作业运行实例的方法可见`com.dangdang.ddframe.job.lite.internal.storage.JobNodeStorage#getJobNodeChildrenKeys,获取的列表为【3299,32994,32972】。由于偏移量为0,所以直接返回该列表并返回进行分片。

1.4. 自定义分片策略

实现JobShardingStrategy接口并实现sharding方法,接口方法参数为作业服务器IP列表和分片策略选项,分片策略选项包括作业名称,分片总数以及分片序列号和个性化参数对照表,可以根据需求定制化自己的分片策略。

DEMO

分片策略

/**
 * @Name: SelfShardingStrategy
 * @Description: 自定义分片策略
 * @Author: ahao
 * @Date: 2024/1/2 11:54 AM
 */
@Log4j2
public class SelfShardingStrategy implements JobShardingStrategy {


    @Override
    public Map<JobInstance, List<Integer>> sharding(List<JobInstance> jobInstances, String jobName, int shardingTotalCount) {
        log.info("自定义分片策略。作业运行实例列表:{},作业名称:{},分片数量:{}", jobInstances, jobName, shardingTotalCount);
        Map<JobInstance, List<Integer>> result = new HashMap<>();
        if (jobInstances == null || jobInstances.isEmpty()) {
            return new HashMap<>();
        }
        int size = (shardingTotalCount+1) / 2;
        int num = 0;
        for (int i = 0; i < jobInstances.size(); i++) {
            JobInstance jobInstance = jobInstances.get(i);
            List<Integer> integers = new ArrayList<>();
            for (int j = num; j < (i+1)*size && j < shardingTotalCount ; j++) {
                integers.add(j);
                num ++;
            }
            result.put(jobInstance,integers);
        }
        return result;
    }

}

任务配置类

				//启动任务
        LiteJobConfiguration liteJobConfiguration = LiteJobConfiguration.newBuilder(simpleJobConfiguration)
                .jobShardingStrategyClass(SelfShardingStrategy.class.getCanonicalName()).overwrite(true).build();

日志输出

在这里插入图片描述

2. 事件追踪

通过代码配置开启事件追踪

Elastic-Job-Lite在配置中提供了JobEventConfiguration,目前支持数据库方式配置。

导入依赖

				<dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.20</version>
        </dependency>

任务配置类

  @Bean(initMethod = "init")
    public SpringJobScheduler springJobScheduler() {
        // 配置数据源
        MysqlDataSource dataSource = new MysqlDataSource();
        dataSource.setUrl("url");
        dataSource.setUser("username");
        dataSource.setPassword("password");
        // 定义日志数据库事件溯源配置
        JobEventConfiguration jobEventConfiguration = new JobEventRdbConfiguration(dataSource);
				
        LiteJobConfiguration jobConfiguration = createJobConfiguration(SpringTask.class,
                "0/10 * * * * ?",
                6,
                "0=Monday,1=Tuesday,2=Wednesday,3=Thursday,4=Friday,5=Saturday");
      	// 添加作业事件配置
        SpringJobScheduler jobScheduler = new SpringJobScheduler(new SpringTask(), registryCenter, jobConfiguration,jobEventConfiguration);
        return jobScheduler;
    }

数据库日志

在这里插入图片描述

事件追踪日志表说明

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

JOB_EXECUTION_LOG字段含义

字段名称字段类型是否必填描述
idVARCHAR(40)主键
job_nameVARCHAR(100)作业名称
task_idVARCHAR(1000)任务名称,每次作业运行生成新任务
hostnameVARCHAR(255)主机名称
ipVARCHAR(50)主机IP
sharding_itemINT分片项
execution_sourceVARCHAR(20)作业执行来源。可选值为NORMAL_TRIGGER, MISFIRE, FAILOVER
failure_causeVARCHAR(2000)执行失败原因
is_successBIT是否执行成功
start_timeTIMESTAMP作业开始执行时间
complete_timeTIMESTAMP作业结束执行时间

JOB_EXECUTION_LOG记录每次作业的执行历史。分为两个步骤:

  1. 作业开始执行时向数据库插入数据,除failure_cause和complete_time外的其他字段均不为空。
  2. 作业完成执行时向数据库更新数据,更新is_success, complete_time和failure_cause(如果作业执行失败)。

JOB_STATUS_TRACE_LOG字段含义

字段名称字段类型是否必填描述
idVARCHAR(40)主键
job_nameVARCHAR(100)作业名称
original_task_idVARCHAR(1000)原任务名称
task_idVARCHAR(1000)任务名称
slave_idVARCHAR(1000)执行作业服务器的名称,Lite版本为服务器的IP地址,Cloud版本为Mesos执行机主键
sourceVARCHAR(50)任务执行源,可选值为CLOUD_SCHEDULER, CLOUD_EXECUTOR, LITE_EXECUTOR
execution_typeVARCHAR(20)任务执行类型,可选值为NORMAL_TRIGGER, MISFIRE, FAILOVER
sharding_itemVARCHAR(255)分片项集合,多个分片项以逗号分隔
stateVARCHAR(20)任务执行状态,可选值为TASK_STAGING, TASK_RUNNING, TASK_FINISHED, TASK_KILLED, TASK_LOST, TASK_FAILED, TASK_ERROR
messageVARCHAR(2000)相关信息
creation_timeTIMESTAMP记录创建时间

JOB_STATUS_TRACE_LOG记录作业状态变更痕迹表。可通过每次作业运行的task_id查询作业状态变化的生命周期和运行轨迹。

3. 作业运行状态监控

监听job_name\instances\job_instance_id节点是否存在。该节点为临时节点,如果作业服务器下线,该节点将删除。

在这里插入图片描述

4. 作业监听器

可通过配置多个任务监听器,在任务执行前和执行后执行监听的方法。监听器分为2种:每台作业节点均执行和分布式场景中仅单一节点执行。

4.1. 每台作业节点均执行

监听器接口

/**
 * @Name: AllNodeExecutorJobListener
 * @Description: 所有节点都执行的监听器
 * @Author: ahao
 * @Date: 2024/1/2 3:12 PM
 */
@Log4j2
public class AllNodeExecutorJobListener implements ElasticJobListener {

    /**
     * 执行前的钩子函数
     * @param shardingContexts 分片上下文
     */
    @Override
    public void beforeJobExecuted(ShardingContexts shardingContexts) {
        log.info("> 执行前的钩子函数 <");
    }

    /**
     * 执行后的钩子函数
     * @param shardingContexts 分片上下文
     */
    @Override
    public void afterJobExecuted(ShardingContexts shardingContexts) {
        log.info("< 执行后的钩子函数 >");
    }

}

任务配置类

    @Bean(initMethod = "init")
    public SpringJobScheduler springJobScheduler() {
//        // 配置数据源
//        MysqlDataSource dataSource = new MysqlDataSource();
//        dataSource.setUrl("jdbc:mysql://localhost:3306/elasticjob?characterEncoding=utf8&serverTimezone=Asia/Shanghai");
//        dataSource.setUser("root");
//        dataSource.setPassword("12345678");
//        // 定义日志数据库事件溯源配置
//        JobEventConfiguration jobEventConfiguration = new JobEventRdbConfiguration(dataSource);

        LiteJobConfiguration jobConfiguration = createJobConfiguration(SpringTask.class,
                "0/10 * * * * ?",
                6,
                "0=Monday,1=Tuesday,2=Wednesday,3=Thursday,4=Friday,5=Saturday");
      	// 添加事件监听器
        SpringJobScheduler jobScheduler = new SpringJobScheduler(new SpringTask(), registryCenter,
                jobConfiguration,new AllNodeExecutorJobListener());
        return jobScheduler;
    }

日志输出

在这里插入图片描述

4.2. 仅单一节点执行

若作业处理数据库数据,处理完成后只需一个节点完成数据清理任务即可。此类型任务处理复杂,需同步分布式环境下作业的状态同步,提供了超时设置来避免作业不同步导致的死锁,请谨慎使用。

监听器抽象类

/**
 * @Name: OnlyOneJobListener
 * @Description: 仅单一节点执行的监听器
 * @Author: ahao
 * @Date: 2024/1/2 3:25 PM
 */
@Log4j2
public class OnlyOneJobListener extends AbstractDistributeOnceElasticJobListener {

    /**
     *
     * @param startedTimeoutMilliseconds 任务开始超时时间,表示等待所有任务启动等待+doBeforeJobExecutedAtLastStarted()执行的时长
     * @param completedTimeoutMilliseconds 任务完成超时时间,表示等待所有任务完成+doAfterJobExecutedAtLastCompleted()执行的时长
     */
    public OnlyOneJobListener(long startedTimeoutMilliseconds, long completedTimeoutMilliseconds) {
        super(startedTimeoutMilliseconds, completedTimeoutMilliseconds);
    }

    /**
     * 执行前的钩子函数
     * @param shardingContexts 分片上下文
     */
    @Override
    public void doBeforeJobExecutedAtLastStarted(ShardingContexts shardingContexts) {
        log.info("> Before <");
    }

    /**
     * 执行后的钩子函数
     * @param shardingContexts 分片上下文
     */
    @Override
    public void doAfterJobExecutedAtLastCompleted(ShardingContexts shardingContexts) {
        log.info("< After >");
    }

}

任务配置类

    @Bean(initMethod = "init")
    public SpringJobScheduler springJobScheduler() {
//        // 配置数据源
//        MysqlDataSource dataSource = new MysqlDataSource();
//        dataSource.setUrl("jdbc:mysql://localhost:3306/elasticjob?characterEncoding=utf8&serverTimezone=Asia/Shanghai");
//        dataSource.setUser("root");
//        dataSource.setPassword("12345678");
//        // 定义日志数据库事件溯源配置
//        JobEventConfiguration jobEventConfiguration = new JobEventRdbConfiguration(dataSource);

        LiteJobConfiguration jobConfiguration = createJobConfiguration(SpringTask.class,
                "0/10 * * * * ?",
                6,
                "0=Monday,1=Tuesday,2=Wednesday,3=Thursday,4=Friday,5=Saturday");
        SpringJobScheduler jobScheduler = new SpringJobScheduler(new SpringTask(), registryCenter,
                jobConfiguration,new OnlyOneJobListener(5000l,5000l));
        return jobScheduler;
    }

日志输出

在这里插入图片描述

不难发现监听方法被执行了两遍,按理来说应该执行一遍。研究源码可发现,在执行监听方法前,会先根据分片项注册任务开始节点,

如果所有的分片任务都注册好了任务开始节点,则执行监听方法(afterJobExecuted方法也是如此)。如下所示:

public final void beforeJobExecuted(final ShardingContexts shardingContexts) {
  			// 根据分片项注册任务开始节点
        guaranteeService.registerStart(shardingContexts.getShardingItemParameters().keySet());
  			// 判断是否所有的任务均启动完毕
        if (guaranteeService.isAllStarted()) {
          	// 钩子函数
            doBeforeJobExecutedAtLastStarted(shardingContexts);
          	// 清理所有任务启动信息
            guaranteeService.clearAllStartedInfo();
            return;
        }
  			// 获取当前时间(毫秒)
        long before = timeService.getCurrentMillis();
        try {
          	// 锁等待
            synchronized (startedWait) {
                startedWait.wait(startedTimeoutMilliseconds);
            }
        } catch (final InterruptedException ex) {
            Thread.interrupted();
        }
  			// 判断 (当前时间 减去 before)是否超过了定义好的任务开始超时时间
        if (timeService.getCurrentMillis() - before >= startedTimeoutMilliseconds) {
          	// 清理所有任务启动信息
            guaranteeService.clearAllStartedInfo();
          	// 抛异常
            handleTimeout(startedTimeoutMilliseconds);
        }
    }

所以会存在一种情况就是,在同一时刻或者极短的时间(根据分片项注册任务开始节点 和 判断是否所有的任务均启动完毕之间)内,任务节点都注册到了ZK上,所以在guaranteeService.isAllStarted()判断都通过了或者不止一个节点为true。

读者可等待较长时间,反复观看日志输出结果,可发现,有时候会出现只执行一遍的情况,或者一个节点执行了doBeforeJobExecutedAtLastStarted 另一个节点执行了doAfterJobExecutedAtLastCompleted

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值