一:概述
1:为什么需要分布式调度
使用Spring中提供的注解@Scheduled,也能实现任务的调度功能
在业务了方法上贴上这个注解,然后在启动类上贴上@EnableScheduling注解
@Scheduled(cron = "0/20 * * * * ? ")
public void doWork() {
// dosomething
}
都有这个注解了,为什么还要分布式注解呢?主要有下面几点原因
- 高可用性:单机的分布式调度只能在一台机器上运行,一旦出现故障就会导致功能不可用
- 防止重复的执行:如果部署了多台服务,同时每台服务又都有定时任务的时候,如果不能合理的控制在同一时间,定时任务可能出现问题和错误
- 单机处理极限:原本一分钟内需要处理一万个订单,但是现在要处理10万个, 单机能力有限,可能出现处理不过来的情况
2:XXL-JOB介绍
XXL-JOB是大众点评的分布式调度平台,是一个轻量级的分布式任务调度平台,其核心的设计目标是开发迅速,学习简单,轻量级,易于扩展
大众点评目前已经介入XXL-JOB,在系统内部调用超过100万次,表现优异
官网地址:https://www.xuxueli.com/xxl-job/
2.1:系统架构图
2.2:调度中心和执行器
调度中心三个核心工作:
- 配置任务的信息(cron表达式)
- 当到达任务的执行时间,将会调用执行器的指定方法执行这个任务
- 收集任务的执行情况,用可视化的界面展示
执行器的四个核心工作:
- 引入xxl-job的依赖
- 添加xxl-job的注解
- 编写任务的处理逻辑
- 配置调度中心的地址(启动的时候会注册自己的信息到调度中心)
二:快速入门
1:调度中心配置
源码下载
源码地址:https://gitee.com/xuxueli0323/xxl-job
调度数据库配置
解压之后获取sql脚本并执行即可
admin配置修改
主要就是将数据库用户名密码换成自己的
### web
server.port=8080
server.servlet.context-path=/xxl-job-admin
### actuator
management.server.base-path=/actuator
management.health.mail.enabled=false
### resources
spring.mvc.servlet.load-on-startup=0
spring.mvc.static-path-pattern=/static/**
spring.web.resources.static-locations=classpath:/static/
### freemarker
spring.freemarker.templateLoaderPath=classpath:/templates/
spring.freemarker.suffix=.ftl
spring.freemarker.charset=UTF-8
spring.freemarker.request-context-attribute=request
spring.freemarker.settings.number_format=0.##########
### mybatis
mybatis.mapper-locations=classpath:/mybatis-mapper/*Mapper.xml
#mybatis.type-aliases-package=com.xxl.job.admin.core.model
### xxl-job, datasource 主要是这里换成自己的
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=314159
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
### datasource-pool
spring.datasource.type=com.zaxxer.hikari.HikariDataSource
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.maximum-pool-size=30
spring.datasource.hikari.auto-commit=true
spring.datasource.hikari.idle-timeout=30000
spring.datasource.hikari.pool-name=HikariCP
spring.datasource.hikari.max-lifetime=900000
spring.datasource.hikari.connection-timeout=10000
spring.datasource.hikari.connection-test-query=SELECT 1
spring.datasource.hikari.validation-timeout=1000
### xxl-job, email
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
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
### xxl-job, access token
xxl.job.accessToken=default_token
### xxl-job, i18n (default is zh_CN, and you can choose "zh_CN", "zh_TC" and "en")
xxl.job.i18n=zh_CN
## xxl-job, triggerpool max size
xxl.job.triggerpool.fast.max=200
xxl.job.triggerpool.slow.max=100
### xxl-job, log retention days
xxl.job.logretentiondays=30
启动调度中心
访问地址:http://localhost:8080/xxl-job-admin
默认登录账号和密码是:admin/123456
2:执行器的部署
maven依赖添加
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- xxl-job-core -->
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
<version>2.3.1</version>
</dependency>
</dependencies>
添加执行器的配置
xxl:
job:
admin:
# 调度中心部署的根地址,如果调度中心集群部署在多个地址用逗号分割
# 执行器会使用这个地址进行“执行器心跳注册”和“任务结果回调”
addresses: http://127.0.0.1:8080/xxl-job-admin
accessToken: default_token # 执行器的通讯token
executor:
appname: xxl-job-work-demo # 执行器的appName, 执行器心跳注册的分组依据,为空的时候关闭自动注册
ip: 127.0.0.1 # 执行器ip
port: 9999 # 执行器端口号。<= 0时自动获取,默认为9999,单机部署多个执行器的时候,注意配置不同的执行器端口
logpath: /data/applogs/xxl-job/jobhandler # 日志文件存储磁盘路径,要求路径具有读写权限,为空的时候为默认路径
logretentiondays: 30 # 执行器日志的保存天数
执行器配置类
package cn.cui.xxljobworkdemo.config;
import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@Slf4j
public class XxlJobConfig {
@Value("${xxl.job.admin.addresses}")
private String adminAddresses;
@Value("${xxl.job.accessToken}")
private String accessToken;
@Value("${xxl.job.executor.appname}")
private String appname;
@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(">>>>>>>>>>> xxl-job config init.");
XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
xxlJobSpringExecutor.setAppname(appname);
xxlJobSpringExecutor.setIp(ip);
xxlJobSpringExecutor.setPort(port);
xxlJobSpringExecutor.setAccessToken(accessToken);
xxlJobSpringExecutor.setLogPath(logPath);
xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);
return xxlJobSpringExecutor;
}
}
定义任务
package cn.cui.xxljobworkdemo.job;
import com.xxl.job.core.handler.annotation.XxlJob;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
public class SimpleJob {
@XxlJob("myFirstJobTest")
public void myFirstJobTest() throws Exception {
System.out.println("执行定时任务,执行时间:" + new Date());
}
}
在调度中心进行配置执行器
执行器管理 -> 新增执行器 -> 输入appName[配置文件中自己定义的]和名称
注册成功会看到注册的节点信息
任务管理
任务管理 -> 新增任务 ->
- 注意执行器选择你刚刚创建的执行器的名称,这个名称是执行器分组用的
- 注意JobHandler中填入的是定义任务代码中的
@XxlJob("myFirstJobTest")
中的内容
调度日志,查看执行情况
3:GLUE模式(java)
glue 胶水,专注于
任务以源码的方式维护在调度中心支持通过web IDE的方式在线更新,实时编译和生效,因此不需要指定JobHandler
本质上是一段继承自IJobHandler的java类代码
,在执行器项目中运行,可以使用注解注入执行器@Resouce
/@AutoWired
中的其他服务
编写业务代码
package cn.cui.xxljobworkdemo.service;
/**
* GLUE模式测试
*/
@Service
public class HelloService {
public void methodA() {
System.out.println("执行MethodA方法");
}
public void methodB() {
System.out.println("执行MethodB方法");
}
}
创建GLUE模式任务
编写实时代码
package com.xxl.job.service.handler;
import com.xxl.job.core.context.XxlJobHelper;
import com.xxl.job.core.handler.IJobHandler;
import cn.cui.xxljobworkdemo.service.HelloService; // 引入要执行的任务的类
import org.springframework.beans.factory.annotation.Autowired; // @Autowired注解引入
public class DemoGlueJobHandler extends IJobHandler {
@Autowired
private HelloService helloService;
@Override
public void execute() throws Exception {
XxlJobHelper.log("XXL-JOB, Hello World.");
helloService.methodA();
}
}
输入备注之后执行即可
测试修改IDE中的方法
不用重新启动项目,修改IDE中的execute方法,使其执行methodB()
4:客户端集群和负载均衡模拟
通过编辑配置的方式,将一个服务复制两个出来赋予不同的端口,模拟客户端集群
两个服务启动之后就可以在XXL-JOB的调度中心中看见两个注册信息
选择轮询的方式执行这个任务
可以看到8088和8089交替执行
路由策略
- FIRST(第一个):固定选择第一个机器
- LAST(最后一个):固定选择最后一个机器;
- ROUND(轮询):依次的选择在线的机器发起调度
- RANDOM(随机):随机选择在线的机器;
- CONSISTENT_HASH(一致性HASH):每个任务按照Hash算法固定选择某一台机器,且所有任务均匀散列在不同机器上。
- LEAST_FREQUENTLY_USED(最不经常使用):使用频率最低的机器优先被选举;
- LEAST_RECENTLY_USED(最近最久未使用):最久未使用的机器优先被选举;
- FAILOVER(故障转移):按照顺序依次进行心跳检测,第一个心跳检测成功的机器选定为目标执行器并发起调度;
- BUSYOVER(忙碌转移):按照顺序依次进行空闲检测,第一个空闲检测成功的机器选定为目标执行器并发起调度;
- SHARDING_BROADCAST(分片广播):广播触发对应集群中所有机器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务;
三:分片功能详解
1:测试环境准备
数据准备
新建一个数据库:xxl-job-demo,指定编码方式是utf-8
新建表:t_user_mobile_plan
DROP TABLE IF EXISTS `t_user_mobile_plan`;
CREATE TABLE `t_user_mobile_plan` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`nickname` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`phone` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`info` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2001 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
插入数据:
-- 类似的,生成1000条
-- ----------------------------
-- Records of t_user_mobile_plan
-- ----------------------------
INSERT INTO `t_user_mobile_plan` VALUES (1, '张三1', '昵称1', '13000000001', '这个家伙很懒,什么都没留下');
INSERT INTO `t_user_mobile_plan` VALUES (2, '张三2', '昵称2', '13000000002', '这个家伙很懒,什么都没留下');
INSERT INTO `t_user_mobile_plan` VALUES (3, '张三3', '昵称3', '13000000003', '这个家伙很懒,什么都没留下');
INSERT INTO `t_user_mobile_plan` VALUES (4, '张三4', '昵称4', '13000000004', '这个家伙很懒,什么都没留下');
INSERT INTO `t_user_mobile_plan` VALUES (5, '张三5', '昵称5', '13000000005', '这个家伙很懒,什么都没留下');
依赖准备
<dependencies>
<!-- starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- core -->
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
<version>2.3.1</version>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!-- mybatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.2.0</version>
</dependency>
<!-- mysql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- druid -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.10</version>
</dependency>
</dependencies>
添加配置
spring:
datasource:
url: jdbc:mysql://localhost:3306/xxl_job_demo?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=UTF-8
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: 314159
type: com.alibaba.druid.pool.DruidDataSource
2:项目配置
添加实体类
package cn.cui.xxljobworkdemo.pojo;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Setter
@Getter
@ToString
public class UserMobilePlan {
private Long id; // 主键
private String username; // 用户名
private String nickname; // 昵称
private String phone; // 手机号码
private String info; // 备注
}
添加Mapper处理类
package cn.cui.xxljobworkdemo.mapper;
import cn.cui.xxljobworkdemo.pojo.UserMobilePlan;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface UserMobilePlanMapper {
/**
* select all
* @return 选择全部的内容
*/
@Select("select * from t_user_mobile_plan")
List<UserMobilePlan> selectAll();
}
主启动类添加扫描
@SpringBootApplication
@MapperScan(basePackages = "cn.cui.xxljobworkdemo.mapper")
public class XxlJobWorkDemoApplication {
public static void main(String[] args) {
SpringApplication.run(XxlJobWorkDemoApplication.class, args);
}
}
定义任务
package cn.cui.xxljobworkdemo.job;
import cn.cui.xxljobworkdemo.mapper.UserMobilePlanMapper;
import cn.cui.xxljobworkdemo.pojo.UserMobilePlan;
import com.xxl.job.core.handler.annotation.XxlJob;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Component
public class SimpleJob {
@Autowired
private UserMobilePlanMapper userMobilePlanMapper;
@XxlJob("myFirstJobTest")
public void myFirstJobTest() throws Exception {
System.out.println("执行定时任务,执行时间:" + new Date());
}
// 定义发送任务 sendMsgHandler()
@XxlJob("sendMsgHandler")
public void sendMsgHandler() throws Exception{
// 模拟给所有的用户发送短信
List<UserMobilePlan> userMobilePlans = userMobilePlanMapper.selectAll();
System.out.println("任务开始时间:"+new Date()+",处理任务数量:"+userMobilePlans.size());
long startTime = System.currentTimeMillis();
userMobilePlans.forEach(item->{
try {
//模拟发送短信动作,每一个需要10ms
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println("任务结束时间:"+new Date());
System.out.println("任务耗时:"+(System.currentTimeMillis()-startTime)+"毫秒");
}
}
测试
启动任务后测试,可以发现项目日志打印:
3:改造成为任务分片
分片概念讲解
比如我们的案例中有2000+条数据,如果不采取分片形式的话,任务只会在一台机器上执行,这样的话需要20+秒才能执行完任务.
如果采取分片广播的形式的话,一次任务调度将会广播触发对应集群中所有执行器执行一次任务,同时系统自动传递分片参数;
可根据分片参数开发分片任务;
获取分片参数方式:
// 可参考Sample示例执行器中的示例任务"ShardingJobHandler"了解试用
int shardIndex = XxlJobHelper.getShardIndex();
int shardTotal = XxlJobHelper.getShardTotal();
通过这两个参数,我们可以通过求模取余的方式,分别查询,分别执行,这样的话就可以提高处理的速度.
之前2000+条数据只在一台机器上执行需要20+秒才能完成任务,分片后,有两台机器可以共同完成2000+条数据,每台机器处理1000+条数据,这样的话只需要10+秒就能完成任务
改造任务分片
1:mapper新增方法
@Mapper
public interface UserMobilePlanMapper {
@Select("select * from t_user_mobile_plan where mod(id,#{shardingTotal})=#{shardingIndex}")
List<UserMobilePlan> selectByMod(@Param("shardingIndex") Integer shardingIndex, @Param("shardingTotal")Integer shardingTotal);
@Select("select * from t_user_mobile_plan")
List<UserMobilePlan> selectAll();
}
2:新增任务
package cn.cui.xxljobworkdemo.job;
import cn.cui.xxljobworkdemo.mapper.UserMobilePlanMapper;
import cn.cui.xxljobworkdemo.pojo.UserMobilePlan;
import com.xxl.job.core.context.XxlJobHelper;
import com.xxl.job.core.handler.annotation.XxlJob;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Component
public class SimpleJob {
@Autowired
private UserMobilePlanMapper userMobilePlanMapper;
@XxlJob("myFirstJobTest")
public void myFirstJobTest() throws Exception {
System.out.println("执行定时任务,执行时间:" + new Date());
}
@XxlJob("sendMsgHandler")
public void sendMsgHandler() throws Exception{
List<UserMobilePlan> userMobilePlans = userMobilePlanMapper.selectAll();
System.out.println("任务开始时间:"+new Date()+",处理任务数量:"+userMobilePlans.size());
calculateTime(userMobilePlans);
}
@XxlJob("sendMsgShardingHandler")
public void sendMsgShardingHandler() throws Exception{
System.out.println("任务开始时间:"+new Date());
// 获取总分片数和当前分片索引
int shardTotal = XxlJobHelper.getShardTotal();
int shardIndex = XxlJobHelper.getShardIndex();
List<UserMobilePlan> userMobilePlans = null;
if(shardTotal==1){
//如果没有分片就直接查询所有数据
userMobilePlans = userMobilePlanMapper.selectAll();
}else{
// 按照分片
userMobilePlans = userMobilePlanMapper.selectByMod(shardIndex,shardTotal);
}
System.out.println("处理任务数量:"+userMobilePlans.size());
calculateTime(userMobilePlans);
}
/**
* 计算时间
*/
public void calculateTime(List<UserMobilePlan> userMobilePlans) {
long startTime = System.currentTimeMillis();
userMobilePlans.forEach(item->{
try {
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println("任务结束时间:"+new Date());
System.out.println("任务耗时:"+(System.currentTimeMillis()-startTime)+"毫秒");
}
}
测试
1:启动两个端口的服务
2:新增任务:注意路由策略指定分片广播
四:再谈XXL_JOB(底层)
1:三大核心概念
1.1:调度中心
- 调度中心是一个单独的Web服务,主要是用来触发定时任务的执行
- 它提供了一些页面操作,我们可以很方便地去管理这些定时任务的触发逻辑
- 调度中心依赖数据库,所以数据都是存在数据库中的
- 调度中心也支持集群模式,但是它们所依赖的数据库必须是同一个
- 所以同一个集群中的调度中心实例之间是没有任何通信的,数据都是通过数据库共享的
1.2:执行器
- 执行器是用来执行具体的任务逻辑的
- 执行器你可以理解为就是平时开发的服务,一个服务实例对应一个执行器实例
- 每个执行器有自己的名字,为了方便,你可以将执行器的名字设置成服务名
1.3:任务
- 就是要进行定时的具体的工作
- 一个执行器中也是可以有多个任务的
总的来说,调用中心是用来控制定时任务的触发逻辑,而执行器是具体执行任务的
这是一种任务和触发逻辑分离的设计思想
这种方式的好处就是使任务更加灵活,可以随时被调用,还可以被不同的调度规则触发。
2:从执行器启动说起
前面Demo中使用到了一个很重要的一个类:XxlJobSpringExecutor
这个类就是整个执行器启动的入口
public class XxlJobSpringExecutor extends XxlJobExecutor
implements ApplicationContextAware, SmartInitializingSingleton, DisposableBean {
private static final Logger logger = LoggerFactory.getLogger(XxlJobSpringExecutor.class);
// start
@Override
public void afterSingletonsInstantiated() {
// init JobHandler Repository
/*initJobHandlerRepository(applicationContext);*/
// init JobHandler Repository (for method)
initJobHandlerMethodRepository(applicationContext);
// refresh GlueFactory
GlueFactory.refreshInstance(1);
// super start
try {
super.start();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// other method..........
}
这个类实现了SmartInitializingSingleton
接口
所以经过Bean的生命周期,一定会调用afterSingletonsInstantiated
这个方法的实现
这个方法干了很多初始化的事,下面的三个最主要:
2.1:初始化JobHandler-封装定时任务
所谓的JobHandler其实就是一个定时任务的封装
一个定时任务会对应一个JobHandler对象
当执行器执行任务的时候,就会调用JobHandler的execute方法,JobHandler有三种实现:
MethodJobHandler
是通过反射
来调用方法执行任务
所以MethodJobHandler的任务的实现就是一个方法,基本模式就是通过MethodJobHandler实现的
GlueJobHandler
支持动态修改任务执行的代码
当你在创建任务的时候,需要指定运行模式为GLUE,也就是上面Demo中的GLUE模式的演示
代码必须得实现IJobHandler接口,之后任务执行的时候就会执行execute方法的实现
如果你需要修改任务的逻辑,只需要重新编辑即可,不需要重启服务
ScriptJobHandler
是专门处理一些脚本的,运行模式除了BEAN和GLUE(Java)之外,其余都是脚本模式
总结
所谓的初始化JobHandler就是指:
- 执行器启动的时候会去Spring容器中找到加了
@XxlJob
注解的Bean - 然后解析注解,然后封装成一个MethodJobHandler对象
- 最终存到XxlJobSpringExecutor成员变量的一个本地的Map缓存中
其中缓存key就是任务的名字
至于GlueJobHandler和ScriptJobHandler都是任务触发时才会创建
除了上面这几种,你也自己实现JobHandler,手动注册到JobHandler的缓存中,也是可以通过调度中心触发的
2.2:创建一个Http服务器-连接调度中心
EmbedServer
除了初始化JobHandler之外,执行器还会创建一个Http服务器
这个服务器端口号就是通过XxlJobSpringExecutor
配置的端口,底层是通过Netty实现的
public class EmbedServer {
// logger
private static final Logger logger = LoggerFactory.getLogger(EmbedServer.class);
private ExecutorBiz executorBiz;
private Thread thread;
public void start(final String address, final int port, final String appname, final String accessToken) {
executorBiz = new ExecutorBizImpl();
thread = new Thread(new Runnable() {
@Override
public void run() {
// 声明父子事件循环组
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
// 声明一个线程池
ThreadPoolExecutor bizThreadPool = new ThreadPoolExecutor(
0,
200,
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(2000),
new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "xxl-job, EmbedServer bizThreadPool-" + r.hashCode());
}
},
new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
throw new RuntimeException("xxl-job, EmbedServer bizThreadPool is EXHAUSTED!");
}
});
try {
// Netty启动器的声明
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup) // 指定启动器的事件循环组
.channel(NioServerSocketChannel.class) // 指明通道的类型
// 子处理器链
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel channel) throws Exception {
channel.pipeline()
.addLast(new IdleStateHandler(0, 0, 30 * 3, TimeUnit.SECONDS)) // beat 3N, close if idle
.addLast(new HttpServerCodec()) // 编码
.addLast(new HttpObjectAggregator(5 * 1024 * 1024)) // merge request & reponse to FULL
.addLast(new EmbedHttpServerHandler(executorBiz, accessToken, bizThreadPool));
}
})
.childOption(ChannelOption.SO_KEEPALIVE, true);
// bind
ChannelFuture future = bootstrap.bind(port).sync();
logger.info(">>>>>>>>>>> xxl-job remoting server start success, nettype = {}, port = {}", EmbedServer.class, port);
// start registry
startRegistry(appname, address);
// wait util stop
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
logger.info(">>>>>>>>>>> xxl-job remoting server stop.");
} catch (Exception e) {
logger.error(">>>>>>>>>>> xxl-job remoting server error.", e);
} finally {
// stop
try {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
}
}
});
thread.setDaemon(true); // daemon, service jvm, user thread leave >>> daemon leave >>> jvm leave
thread.start();
}
// other methods ...........
}
这个Http服务端会接收来自调度中心的请求
ExecutorBizImpl执行线程池
当执行器接收到调度中心的请求时,会把请求交给ExecutorBizImpl
来处理
这个类非常重要,所有调度中心的请求都是这里处理的
ExecutorBizImpl
实现了ExecutorBiz
接口;进入这个接口之后,可以发现,ExecutorBiz
还有一个ExecutorBizClient
实现
ExecutorBizClient
的实现就是发送http请求
所以这个实现类是在调度中心使用的,用来访问执行器提供的http接口
2.3:注册到调度中心
当执行器启动的时候,会启动一个注册线程,这个线程会往调度中心注册当前执行器的信息
包括两部分数据:
- 执行器的名字,也就是设置的appname
- 执行器所在机器的ip和端口,这样调度中心就可以访问到这个执行器提供的Http接口
前面提到每个服务实例都会对应一个执行器实例,所以调用中心会保存每个执行器实例的地址
3:任务触发原理
3.1:任务如何触发
调度中心在启动的时候,会开启一个线程,这个线程的作用就是来计算任务触发时机【调度线程】
这个调度线程会去查询xxl_job_info
这张表,这张表存了任务的一些基本信息和任务下一次执行的时间
调度线程会去查询下一次执行的时间 <= 当前时间 + 5s
的任务
这个5s是XxlJob写死的,被称为预读时间,提前读出来,保证任务能准时触发
也就是说:假设现在的时间是08:00:10
,那个这个操作就会查询出来时间在08:00:15
之前执行的任务
查询到任务之后,调度线程会去将这些任务根据执行时间划分为三个部分:
- 当前时间已经超过任务下一次执行时间5s以上
- 当前时间已经超过任务下一次执行时间,但是但不足5s
- 还未到触发时间,但是一定是5s内就会触发执行的
第一部分任务的处理
对于第一部分的已经超过5s以上时间的任务,会根据任务配置的调度过期策略来选择要不要执行
- 直接忽略这个已经过期的任务
- 立马执行一次这个过期的任务
第二部分任务的处理
对于第二部分的超时时间在5s以内的任务,就直接立马执行一次,之后如果判断任务下一次执行时间就在5s内,会直接放到一个时间轮里面,等待下一次触发执行
第三部分任务的处理
对于第三部分任务,由于还没到执行时间,所以不会立马执行,也是直接放到时间轮里面,等待触发执行
当这批任务处理完成之后,不论是前面是什么情况,调度线程都会去重新计算每个任务的下一次触发时间,然后更新xxl_job_info
这张表的下一次执行的时间
到此,一次调度的计算就算完成了
之后调度线程还会继续重复上面的步骤,查任务,调度任务,更新任务下次执行时间,一直死循环下去,这就实现了任务到了执行时间就会触发的功能
集群调度中心的分布式锁处理
这里在任务触发的时候还有一个很有意思的细节:
由于调度中心可以是集群的形式,每个调度中心实例都有调度线程,那么如何保证任务在同一时间只会被其中的一个调度中心触发一次?
第一时间肯定想到分布式锁,但是怎么加呢?
XxlJob实现就比较有意思了,它是基于八股文中常说的通过数据库来实现的分布式锁的,在调度之前,调度线程会尝试执行下面这句sql:
select * from xxl_job_lock where lock_name = 'schedule_lock' for update
一旦执行成功,说明当前调度中心成功抢到了锁,接下来就可以执行调度任务了
当调度任务执行完之后再去关闭连接,从而释放锁
由于每次执行之前都需要去获取锁,这样就保证在调度中心集群中,同时只有一个调度中心执行调度任务
3.2:快慢线程池的异步触发任务优化
当任务达到了触发条件,并不是由调度线程直接去触发执行器的任务执行
调度线程会将这个触发的任务交给线程池去执行
所以上图中的最后一部分触发任务执行其实是线程池异步去执行的
为什么要使用线程池异步呢?主要是因为触发任务,需要通过Http接口调用具体的执行器实例去触发任务
这一过程必然会耗费时间,如果调度线程去做,就会耽误调度的效率。
所以就通过异步线程去做,调度线程只负责判断任务是否需要执行
并且,Xxl-Job为了进一步优化任务的触发,将这个触发任务执行的线程池划分成快线程池和慢线程池两个线程池
在调用执行器的Http接口触发任务执行的时候,Xxl-Job会去记录每个任务的触发所耗费的时间
注意并不是任务执行时间,只是整个Http请求耗时时间,这是因为执行器执行任务是异步执行的,所以整个时间不包括任务执行时间
当任务一次触发的时间超过500ms,那么这个任务的慢次数就会加1
如果这个任务一分钟内触发的慢次数超过10次,接下来就会将触发任务交给慢线程池去执行
所以快慢线程池就是避免那种频繁触发并且每次触发时间还很长的任务阻塞其它任务的触发的情况发生
3.3:如何选择执行器实例
路由策略
当任务需要触发的时候,调度中心会向执行器发送Http请求,执行器去执行具体的任务
由于一个执行器会有很多实例,那么应该向哪个实例请求?
这其实就跟任务配置时设置的路由策略有关了
从图上可以看出xxljob支持多种路由策略
除了分片广播,其余的具体的算法实现都是通过ExecutorRouter的实现类来实现的
分片广播
XxlJob给每个执行器分配一个编号,从0开始递增,然后向所有执行器触发任务,告诉每个执行器自己的编号和总共执行器的数据
我们可以通过XxlJobHelper#getShardIndex
获取到编号,XxlJobHelper#getShardTotal
获取到执行器的总数据量【就像上面demo演示的那样】
分片广播就是将任务量分散到各个执行器,每个执行器只执行一部分任务,加快任务的处理
举个例子,比如你现在需要处理30w条数据,有3个执行器,此时使用分片广播,那么此时可将任务分成3分,每份10w条数据,执行器根据自己的编号选择对应的那份10w数据处理
当选择好了具体的执行器实例之后,调用中心就会携带一些触发的参数,发送Http请求,触发任务
3.4:执行器如何去执行任务
故障转移和忙碌转移请求执行器进行判断,最终执行器也是交给ExecutorBizImpl处理的
执行器处理触发请求是这个ExecutorBizImpl的run方法实现的
当执行器接收到请求,在正常情况下,执行器会去为这个任务创建一个单独的线程
这个线程被称JobThread,单独的线程可以保证不同的任务执行互不影响
之后任务并不是直接交给线程处理的,而是直接放到一个内存队列中,线程直接从队列中获取任务
为什么不直接处理呢?而是要交给队列,然后从队列中获取任务呢?
那就得讲讲不正常的情况了:如果调度中心选择的执行器实例正在处理定时任务,那么此时该怎么处理呢
这时就跟阻塞处理策略有关
3.5:任务执行结果的回调
当任务处理完成之后,执行器会将任务执行的结果发送给调度中心
如上图所示,这整个过程也是异步化的
- JobThread会将任务执行的结果发送到一个内存队列中
- 执行器启动的时候会开启一个处发送任务执行结果的线程:TriggerCallbackThread
- 这个线程会不停地从队列中获取所有的执行结果,将执行结果批量发送给调度中心
- 调用中心接收到请求时,会根据执行的结果修改这次任务的执行状态和进行一些后续的事,比如失败了是否需要重试,是否有子任务需要触发等等
到此,一次任务的就算真正处理完成了