Xxl-Job架构图
XXL-JOB是一个分布式任务调度平台
图中的自研RPC(xxl-rpc)部分已经替换成了Http协议,这主要是拥抱生态,方便跨语言接入
一、搭建调度中心
①、https://github.com/xuxueli/xxl-job.git下载源码
改一下数据库连接信息,执行一下在项目源码中的/doc/db下的sql文件
启动可以打成一个jar包,或者本地启动就是可以的
启动完成之后,访问下面这个地址就可以访问到控制台页面了
http://localhost:8080/xxl-job-admin/toLogin
用户名密码默认是 admin/123456
二、执行器和任务添加
添加一个名为sanyou-xxljob-demo执行器
任务添加
执行器选择我们刚刚添加的,指定任务名称为TestJob,corn表达式的意思是每秒执行一次
创建完之后需要启动一下任务,默认是关闭状态,也就不会执行
创建执行器和任务其实就是CRUD,并没有复杂的业务逻辑
每隔1s,执行一次sanyou-xxljob-demo这个执行器中的TestJob任务
三、创建执行器和任务
任务触发原理
一、任务如何触发
调度中心在启动的时候,会开启一个线程,这个线程的作用就是来计算任务触发时机,这个线程称为调度线程。
- 调度线程会去查询xxl_job_info这张表,这张表存了任务的一些基本信息和任务下一次执行的时间
- 调度线程会去查询下一次执行的时间 <= 当前时间 + 5s的任务,这个5s是XxlJob写死的,被称为预读时间,提前读出来,保证任务能准时触发
举个例子,假设当前时间是2023-11-29 08:00:10,这里的查询就会查出下一次任务执行时间在2023-11-29 08:00:15之前执行的任务 - 查询到任务之后,调度线程会去将这些任务根据执行时间划分为三个部分:
当前时间已经超过任务下一次执行时间5s以上,也就是需要在2023-11-29 08:00:05(不包括05s)之前的执行的任务
当前时间已经超过任务下一次执行时间,但是但不足5s,也就是在2023-11-29 08:00:05和2023-11-29 08:00:10(不包括10s)之间执行的任务
还未到触发时间,但是一定是5s内就会触发执行的
对于第一部分的已经超过5s以上时间的任务,会根据任务配置的调度过期策略来选择要不要执行
调度过期策略就两种,就是字面意思
①、直接忽略这个已经过期的任务
②、立马执行一次这个过期的任务
对于第二部分的超时时间在5s以内的任务,就直接立马执行一次,之后如果判断任务下一次执行时间就在5s内,会直接放到一个时间轮里面,等待下一次触发执行
对于第三部分任务,由于还没到执行时间,所以不会立马执行,也是直接放到时间轮里面,等待触发执行
当这批任务处理完成之后,不论是前面是什么情况,调度线程都会去重新计算每个任务的下一次触发时间,然后更新xxl_job_info这张表的下一次执行时间
到此,一次调度的计算就算完成了
- 调度线程还会继续重复上面的步骤,查任务,调度任务,更新任务下次执行时间,一直死循环下去,这就实现了任务到了执行时间就会触发的功能
由于调度中心可以是集群的形式,每个调度中心实例都有调度线程,那么如何保证任务在同一时间只会被其中的一个调度中心触发一次?
XxlJob实现通过数据库来实现的分布式锁的
在调度之前,调度线程会尝试执行下面这句sql
select * from xxl_job_lock where lock_name = 'schedule_lock' for update
一旦执行成功,说明当前调度中心成功抢到了锁,接下来就可以执行调度任务了
当调度任务执行完之后再去关闭连接,从而释放锁
由于每次执行之前都需要去获取锁,这样就保证在调度中心集群中,同时只有一个调度中心执行调度任务
二、快慢线程池的异步触发任务优化
当任务达到了触发条件,并不是由调度线程直接去触发执行器的任务执行
调度线程会将这个触发的任务交给线程池去执行
所以上图中的最后一部分触发任务执行其实是线程池异步去执行的
那么,为什么要使用线程池异步呢?
主要是因为触发任务,需要通过Http接口调用具体的执行器实例去触发任务
这一过程必然会耗费时间,如果调度线程去做,就会耽误调度的效率
所以就通过异步线程去做,调度线程只负责判断任务是否需要执行
并且,Xxl-Job为了进一步优化任务的触发,将这个触发任务执行的线程池划分成快线程池和慢线程池两个线程池
在调用执行器的Http接口触发任务执行的时候,Xxl-Job会去记录每个任务的触发所耗费的时间
注意并不是任务执行时间,只是整个Http请求耗时时间,这是因为执行器执行任务是异步执行的,所以整个时间不包括任务执行时间,这个后面会详细说
当任务一次触发的时间超过500ms,那么这个任务的慢次数就会加1
如果这个任务一分钟内触发的慢次数超过10次,接下来就会将触发任务交给慢线程池去执行
所以快慢线程池就是避免那种频繁触发并且每次触发时间还很长的任务阻塞其它任务的触发的情况发生
三、如何选择执行器实例
当任务需要触发的时候,调度中心会向执行器发送Http请求,执行器去执行具体的任务
由于一个执行器会有很多实例,那么应该向哪个实例请求?这其实就跟任务配置时设置的路由策略有关了
从图上可以看出xxljob支持多种路由策略
除了分片广播,其余的具体的算法实现都是通过ExecutorRouter的实现类来实现的
最不经常使用(LFU:Least Frequently Used):Xxl-Job内部会有一个缓存,统计每个任务每个地址的使用次数,每次都选择使用次数最少的地址,这个缓存每隔24小时重置一次
最近最久未使用(LRU:Least Recently Used):将地址存到LinkedHashMap中,它利用LinkedHashMap可以根据元素访问(get/put)顺序来给元素排序的特性,快速找到最近最久未使用(未访问)的节点
故障转移:调度中心都会去请求每个执行器,只要能接收到响应,说明执行器正常,那么任务就会交给这个执行器去执行
忙碌转移:调度中心也会去请求每个执行器,判断执行器是不是正在执行当前需要执行的任务(任务执行时间过长,导致上一次任务还没执行完,下一次又触发了),如果在执行,说明忙碌,不能用,否则就可以用
分片广播:XxlJob给每个执行器分配一个编号,从0开始递增,然后向所有执行器触发任务,告诉每个执行器自己的编号和总共执行器的数据
我们可以通过XxlJobHelper#getShardIndex获取到编号,XxlJobHelper#getShardTotal获取到执行器的总数据量
分片广播就是将任务量分散到各个执行器,每个执行器只执行一部分任务,加快任务的处理
举个例子,比如你现在需要处理30w条数据,有3个执行器,此时使用分片广播,那么此时可将任务分成3分,每份10w条数据,执行器根据自己的编号选择对应的那份10w数据处理
当选择好了具体的执行器实例之后,调用中心就会携带一些触发的参数,发送Http请求,触发任务
四、执行器如何去执行任务
执行器启动是会创建一个Http服务器,所以前面提到的故障转移和忙碌转移请求执行器进行判断,最终执行器也是交给ExecutorBizImpl处理的
执行器处理触发请求是这个ExecutorBizImpl的run方法实现的
当执行器接收到请求,在正常情况下,执行器会去为这个任务创建一个单独的线程,这个线程被称为JobThread
每个任务在触发的时候都有单独的线程去执行,保证不同的任务执行互不影响
之后任务并不是直接交给线程处理的,而是直接放到一个内存队列中,线程直接从队列中获取任务
如果调度中心选择的执行器实例正在处理定时任务,那么此时该怎么处理呢?**
这时就跟阻塞处理策略有关了
阻塞处理策略总共有三种:
- 单机串行
- 丢弃后续调度
- 覆盖之前调度
单机串行的实现就是将任务放到队列中,由于队列是先进先出的,所以就实现串行,这也是为什么放在队列的原因
丢弃调度的实现就是执行器什么事都不用干就可以了,自然而然任务就丢了
覆盖之前调度的实现就很暴力了,他是直接重新创建一个JobThread来执行任务,并且尝试打断之前的正在处理任务的JobThread,丢弃之前队列中的任务
打断是通过Thread#interrupt方法实现的,所以正在处理的任务还是有可能继续运行,并不是说一打断正在运行的任务就终止了
这里需要注意的一点就是,阻塞处理策略是对于单个执行器上的任务来生效的,不同执行器实例上的同一个任务是互不影响的
比如说,有一个任务有两个执行器A和B,路由策略是轮询
任务第一次触发的时候选择了执行器实例A,由于任务执行时间长,任务第二次触发的时候,执行器的路由到了B,此时A的任务还在执行,但是B感知不到A的任务在执行,所以此时B就直接执行了任务
所以此时你配置的什么阻塞处理策略就没什么用了
如果业务中需要保证定时任务同一时间只有一个能运行,需要把任务路由到同一个执行器上,比如路由策略就选择第一个
五、任务执行结果的回调
当任务处理完成之后,执行器会将任务执行的结果发送给调度中心
如上图所示,这整个过程也是异步化的
- JobThread会将任务执行的结果发送到一个内存队列中
- 执行器启动的时候会开启一个处发送任务执行结果的线程:TriggerCallbackThread
- 这个线程会不停地从队列中获取所有的执行结果,将执行结果批量发送给调度中心
- 调用中心接收到请求时,会根据执行的结果修改这次任务的执行状态和进行一些后续的事,比如失败了是否需要重试,是否有子任务需要触发等等
springboot集成xxl-job
采用docker-compose来搭建测试环境
# 参考文档:https://www.xuxueli.com/xxl-job
version: "3"
networks:
xxljob:
driver: bridge
services:
xxl-job-admin:
image: registry.cn-hangzhou.aliyuncs.com/zhengqing/xxl-job-admin:2.3.0 # 原镜像`xuxueli/xxl-job-admin:2.3.0`
container_name: xxl-job-admin
environment:
# TODO 根据自己的配置修改,配置项参考源码文件:/xxl-job/xxl-job-admin/src/main/resources/application.properties
PARAMS: "--spring.datasource.url=jdbc:mysql://10.11.68.77:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
--spring.datasource.username=root
--spring.datasource.password=root
--server.servlet.context-path=/xxl-job-admin
--spring.mail.host=smtp.qq.com
--spring.mail.port=25
--spring.mail.username=xxx@qq.com
--spring.mail.from=xxx@qq.com
--spring.mail.password=xxx
--xxl.job.accessToken="
ports:
- "8080:8080"
depends_on:
- mysql
networks:
- xxljob
mysql:
image: registry.cn-hangzhou.aliyuncs.com/zhengqing/mysql:5.7 # 原镜像`mysql:5.7`
container_name: mysql_3306 # 容器名为'mysql_3306'
restart: unless-stopped # 指定容器退出后的重启策略为始终重启,但是不考虑在Docker守护进程启动时就已经停止了的容器
volumes: # 数据卷挂载路径设置,将本机目录映射到容器目录
- "./mysql/my.cnf:/etc/mysql/my.cnf"
- "./mysql/init-file.sql:/etc/mysql/init-file.sql"
- "./mysql/data:/var/lib/mysql"
# - "./mysql/conf.d:/etc/mysql/conf.d"
- "./mysql/log/mysql/error.log:/var/log/mysql/error.log"
- "./mysql/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d" # 可执行初始化sql脚本的目录 -- tips:`/var/lib/mysql`目录下无数据的时候才会执行(即第一次启动的时候才会执行)
environment: # 设置环境变量,相当于docker run命令中的-e
TZ: Asia/Shanghai
LANG: en_US.UTF-8
MYSQL_ROOT_PASSWORD: root # 设置root用户密码
MYSQL_DATABASE: xxl_job # 初始化的数据库名称
ports: # 映射端口
- "3306:3306"
networks:
- xxljob
运行:
docker-compose -f docker-compose-xxl-job.yml -p xxl-job up -d
访问地址:http://ip地址:9003/xxl-job-admin 默认登录账号密码:admin/123456
一、依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>springboot-demo</artifactId>
<groupId>com.et</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>xxl-job</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.4</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
<version>2.3.0</version>
</dependency>
</dependencies>
</project>
二、配置文件和启动类
server:
port: 8088
logging:
level:
com.ramble: debug
xxl:
job:
admin:
#调度中心部署根地址 [选填]:如调度中心集群部署存在多个地址则用逗号分隔。执行器将会使用该地址进行"执行器心跳注册"和"任务结果回调";为空则关闭自动注册;
addresses: http://127.0.0.1:8080/xxl-job-admin
#执行器通讯TOKEN [选填]:非空时启用;
accessToken:
executor:
#执行器AppName [选填]:执行器心跳注册分组依据;为空则关闭自动注册
appname: xxljob-demo-service
#${spring.application.name}
#执行器注册 [选填]:优先使用该配置作为注册地址,为空时使用内嵌服务 ”IP:PORT“ 作为注册地址。从而更灵活的支持容器类型执行器动态IP和动态映射端口问题。
address: ""
#执行器IP [选填]:默认为空表示自动获取IP,多网卡时可手动设置指定IP,该IP不会绑定Host仅作为通讯实用;地址信息用于 "执行器注册" 和 "调度中心请求并触发任务";
ip: ""
#执行器端口号 [选填]:小于等于0则自动获取;默认端口为9999,单机部署多个执行器时,注意要配置不同执行器端口;
port: 0
###${server-port}
#执行器运行日志文件存储磁盘路径 [选填] :需要对该路径拥有读写权限;为空则使用默认路径;
logpath: ./logs/xxl-job/jobhandler
#执行器日志文件保存天数 [选填] :过期日志自动清理, 限制值大于等于3时生效; 否则, 如-1, 关闭自动清理功能;
logretentiondays: 30
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
三、xxl-job配置类
@Configuration
@Slf4j
public class XxlJobConfig{
@Value("${xxl.job.admin.address}")
private String adminAddresses;
@Value("${xxl.job.accessToken}")
private String accessToken;
@Value("${xxl.job.executor.appname}")
private String appname;
@Value("${xxl.job.executor.address}")
private String address;
@Value("${xxl.job.executor.ip}")
private String ip;
@Value("${xxl.job.executor.port}")
private int port;
@Value("${xxl.job.executor.logpath}")
private String logPath;
@Value("${xxl.job.executor.logretentiondays}")
private int logRetentionDays;
@Bean
public XxlJobSpringExecutor xxlJobExecutor(){
log.info(">>>>>>>>>>> start xxl-job config init");
XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
xxlJobSpringExecutor.setAppname(appname);
xxlJobSpringExecutor.setAddress(address);
xxlJobSpringExecutor.setIp(ip);
xxlJobSpringExecutor.setPort(port);
xxlJobSpringExecutor.setAccessToken(accessToken);
xxlJobSpringExecutor.setLogPath(logPath);
xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);
return xxlJobSpringExecutor;
}
}
四、jobhandler
@Slf4j
@Component
public class DemoJob {
/**
* 简单的job,调度器
*/
@XxlJob("job1")
public void job1() {
log.info("do job1");
}
}
测试
启动执行器项目,在调度中心->执行器管理,创建新执行器,AppName要与执行器配置文件里面配置的名称一致,选择自动注册即可
在调度中心->任务管理,创建新任务,选择我们刚刚创建的新执行器,重点关注JobHandler这个配置,名称要跟相应的任务方法上@XxlJob注解里面的名称一致,才能找到相应的任务去执行
创建完成后,在操作那里进行执行一次,查看对应的日志输出,可知配置成功
2024-02-28 10:43:49.595 INFO 26368 --- [ Thread-8] com.xxl.job.core.thread.JobThread : >>>>>>>>>>> xxl-job JobThread stoped, hashCode:Thread[Thread-8,10,main]
2024-02-28 10:43:50.016 INFO 26368 --- [ Thread-9] com.et.xxljob.handler.DemoJob : do job1