定时任务是工作中经常要使用的技术,比如定时同步数据生成报表、定时清理磁盘日志文件、定时扫描超时订单进行补偿回调等,我们可以使用多种技术来开发一个定时任务。
比如直接基于线程:
new Thread(() -> {
while (true) {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行任务"+ LocalDateTime.now());
}
}).start();
System.in.read();
以上代码能做到每隔3s执行一次。
也可以利用Timer:
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("执行任务"+LocalDateTime.now());
}
}, 5000,3000);
以上代码能做到5s之后开始执行,后续每隔3s执行一次。
也可以利用ScheduledThreadPoolExecutor:
ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(10);
executor.scheduleWithFixedDelay(new Runnable() {
@Override
public void run() {
System.out.println("执行任务"+LocalDateTime.now());
}
}, 5, 3, TimeUnit.SECONDS);
以上代码能做到5s之后开始执行,后续每隔3s执行一次。
也可以用quartz:
public class HelloJob implements Job {
@Override
public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
System.out.println("执行任务"+ LocalDateTime.now());
}
}
public static void main(String[] args) throws IOException, SchedulerException {
Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();
Trigger trigger = TriggerBuilder.newTrigger()
.withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(3)
.repeatForever()).build();
JobDetail job = JobBuilder.newJob(HelloJob.class).build();
scheduler.scheduleJob(job,trigger);
scheduler.start();
}
还可以用Spring Task,比如:
@SpringBootApplication
@EnableScheduling
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@Component
public class MySpringTask {
@Scheduled(cron = "0/5 * * * * ?")
public void test(){
System.out.println("执行SpringTask");
}
}
以上几种方式都有几个共同的缺点:
ElasticJob会需要连接zookeeper,建议大家使用apache-zookeeper-3.6.3-bin这个版本,下载地址为:https://www.apache.org/dyn/closer.lua/zookeeper/zookeeper-3.6.3/apache-zookeeper-3.6.3-bin.tar.gz
下载完后解压:
需要把conf目录下的zoo_sample.cfg改为zoo.cfg
然后进去到bin目录下,进入cmd运行zkServer.cmd脚本,就可以启动zookeeper了。
启动完Zookeeper之后,就可以搭一个应用来使用ElasticJob了。
新建一个普通的maven项目,然后增加依赖:
<dependency>
<groupId>org.apache.shardingsphere.elasticjobgroupId>
<artifactId>elasticjob-lite-coreartifactId>
<version>3.0.1version>
dependency>
然后新建一个类,实现SimpleJob:
public class MySimpleJob implements SimpleJob {
@Override
public void execute(ShardingContext context) {
System.out.println("执行任务"+ LocalDateTime.now());
}
}
然后新建一个启动类:
public class TestMySimpleJob {
public static void main(String[] args) {
new ScheduleJobBootstrap(createRegistryCenter(), new MySimpleJob(), createJobConfiguration()).schedule();
}
// 连接Zookeeper
private static CoordinatorRegistryCenter createRegistryCenter() {
CoordinatorRegistryCenter regCenter = new ZookeeperRegistryCenter(new ZookeeperConfiguration("localhost:2181", "my-job"));
regCenter.init();
return regCenter;
}
// 创建作业配置
private static JobConfiguration createJobConfiguration() {
return JobConfiguration.newBuilder("MySimpleJob", 1)
.cron("0/3 * * * * ?")
.build();
}
}
以上代码会每隔3s执行以下MySimpleJob中的任务。
代码在运行时会需要用到slf4j,我们直接引入:
<dependency>
<groupId>org.slf4jgroupId>
<artifactId>slf4j-log4j12artifactId>
<version>1.7.36version>
dependency>
注意,代码一开始需要连接zookeeper,所以可能启动会比较慢
新建一个类实现DataflowJob接口:
public class MyDataflowJob implements DataflowJob<String> {
@Override
public List<String> fetchData(ShardingContext shardingContext) {
List<String> data = new ArrayList<>();
data.add("数据1");
data.add("数据2");
data.add("数据3");
data.add("数据4");
return data;
}
@Override
public void processData(ShardingContext shardingContext, List<String> list) {
System.out.println(LocalDateTime.now()+"处理数据:"+list);
}
}
新建一个启动类进行测试:
public class TestMyDataflowJob {
public static void main(String[] args) {
new ScheduleJobBootstrap(createRegistryCenter(), new MyDataflowJob(), createJobConfiguration()).schedule();
}
// 连接Zookeeper
private static CoordinatorRegistryCenter createRegistryCenter() {
CoordinatorRegistryCenter regCenter = new ZookeeperRegistryCenter(new ZookeeperConfiguration("localhost:2181", "my-job"));
regCenter.init();
return regCenter;
}
// 创建作业配置
private static JobConfiguration createJobConfiguration() {
return JobConfiguration.newBuilder("MyDataflowJob", 1)
.cron("0/3 * * * * ?")
.build();
}
}
以上代码会每隔3s执行以下MyDataflowJob,每次执行任务时会先调用fetchData()方法获取数据,如果获取到数据了(返回值不为 null 或集合容量不为0时),就会执行processData()方法处理数据。
那如何理解数据流呢?
我们可以这么配置我们的Job:
private static JobConfiguration createJobConfiguration() {
return JobConfiguration.newBuilder("MyDataflowJob", 1)
.cron("0/3 * * * * ?")
.setProperty(DataflowJobProperties.STREAM_PROCESS_KEY, "true")
.overwrite(true)
.build();
}
一旦这么做了之后,我们会发现以上代码会不停的执行任务,而不是每隔3s执行一次了。
这是因为,如果开启流式处理,则作业只有在 fetchData 方法的返回值为 null 或集合容量为空时,才停止抓取,否则作业将一直运行下去; 如果关闭流式处理,则作业只会在每次作业执行过程中执行一次 fetchData 和 processData 方法,随即完成本次作业。
所以,以上代码每次调用 fetchData 方法都能获取到数据,所以会一直执行。
如果采用流式作业处理方式,那么就需要业务代理自己来控制什么时候从fetchData获取不到数据,从而停止本次任务的执行。
可以定时执行某个脚本:
public class TestScriptJob {
public static void main(String[] args) {
new ScheduleJobBootstrap(createRegistryCenter(), "SCRIPT", createJobConfiguration()).schedule();
}
private static CoordinatorRegistryCenter createRegistryCenter() {
CoordinatorRegistryCenter regCenter = new ZookeeperRegistryCenter(new ZookeeperConfiguration("localhost:2181", "my-job"));
regCenter.init();
return regCenter;
}
private static JobConfiguration createJobConfiguration() {
// 创建作业配置
return JobConfiguration.newBuilder("MyScriptJob", 1)
.cron("0/5 * * * * ?")
.setProperty(ScriptJobProperties.SCRIPT_KEY, "java -version")
.overwrite(true)
.build();
}
}
注意ScheduleJobBootstrap的第二个参数为"SCRIPT",另外通过设置script.command.line来配置要执行的脚本。
其底层其实就是利用的CommandLine来执行的命令,所以只要在你机器上能执行的命令,那么就可以在这里进行设置并执行。
也可以定时调用某个HTTP接口:
public class TestHttpJob {
public static void main(String[] args) {
new ScheduleJobBootstrap(createRegistryCenter(), "HTTP", createJobConfiguration()).schedule();
}
private static CoordinatorRegistryCenter createRegistryCenter() {
CoordinatorRegistryCenter regCenter = new ZookeeperRegistryCenter(new ZookeeperConfiguration("localhost:2181", "my-job"));
regCenter.init();
return regCenter;
}
private static JobConfiguration createJobConfiguration() {
// 创建作业配置
return JobConfiguration.newBuilder("MyHttpJob", 1)
.cron("0/5 * * * * ?")
.setProperty(HttpJobProperties.URI_KEY, "http://www.baidu.com")
.setProperty(HttpJobProperties.METHOD_KEY, "GET")
.setProperty(HttpJobProperties.DATA_KEY, "source=ejob") // 请求体
.overwrite(true)
.build();
}
}
注意ScheduleJobBootstrap的第二个参数为"HTTP",另外通过设置http.uri、http.method等参数来配置请求信息。
其底层其实就是利用的HttpURLConnection来实现的。
如果要看到调用结果,得把日志级别设置为debug,因为在HttpJobExecutor源码中中是这么打印请求结果的:
if (this.isRequestSucceed(code)) {
log.debug("HTTP job execute result : {}", result.toString());
} else {
log.warn("HTTP job {} executed with response body {}", jobConfig.getJobName(), result.toString());
}
我们可以使用ScheduleJobBootstrap来进行定时调度,可以通过指定cron来进行控制,比如:
ScheduleJobBootstrap scheduleJobBootstrap = new ScheduleJobBootstrap(createRegistryCenter(), new MySimpleJob(), createJobConfiguration());
scheduleJobBootstrap.schedule();
另外我们也可以使用OneOffJobBootstrap来进行一次性调度,比如:
OneOffJobBootstrap jobBootstrap = new OneOffJobBootstrap(createRegistryCenter(), new MySimpleJob(), createJobConfiguration());
// 可多次调用一次性调度
jobBootstrap.execute();
jobBootstrap.execute();
jobBootstrap.execute();
注意,如果使用OneOffJobBootstrap,那么JobConfiguration中不能指定cron。
在执行任务过程中,如果出现错误了,应对策略有以下几种:
配置错误处理策略
private static JobConfiguration createJobConfiguration() {
return JobConfiguration.newBuilder("MySimpleJob", 1)
.cron("0/5 * * * * ?")
// .jobErrorHandlerType("LOG") // 记录日志
// .jobErrorHandlerType("THROW") // 抛出异常
// .jobErrorHandlerType("IGNORE") // 忽略异常
.overwrite(true)
.build();
}
先添加机器人,在手机端选择某个群组,开启机器人,然后复制WEBHOOK:
private static JobConfiguration createJobConfiguration() {
return JobConfiguration.newBuilder("MySimpleJob", 1)
.cron("0/5 * * * * ?")
.jobErrorHandlerType("WECHAT")
.setProperty(WechatPropertiesConstants.WEBHOOK, "WEBHOOK地址")
.overwrite(true)
.build();
}
我们也可以使用SpringBoot来使用ElasticJob,这样使用起来会更简单。
只需要引入对于starter:
<dependency>
<groupId>org.apache.shardingsphere.elasticjobgroupId>
<artifactId>elasticjob-lite-spring-boot-starterartifactId>
<version>3.0.1version>
dependency>
定义Job:
@Component
public class MySimpleJob implements SimpleJob {
@Override
public void execute(ShardingContext shardingContext) {
System.out.println("执行任务"+ LocalDateTime.now());
}
}
在配置文件中指定 ElasticJob 所使用的 Zookeeper。配置前缀为 elasticjob.reg-center。
elasticjob.jobs 是一个 Map,key 为作业名称,value 为作业类型与配置。 Starter 会根据该配置自动创建 OneOffJobBootstrap 或 ScheduleJobBootstrap 的实例并注册到 Spring 容器中,比如:
elasticjob:
regCenter:
serverLists: localhost:2181
namespace: my-job
jobs:
mysimpleJob:
elasticJobClass: com.zhouyu.MySimpleJob
cron: 0/5 * * * * ?
shardingTotalCount: 1
同样,我们也可以直接基于yaml文件配置一个scriptjob,比如:
elasticjob:
regCenter:
serverLists: localhost:2181
namespace: my-job
jobs:
mySimpleJob:
elasticJobClass: com.zhouyu.MySimpleJob
cron: 0/5 * * * * ?
shardingTotalCount: 1
myScriptJob:
elasticJobType: SCRIPT
cron: 0/3 * * * * ?
shardingTotalCount: 1
props:
script.command.line: "java -version"
首先定义一个Job监听器:
public class MyJobListener implements ElasticJobListener {
@Override
public void beforeJobExecuted(ShardingContexts shardingContexts) {
System.out.println("执行任务前...");
}
@Override
public void afterJobExecuted(ShardingContexts shardingContexts) {
System.out.println("执行任务后...");
}
@Override
public String getType() {
return "simpleJobListener";
}
}
然后在项目中新建META-INF/service/org.apache.shardingsphere.elasticjob.infra.listener.ElasticJobListener文件,并在文件中写上com.zhouyu.MyJobListener
然后给需要该监听器的Job配置监听器:
elasticjob:
regCenter:
serverLists: localhost:2181
namespace: my-job
jobs:
mySimpleJob:
elasticJobClass: com.zhouyu.MySimpleJob
cron: 0/5 * * * * ?
shardingTotalCount: 1
jobListenerTypes: simpleJobListener
然后在Job运行过程中就会在前后执行监听器中所设置的逻辑(此处可能会出现bug)。
我们在定义一个Job时,可以通过shardingTotalCount来定义分片数量,以下就是针对mySimpleJob定义了3个分片:
elasticjob:
jobs:
mySimpleJob:
shardingTotalCount: 3
任务分为3片后,那这3片任务在哪里执行呢?那就看该任务对应几个实例了:
比如启动应用的一个实例后,就会在Zookeeper中存入以下信息:
mySimpleJob节点就是表示某个任务:
那如果我们再启动应用的另外一个实例,那Zookeeper中信息就变成了:
这样3个分片就分别在这个两个实例上运行,同理,如果再启动一个实例:
三个分片就会分别对应三个实例。
Leader节点所对应的实例,会负责进行重写分片,比如在前面场景中,我们启动了三个实例,Leader为3324,也就是第一个实例,现在我们把第一个实例进行停止掉,这样相当于Leader挂掉了,此时就会重新进行选举,从剩余的两个实例中选择一个作为新Leader,而新Leader需要对3个分片重新进行分配。
并且Leader节点还需要监听实例的变化(新增实例或减少实例),一旦发生变化,Leader节点对应的实例也需要把分片重新进行分配。
任务的分片数为3,假设现在有3个实例,那么当到达任务执行时间时,ElasticJob就会调用任务中的execute(),如果execute()中直接就是执行任务,那么这个时候,相当于这个任务立刻在3个实例上同时执行,通常这是不允许的,我们对任务进行分片,就是希望,在任务执行时,不同的实例上处理的数据或处理逻辑不一样,ElasticJob在执行execute()方法时,会传入一个ShardingContext对象,利用这个对象可以获取当前任务的分片总数,以及当前实例的分片号。
这样,我们可以在execute()中,根据分片号进行判断,从而处理不同的数据或者逻辑。
比如:
@Component
public class MySimpleJob implements SimpleJob {
@Override
public void execute(ShardingContext shardingContext) {
int shardingItem = shardingContext.getShardingItem();
switch (shardingItem) {
case 0:
System.out.println("处理0-100条数据");
return;
case 1:
System.out.println("处理101-200条数据");
return;
case 2:
System.out.println("处理201-300条数据");
return;
}
}
}
通过以上这种方式,当此任务要执行时,每个实例都会调用execute()方法,并传入对应的分片号,当然,如果两个分片对应一个实例,那么该实例上的execute()方法会调用两次。
当然,如果实例数大于分片数,那就会有空闲实例。
分片号为数字,我们可以在job配置中针对分片号配置更加有意义的参数,比如:
elasticjob:
regCenter:
serverLists: localhost:2181
namespace: my-job
jobs:
mySimpleJob:
elasticJobClass: com.zhouyu.MySimpleJob
cron: 0/5 * * * * ?
shardingTotalCount: 3
shardingItemParameters: 0=bj,1=sh,2=gz
overwrite: true
而我们的任务中就可以这么写:
@Component
public class MySimpleJob implements SimpleJob {
@Override
public void execute(ShardingContext shardingContext) {
int shardingItem = shardingContext.getShardingItem();
String shardingParameter = shardingContext.getShardingParameter();
switch (shardingParameter) {
case "bj":
System.out.println("处理0-100条数据");
return;
case "sh":
System.out.println("处理101-200条数据");
return;
case "gz":
System.out.println("处理201-300条数据");
return;
}
}
}
一般,我们可以把分片数设置得更多一点,这样一旦增加了新的实例,就能自动的进行利用新实例。当然如果已有实例挂掉了,对应的分片也会分配到其他实例上去。
另外,如果分片数如果为1,并且有多个实例,由于一个分片只会对应一个实例,所以只会在其中一个实例上执行任务,而如果该实例挂掉,那么则会选择其他实例来执行任务。
如果某个分片任务在执行过程中挂掉了,大多数定时任务框架会忽略这种情况,而ElasticJob中能够进行失效转移,比如某个分片任务执行过程中挂掉了,那么ElasticJob会及时发现它,并将此任务转移给其他分片来进行执行。
默认是没有开启失效转移的,可以将failover设置为true,来开启失效转移:
elasticjob:
regCenter:
serverLists: localhost:2181
namespace: my-job
jobs:
mySimpleJob:
elasticJobClass: com.zhouyu.MySimpleJob
cron: 0/30 * * * * ?
shardingTotalCount: 1
overwrite: true
failover: true
@Component
public class MySimpleJob implements SimpleJob {
@Override
public void execute(ShardingContext shardingContext) {
for (int i=0; i<10;i++) {
System.out.println("执行任务"+i+","+LocalDateTime.now());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
此时,这个任务会在每分钟的0s、30s时开始执行,并且此任务执行需要10s。
启动两个实例,由于分片数为1,所以此分片只会在其中一个实例上运行,一旦开始运行就把该实例停掉,一旦发现该实例在Zookeeper中的数据消失了,那就表示实例挂断了,就会触发转移,转移过程中的状态如下:
一旦触发了失效转移,刚刚那个没有执行完的分片任务,就会立刻在另外的实例上执行,当然,如果想要达到真正的“接着执行”,就需要Job自己来记录处理进度了(比如在数据库中记录已经处理完的数据和没有处理完的数据),作为ElasticJob它只能做到再次触发刚刚没有执行完的分片任务。
错过任务重执行功能可以使逾期未执行的作业在之前作业执行完成之后立即执行,可以通过misfire来配置任务是否支持错过重执行,默认是没有开启的,比如:
elasticjob:
regCenter:
serverLists: localhost:2181
namespace: my-job
jobs:
mySimpleJob:
elasticJobClass: com.zhouyu.MySimpleJob
cron: 0/5 * * * * ?
shardingTotalCount: 1
overwrite: true
failover: true
misfire: true
mySimpleJob这个任务会5s执行一次,然后任务执行逻辑为:
@Component
public class MySimpleJob implements SimpleJob {
int index = 0;
@Override
public void execute(ShardingContext shardingContext) {
if (index == 0) {
System.out.println(Thread.currentThread().getName()+"执行任务long"+LocalDateTime.now());
try {
Thread.sleep(6000);
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
System.out.println(Thread.currentThread().getName()+"执行任务short"+LocalDateTime.now());
}
index++;
}
}
表示第一次执行需要6s,也就是会使得错过一次任务执行,但是实际上在第二次执行任务时会连续执行两次,以弥补错过的一次。