文章目录
分布式定时任务(三) 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字段含义
字段名称 | 字段类型 | 是否必填 | 描述 |
---|---|---|---|
id | VARCHAR(40) | 是 | 主键 |
job_name | VARCHAR(100) | 是 | 作业名称 |
task_id | VARCHAR(1000) | 是 | 任务名称,每次作业运行生成新任务 |
hostname | VARCHAR(255) | 是 | 主机名称 |
ip | VARCHAR(50) | 是 | 主机IP |
sharding_item | INT | 是 | 分片项 |
execution_source | VARCHAR(20) | 是 | 作业执行来源。可选值为NORMAL_TRIGGER, MISFIRE, FAILOVER |
failure_cause | VARCHAR(2000) | 否 | 执行失败原因 |
is_success | BIT | 是 | 是否执行成功 |
start_time | TIMESTAMP | 是 | 作业开始执行时间 |
complete_time | TIMESTAMP | 否 | 作业结束执行时间 |
JOB_EXECUTION_LOG记录每次作业的执行历史。分为两个步骤:
- 作业开始执行时向数据库插入数据,除failure_cause和complete_time外的其他字段均不为空。
- 作业完成执行时向数据库更新数据,更新is_success, complete_time和failure_cause(如果作业执行失败)。
JOB_STATUS_TRACE_LOG字段含义
字段名称 | 字段类型 | 是否必填 | 描述 |
---|---|---|---|
id | VARCHAR(40) | 是 | 主键 |
job_name | VARCHAR(100) | 是 | 作业名称 |
original_task_id | VARCHAR(1000) | 是 | 原任务名称 |
task_id | VARCHAR(1000) | 是 | 任务名称 |
slave_id | VARCHAR(1000) | 是 | 执行作业服务器的名称,Lite版本为服务器的IP地址,Cloud版本为Mesos执行机主键 |
source | VARCHAR(50) | 是 | 任务执行源,可选值为CLOUD_SCHEDULER, CLOUD_EXECUTOR, LITE_EXECUTOR |
execution_type | VARCHAR(20) | 是 | 任务执行类型,可选值为NORMAL_TRIGGER, MISFIRE, FAILOVER |
sharding_item | VARCHAR(255) | 是 | 分片项集合,多个分片项以逗号分隔 |
state | VARCHAR(20) | 是 | 任务执行状态,可选值为TASK_STAGING, TASK_RUNNING, TASK_FINISHED, TASK_KILLED, TASK_LOST, TASK_FAILED, TASK_ERROR |
message | VARCHAR(2000) | 是 | 相关信息 |
creation_time | TIMESTAMP | 是 | 记录创建时间 |
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
。