从0到1,百亿级任务调度平台的架构与实现

news2024/11/13 11:52:07

尼恩:百亿级海量任务调度平台起源

在40岁老架构师 尼恩的读者交流群(50+)中,经常性的指导小伙伴们改造简历。

经过尼恩的改造之后,很多小伙伴拿到了一线互联网企业如得物、阿里、滴滴、极兔、有赞、希音、百度、网易、美团的面试机会,拿到了大厂机会。

这些机会的来源,主要是尼恩给小伙伴 改造了简历,植入了亮点项目、黄金项目。

尼恩的 亮点项目、黄金项目 需要持续迭代。

下一个亮点项目、黄金项目是:百亿级海量任务调度平台。

于是,尼恩组织小伙伴开始研究和 设计 《百亿级海量任务调度平台》,帮助大家打造一个新的黄金项目,实现大厂的梦想。

百亿级海量任务调度平台

海量亿级任务调度平台是为了满足各种业务异步定时执行需求而设计的一套分布式任务调度系统。

它不仅能够满足传统的定时任务执行需求,还可以应对海量任务、高可靠、低延迟等特点。

百亿级海量任务调度平台业务诉求:

在日常开发中会经常遇到一些需要异步定时执行的业务诉求,典型的使用场景如:

  • 超时未支付订单关单
  • 每隔 2h 更新好友排行榜
  • 3.22 日 17 点《xx》剧上线等。

目前业务侧多基于以下思路来快速搭建一个调度系统,mysql 或者 redis 队列存储待执行任务,通过 crontab 定时触发应用完成“捞取、计算、执行等操作”。

不难看出存在几类亟待解决问题:

1)缺少统一的调度平台导致各业务重复开发;

2)简易版调度实现在任务吞吐、调度时效上缺少保障;

3)业务和调度数据强耦合存储给线上稳定性引入大 key、慢 sql 风险。

目前存在多类开源解决方案如 XXL-Job 、 Elastic-Job、quartz 调度等,但这些都属于进程级调度平台,很难满足更细粒度的业务调用。

海量亿级任务调度平台到底长成什么样呢?

海量百亿级任务调度平台主要特点和功能:

海量百亿级任务调度平台主要特点和功能,大致如下。

  1. 分布式架构: 任务调度平台采用分布式架构,可以横向扩展以应对海量任务的调度需求。通过将任务分散到多个节点上执行,提高了系统的吞吐量和稳定性。
  2. 高可靠性: 平台具有高可靠性,能够保证任务的准确执行。采用主备节点、任务重试机制、任务监控和告警等方式确保任务不会丢失和错过。
  3. 低延迟: 任务调度平台能够保证低延迟的任务执行。通过优化任务调度算法、减少任务执行的等待时间、提高任务执行的并发度等方式,降低了任务执行的延迟。
  4. 统一管理: 提供统一的任务管理界面和接口,方便业务方统一管理和监控各类任务。通过可视化界面展示任务执行情况、任务调度状态等信息,提供给用户更直观的操作体验。
  5. 灵活扩展: 平台具有良好的扩展性,可以根据业务需求灵活定制任务调度策略和调度算法。支持定制化的任务调度器、触发器、任务处理器等组件,满足不同业务场景的需求。
  6. 多样化任务类型: 平台支持多样化的任务类型,包括定时任务、周期任务、延时任务、异步任务等。能够满足不同业务场景下的任务调度需求。

海量亿级任务调度平台解决方案:

  1. 统一调度平台: 搭建统一的任务调度平台,为各业务提供统一的任务调度服务,避免各业务重复开发调度功能,提高开发效率。
  2. 高可靠性设计: 在平台设计中考虑高可靠性,采用集群部署、主备机制、任务重试、监控告警等方式确保任务执行的稳定性和可靠性。
  3. 低延迟优化: 优化任务调度算法和执行流程,减少任务调度的等待时间和执行时间,提高任务执行的效率和响应速度。
  4. 解耦业务和调度: 对业务和调度进行解耦,采用消息队列等方式将任务调度数据存储与业务数据存储分离,降低了业务和调度的耦合性,减少了大 key、慢 SQL 的风险。
  5. 技术选型: 综合考虑开源解决方案如XXL-Job、Elastic-Job、Quartz等,并结合业务需求和现有技术栈选择合适的调度平台,以满足海量任务调度的需求。

通过以上扩展,海量亿级任务调度平台能够更好地满足业务的高可靠性、低延迟、统一管理等需求,为企业提供稳定可靠的任务调度服务。

海量百亿级任务调度平台的功能性诉求

任务管理: 包括任务注册、任务启停、任务更新等,

任务查询: 主要用于任务追踪、问题排查、调度统计等,

任务回调: 由业务提供 任务spi回调实现,调度 平台定时调用触发

在这里插入图片描述

海量百亿级任务调度平台的非功能性诉求

海量亿级任务调度平台的非功能性保障:

  • 平台化: 支持多业务接入、百亿级任务注册

  • 易用性: 自助化接入、运维,使用成本远低自建

  • 高可靠: 全年 3 个 9 可用性、p99(时延)<1s

  • 高性能: 支持 100w+TPM 的任务触发

综合看需要 支持百亿级任务量和百万 TPM 并发执行,并在此基础上满足三个 SLA:

  1. 注册\触发可用性>99.95%
  2. 任务触达率>99.99%
  3. p99(触达延时)<1s

百亿级任务量和百万 TPM 并发的架构方案

数据存储架构:

重点解决两个问题数据可靠和海量存储,可靠的存储保障任务不丢、任务高触达率,鉴于 mysql 在持久化以及 master-slave 部署架构对高可用支持表现,优先选用 mysql 作为底层存储;

但单 DB 在 TPS 性能、数据量上存在瓶颈,这里选用分库分表策略,通过增加数据库实例打平数据分布以提升整体性能和存储上限;

实时性架构:

类似多级缓存的思路,为保障任务触发时效(p99<1s)这里的设计思路“任务前置”,拆解任务触发步骤,将任务捞取、计算工作尽量提前完成,通过毫秒级延迟的内存时间轮最终触发,保障任务的触发时效性;

高并发:

采用可伸缩架构设计,存储层尽量拆分为多个逻辑库,前期通过合并部署降低成本但保留多个逻辑库隔离能力,未来支持快速迁移独立部署以提升性能;

应用层采用多级调度思路,按数据分片将大任务拆分成小粒度任务动态根据计算节点数完成分配,实现通过增加计算节点快速提升任务触发能力;

高可用:

MTTR 分段治理思路,架构层在设计阶段考虑到单点、单机房风险,不管是存储层还是应用层都采用多机多活架构,并支持 HA 自动切换大大缩短 MTTF 时效;

立体化的监控+拨测能力,覆盖从注册到触发全流程波动、成功率、耗时、延迟多维度监控,缩短 MTTI 时效;

MTTR 是指 Mean Time to Repair,即平均修复时间。

在系统运维和故障处理领域,MTTR 是一个重要的性能指标,用于衡量系统故障发生后,从故障发生到恢复正常运行所花费的平均时间。

MTTR 的计算方式为:

𝑀𝑇𝑇𝑅=总的修复时间/发生故障次数

MTTR 的值越小越好,因为它反映了系统修复故障的效率和速度。

较低的 MTTR 意味着系统能够更快地从故障中恢复,减少了系统的停机时间,提高了系统的可用性和稳定性。

在实际运维工作中,降低 MTTR 可以通过以下方式实现:

  1. 自动化故障检测和修复: 引入自动化工具和脚本,实现对常见故障的自动检测和修复,减少人工干预的时间和错误。
  2. 监控和告警系统: 配置有效的监控系统,及时发现系统异常和故障,通过告警通知运维人员进行快速响应和处理。
  3. 故障排查和诊断: 提前准备好故障排查的工具和流程,快速定位故障原因,并采取有效的措施进行修复。

MTTF 是指 Mean Time to Failure,即平均故障间隔时间。它是指系统或设备在正常使用条件下,平均运行多长时间后出现故障的时间。MTTF 是一个重要的可靠性指标,用于评估系统或设备的可靠性和稳定性。

MTTF=总的运行时间/发生故障次数

MTTF 的值越大越好,因为它表示系统或设备在正常运行下的平均故障间隔时间越长,说明系统的可靠性越高。

海量百亿级任务调度平台的实操

技术自由圈为了帮助1000多会员打造顶级实操项目,计划基于 XXL-Job,来一个海量百亿级任务调度平台的实操

基于 XXL-JOB 进行二次架构,二次定制,二次改进, 一步一步来。

大概得步骤如下:

  • XXL-JOB 入门学习
  • XXL-JOB 架构和源码学习
  • XXL-JOB-Massive 海量任务调度平台的架构和实现

"海量百亿级任务调度平台"的英文翻译可以是 “Massive Hundred Billion-Level Task Scheduling Platform”。

XXL-JOB 入门学习

XXL-JOB是一个分布式任务调度平台,其核心设计目标是开发迅速、学习简单、轻量级、易扩展。现已开放源代码并接入多家公司线上产品线,开箱即用。

单体任务调度框架

项目中比如对账单,日结、月结、放单短信、营销类短信,等场景都需要任务调度单体系统中有许多实现任务调度的方式,如多线程方式、Timer 类、Spring Tasks 等等。

Springboot里比较常用的是 Spring Tasks(通过 @EnableScheduling + @Scheduled 的注解可以自定义定时任务)

但是,在集群服务下,如果还是使用每台机器按照单体系统的任务调度实现方式实现的话,会出现下面问题:

  1. 怎么做到对任务的控制(如何避免任务重复执行,单体项目集群部署,会重复执行)。
  2. 如果某台机器宕机了,会不会存在任务丢失。
  3. 如果要增加服务实例,怎么做到弹性扩容。
  4. 如何做到对任务调度的执行情况统一监测。
  5. 如何做到高可用
  6. 如何做到动态配置
  7. 如何实现任务分片执行

分布式任务调度框架

分布式定时任务调度的框架可以选择的有:quartz、elastic-job、xxl-job

功能quartzelastic-jobxxl-job
HA(高可用)多节点部署,通过数据库锁来保证只有一个节点执行任务通过zookeeper的注册和发现,可以动态添加服务器,支持水平扩容集群部署
任务分片不支持支持支持
文档完善完善完善完善
管理界面没有
难易程度简单较复杂简单
公司OpenSymphony当当网个人
缺点没有管理界面不支持任务分片,不适用于分布式场景需要引入zookeeper,增加系统复杂度,比较复杂 通过获取数据库锁的方式通过获取数据库锁的方式,保证集群中执行任务的唯一性,性能不好

quartz和xxl-job对比:

  • quartz采用api的方式调用任务,quartz不方便,但是xxl-job使用的是管理界面。
  • quartz比xxl-job代码侵入更强
  • quartz调度逻辑和QuartzJobBean耦合在一个项目中,当任务增多,逻辑复杂的时候,性能会受到影响
  • quartz底层以抢占式获取db锁并且由抢占成功的节点运行,导致节点负载悬殊非常大;xxl-job通过执行器实现协同分配式运行任务,各个节点比较均衡。

方式一:手工部署XXL-Job 的5大步骤

步骤一:部署 XXL-Job
  1. 下载 XXL-Job: 访问 XXL-Job 的官方网站(https://github.com/xuxueli/xxl-job/)下载最新版本的 XXL-Job。
  2. 解压文件: 将下载的压缩包解压到你选择的目录中。
  3. 配置数据库: 进入解压后的目录,找到 conf/application.properties 文件,配置数据库连接信息,包括数据库地址、用户名、密码等。
  4. 创建数据库表: 运行 docs/sql/tables_xxl_job.sql 文件中的 SQL 脚本,在数据库中创建 XXL-Job 所需的表结构。
  5. 启动服务: 运行 bin/startup.sh(Linux/Mac)或 bin/startup.cmd(Windows)启动 XXL-Job 服务。
安装要点:初始化“调度数据库”

下载 XXL-Job: 访问 XXL-Job 的官方网站(https://github.com/xuxueli/xxl-job/)下载最新版本的 XXL-Job。

解压,获取 “调度数据库初始化SQL脚本” 并执行即可。

“调度数据库初始化SQL脚本” 位置为:

/xxl-job/doc/db/tables_xxl_job.sql

调度中心支持集群部署,集群情况下各节点务必连接同一个mysql实例;

如果mysql做主从,调度中心集群节点务必强制走主库;

步骤二:访问管理界面
  1. 打开浏览器,输入 XXL-Job 的访问地址,默认为 http://localhost:8080/xxl-job-admin
  2. 使用默认的管理员账号和密码登录,默认账号密码均为 admin
  3. 登录成功后,可以在管理界面中进行任务的创建、管理、监控等操作。
步骤三:创建任务
  1. 在管理界面中,点击左侧菜单栏的 “任务管理”,然后点击 “新增” 按钮。
  2. 填写任务信息,包括任务描述、任务执行器、任务参数等。
  3. 点击 “保存” 按钮完成任务的创建。
步骤四:监控任务执行情况
  1. 在管理界面中,点击左侧菜单栏的 “任务监控”,可以查看任务的执行情况、调度情况、日志等信息。
  2. 可以手动触发任务执行,也可以查看任务的执行历史记录。
步骤五:扩展其他功能

除了基本的任务管理和监控功能外,XXL-Job 还提供了更多高级功能,如分片广播任务、GLUE脚本任务、任务执行日志清理等。你可以根据实际需求,进一步了解和使用这些功能。

以上就是 XXL-Job 的快速入门指南,希望能够帮助你快速上手并使用 XXL-Job 进行任务调度管理。

方式二:通过 编译源码部署 XXL-Job 的2大步骤

下载 XXL-Job:访问 XXL-Job 的官方网站(https://github.com/xuxueli/xxl-job/)下载最新版本的 XXL-Job。

步骤1:解压的编译源码

解压源码,按照maven格式将源码导入IDE, 使用maven进行编译即可,

源码结构如下:

xxl-job-admin:调度中心
xxl-job-core:公共依赖
xxl-job-executor-samples:执行器Sample示例(选择合适的版本执行器,可直接使用,也可以参考其并将现有项目改造成执行器)

xxl-job-executor-sample-springboot:Springboot版本,通过Springboot管理执行器,推荐这种方式;

xxl-job-executor-sample-frameless:无框架版本;

步骤2:配置部署“调度中心”

调度中心作用:统一管理任务调度平台上调度任务,负责触发调度执行,并且提供任务管理平台。

调度中心项目:xxl-job-admin

1:调度中心配置:

调度中心配置文件地址:

/xxl-job/xxl-job-admin/src/main/resources/application.properties

调度中心配置内容说明:

### 调度中心JDBC链接:链接地址请保持和 2.1章节 所创建的调度数据库的地址一致
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=root_pwd
spring.datasource.driver-class-name=com.mysql.jdbc.Driver

### 报警邮箱
spring.mail.host=smtp.qq.com
spring.mail.port=25
spring.mail.username=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

### 调度中心通讯TOKEN [选填]:非空时启用;
xxl.job.accessToken=

### 调度中心国际化配置 [必填]: 默认为 "zh_CN"/中文简体, 可选范围为 "zh_CN"/中文简体, "zh_TC"/中文繁体 and "en"/英文;
xxl.job.i18n=zh_CN

## 调度线程池最大线程配置【必填】
xxl.job.triggerpool.fast.max=200
xxl.job.triggerpool.slow.max=100

### 调度中心日志表数据保存天数 [必填]:过期日志自动清理;限制大于等于7时生效,否则, 如-1,关闭自动清理功能;
xxl.job.logretentiondays=30

2:部署调度项目:

如果已经正确进行上述配置,可将项目编译打包部署。

调度中心访问地址:http://localhost:8080/xxl-job-admin (该地址执行器将会使用到,作为回调地址)

默认登录账号 “admin/123456”, 登录后运行界面如下图所示。

image.png

至此“调度中心”项目已经部署成功。

3:调度中心集群部署(可选):

调度中心支持集群部署,提升调度系统容灾和可用性。

调度中心集群部署时,几点要求和建议:

  • DB配置保持一致;
  • 集群机器时钟保持一致(单机集群忽视);

建议:

  • 推荐通过nginx为调度中心集群做负载均衡。

  • 通过nginx代理的地址,去进行 调度中心访问、执行器回调配置、调用API服务等操作。

方式三:通过Docker 镜像方式搭建调度中心

  • 下载镜像
// Docker地址:https://hub.docker.com/r/xuxueli/xxl-job-admin/     (建议指定版本号)
docker pull xuxueli/xxl-job-admin
  • 创建容器并运行
/**
* 如需自定义 mysql 等配置,可通过 "-e PARAMS" 指定,参数格式 PARAMS="--key=value  --key2=value2" ;
* 配置项参考文件:/xxl-job/xxl-job-admin/src/main/resources/application.properties
* 如需自定义 JVM内存参数 等配置,可通过 "-e JAVA_OPTS" 指定,参数格式 JAVA_OPTS="-Xmx512m" ;
*/
docker run -e PARAMS="--spring.datasource.url=jdbc:mysql://127.0.0.1:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai" -p 8080:8080 -v /tmp:/data/applogs --name xxl-job-admin  -d xuxueli/xxl-job-admin:{指定版本}

开发、配置、部署 XXl-JOB“执行器项目”

XXL-JOB 的 “执行器项目” 是指用于执行任务的项目,它是 XXL-JOB 的一部分,负责接收并执行任务调度中心下发的任务。执行器项目通常是一个独立的 Java 项目,可以与 XXL-JOB 调度中心进行通信,接收任务执行请求,并执行相应的任务逻辑。

执行器项目的主要作用包括:

  1. 任务执行: 接收调度中心下发的任务执行请求,并执行相应的任务逻辑,例如调用接口、执行业务逻辑、发送消息等。
  2. 任务注册: 将执行器项目注册到 XXL-JOB 的调度中心,以便调度中心能够向执行器发送任务执行请求。
  3. 执行器管理: 提供执行器项目的管理功能,包括启动、停止、监控等。

“执行器”项目:

xxl-job-executor-sample-springboot (提供多种版本执行器供选择,现以 springboot 版本为例,可直接使用,也可以参考其并将现有项目改造成执行器)

可直接部署执行器,也可以将执行器集成到现有业务项目中。

下面是执行器项目的一般开发步骤:

  1. 创建执行器项目: 创建一个新的 Java 项目,作为执行器项目。
  2. 引入 XXL-JOB 客户端库: 在项目中引入 XXL-JOB 客户端库,可以通过 Maven 或 Gradle 等依赖管理工具进行引入。
  3. 配置执行器参数: 在项目中配置执行器的参数,包括执行器名称、注册地址、执行器端口等。
  4. 编写任务执行逻辑: 编写任务执行器的逻辑代码,接收任务执行请求并执行相应的任务逻辑。你可以根据任务类型和具体业务需求来编写不同的任务执行逻辑。
  5. 注册执行器: 在执行器项目中注册执行器到 XXL-JOB 的调度中心,以便调度中心能够向执行器发送任务执行请求。
  6. 启动执行器: 启动执行器项目,让执行器能够正常接收和执行任务。
步骤2:maven依赖

确认pom文件中引入了 “xxl-job-core” 的maven依赖;

在这里插入图片描述

步骤3:执行器配置

执行器配置,配置文件地址:

/xxl-job/xxl-job-executor-samples/xxl-job-executor-sample-springboot/src/main/resources/application.properties

执行器配置,配置内容说明:

### 调度中心部署根地址 [选填]:如调度中心集群部署存在多个地址则用逗号分隔。执行器将会使用该地址进行"执行器心跳注册"和"任务结果回调";为空则关闭自动注册;
xxl.job.admin.addresses=http://127.0.0.1:8080/xxl-job-admin

### 执行器通讯TOKEN [选填]:非空时启用;
xxl.job.accessToken=

### 执行器AppName [选填]:执行器心跳注册分组依据;为空则关闭自动注册
xxl.job.executor.appname=xxl-job-executor-sample
### 执行器注册 [选填]:优先使用该配置作为注册地址,为空时使用内嵌服务 ”IP:PORT“ 作为注册地址。从而更灵活的支持容器类型执行器动态IP和动态映射端口问题。
xxl.job.executor.address=
### 执行器IP [选填]:默认为空表示自动获取IP,多网卡时可手动设置指定IP,该IP不会绑定Host仅作为通讯实用;地址信息用于 "执行器注册" 和 "调度中心请求并触发任务";
xxl.job.executor.ip=
### 执行器端口号 [选填]:小于等于0则自动获取;默认端口为9999,单机部署多个执行器时,注意要配置不同执行器端口;
xxl.job.executor.port=9999
### 执行器运行日志文件存储磁盘路径 [选填] :需要对该路径拥有读写权限;为空则使用默认路径;
xxl.job.executor.logpath=/data/applogs/xxl-job/jobhandler
### 执行器日志文件保存天数 [选填] : 过期日志自动清理, 限制值大于等于3时生效; 否则, 如-1, 关闭自动清理功能;
xxl.job.executor.logretentiondays=30

执行器组件配置 的加载,在下面XxlJobConfig ,路径如

/xxl-job/xxl-job-executor-samples/xxl-job-executor-sample-springboot/src/main/java/com/xxl/job/executor/core/config/XxlJobConfig.java

XxlJobConfig 执行器组件,配置内容说明:

@Bean
public XxlJobSpringExecutor xxlJobExecutor() {
    logger.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;
}
步骤4:编写任务执行逻辑

编写任务执行器的逻辑代码,接收任务执行请求并执行相应的任务逻辑。

可以根据任务类型和具体业务需求来编写不同的任务执行逻辑。

下面是源码里边参考的执行器

在这里插入图片描述

步骤5:部署执行器项目:

如果已经正确进行上述配置,可将执行器项目编译打部署,系统提供多种执行器Sample示例项目,选择其中一个即可,各自的部署方式如下。

xxl-job-executor-sample-springboot:项目编译打包成springboot类型的可执行JAR包,命令启动即可;
xxl-job-executor-sample-frameless:项目编译打包成JAR包,命令启动即可;

至此“执行器”项目已经部署结束。

步骤6:执行器集群(可选):

执行器支持集群部署,提升调度系统可用性,同时提升任务处理能力。

执行器集群部署时,几点要求和建议:

  • 执行器回调地址(xxl.job.admin.addresses)需要保持一致;执行器根据该配置进行执行器自动注册等操作。
  • 同一个执行器集群内AppName(xxl.job.executor.appname)需要保持一致;调度中心根据该配置动态发现不同集群的在线执行器列表。

开发第一个任务“Hello World”

XXL-Job 几种不同的任务模式

XXL-Job 提供了几种不同的任务模式,用于满足不同场景下的任务调度需求。以下是 XXL-Job 的几种任务模式:

  1. Bean模式(Spring Bean模式): Bean 模式是 XXL-Job 最简单的任务模式之一,适用于基于 Spring 框架开发的应用。在 Bean 模式下,任务执行逻辑由一个 Spring Bean 实现,并通过 Spring 的依赖注入机制来管理任务实例。在 XXL-Job 的任务配置中,需要指定任务执行的 Bean 名称和方法名称。
  2. Script模式(GLUE脚本模式): Script 模式是 XXL-Job 提供的另一种灵活的任务模式,允许用户通过脚本来定义任务执行逻辑。在 Script 模式下,任务执行逻辑可以使用多种脚本语言编写,如 Java、Python、Shell 等。用户可以在 XXL-Job 的任务配置中直接编写脚本代码,而无需提前编译和打包。
  3. Remote模式(远程调用模式): Remote 模式允许用户将任务执行逻辑部署在独立的远程服务上,并通过远程调用的方式来执行任务。在 Remote 模式下,XXL-Job 调度中心发送任务执行请求到远程服务,远程服务接收请求并执行任务逻辑,然后返回执行结果给调度中心。这种模式适用于任务逻辑较为复杂或需要与其他系统进行交互的情况。
  4. Shell模式(Shell脚本模式): Shell 模式是一种简单的任务执行模式,适用于需要执行 Shell 脚本的场景。在 Shell 模式下,XXL-Job 的任务配置中直接指定要执行的 Shell 命令或脚本文件路径,XXL-Job 调度中心将执行任务的请求发送给执行器,并在执行器上执行相应的 Shell 命令。

每种任务模式都有其适用的场景和特点,用户可以根据实际需求选择合适的任务模式来实现任务调度和执行功能。

“Bean模式任务”需要在执行器项目开发部署上线, 然后接收调度命令进行调度

“GLUE模式(Java)”的执行代码托管到调度中心在线维护, 用户可以在 XXL-Job 的任务配置中直接编写脚本代码,而无需提前编译和打包,直接在调度节点解释执行。

xxl-job的GLUE模式,包括下面的 子模式:

  1. GLUE模式(Java):任务以源码方式维护在调度中心;该模式的任务实际上是一段继承自IJobHandler的Java类代码并"groovy"源码方式维护,它在执行器项目中运行,可使用@Resource/@Autowire注入执行器里中的其他服务;
  2. GLUE模式(Shell):任务以源码方式维护在调度中心;该模式的任务实际上是一段"shell"脚本;
  3. GLUE模式(Python):任务以源码方式维护在调度中心;该模式的任务实际上是一段"python"脚本;
  4. GLUE模式(PHP):任务以源码方式维护在调度中心;该模式的任务实际上是一段"php"脚本;
  5. GLUE模式(NodeJS):任务以源码方式维护在调度中心;该模式的任务实际上是一段"nodejs"脚本;
  6. GLUE模式(PowerShell):任务以源码方式维护在调度中心;该模式的任务实际上是一段"PowerShell"脚本;

“GLUE模式(Java)” 运行模式的开发第一个调度任务

总之,相比来说,“GLUE模式(Java)”更加简便轻量,适合调试。

本示例以新建一个 “GLUE模式(Java)” 运行模式的任务为例。

前提:请确认“调度中心”和“执行器”项目已经成功部署并启动;

步骤一:新建任务:

登录调度中心,点击下图所示“新建任务”按钮,新建示例任务。

然后,参考下面截图中任务的参数配置,点击保存。
image.png
image.png

步骤二:“GLUE模式(Java)” 任务开发:

请点击任务右侧 “GLUE” 按钮,进入 “GLUE编辑器开发界面” ,见下图。

“GLUE模式(Java)” 运行模式的任务默认已经初始化了示例任务代码,即打印Hello World。

“GLUE模式(Java)” 运行模式的任务实际上是一段继承自IJobHandler的Java类代码,它在执行器项目中运行,

image.png
image.png

实际上, “GLUE模式(Java)” 使用@Resource/@Autowire注入执行器里中的其他服务

步骤三:触发执行:

请点击任务右侧 “执行” 按钮,可手动触发一次任务执行(通常情况下,通过配置Cron表达式进行任务调度触发)。

步骤四:查看日志:

请点击任务右侧 “日志” 按钮,可前往任务日志界面查看任务日志。
在任务日志界面中,可查看该任务的历史调度记录以及每一次调度的任务调度信息、执行参数和执行信息。运行中的任务点击右侧的“执行日志”按钮,可进入日志控制台查看实时执行日志。
image.png

在日志控制台,可以Rolling方式实时查看任务在执行器一侧运行输出的日志信息,实时监控任务进度;

image.png

大规模任务的分片调度

用一张图来描述大规模任务的分片调度
image.png

调度中心会向当前任务的所有执行器节点都发起一个调度请求,并且带上分片参数。

执行器在收到请求之后,可以获取执行器的 index的值,以不同index的值来做分片策略。

image.png

对应的任务代码:

    static Map<Integer, String> singleMachineMultiTasks = new HashMap<>();

    static {
        singleMachineMultiTasks.put(1, "武汉");
        singleMachineMultiTasks.put(2, "222");
        singleMachineMultiTasks.put(3, "北京");
        singleMachineMultiTasks.put(4, "444");
        singleMachineMultiTasks.put(5, "上海");
        singleMachineMultiTasks.put(6, "666");
    }

    /**
     * 分片任务
     * @return
     * @throws Exception
     */
    @XxlJob(value = "multiMachineMultiTasks", init = "init", destroy = "destroy")
    public ReturnT<String> multiMachineMultiTasks() throws Exception {
        String params =  XxlJobHelper.getJobParam();

        int n = XxlJobHelper.getShardTotal(); // 动态获取所有实例数
        int i = XxlJobHelper.getShardIndex(); // 当前为第i个序号

        List<Integer> ids =  singleMachineMultiTasks.keySet().stream().collect(Collectors.toList());

        IntStream.range(0, ids.size()).forEach(index -> {
            //使用取余分片
            if (index % n == i) {
                int cityIndex = ids.get(index);
                String city = singleMachineMultiTasks.get(cityIndex);
                XxlJobHelper.log("实例【{}】执行【{}】,任务内容为:{}", i, cityIndex, city);
            }
        });
        return ReturnT.SUCCESS;
    }

执行结果:

image.png

image.png

XXL-JOB的配置属性说明

  1. 基础配置:
  • **执行器:**任务的绑定的执行器,任务触发调度时将会自动发现注册成功的执行器, 实现任务自动发现功能; 另一方面也可以方便的进行任务分组。每个任务必须绑定一个执行器, 可在 “执行器管理” 进行设置;
  • 任务描述:任务的描述信息,便于任务管理;
  • 负责人:任务的负责人;
  • 报警邮件:任务调度失败时邮件通知的邮箱地址,支持配置多邮箱地址,配置多个邮箱地址时用逗号分隔;
  1. 触发配置:
  • 调度类型:
    • 无:该类型不会主动触发调度;
    • CRON:该类型将会通过CRON,触发任务调度;
    • 固定速度:该类型将会以固定速度,触发任务调度;按照固定的间隔时间,周期性触发;
    • 固定延迟:该类型将会以固定延迟,触发任务调度;按照固定的延迟时间,从上次调度结束后开始计算延迟时间,到达延迟时间后触发下次调度;
  • CRON:触发任务执行的Cron表达式;
  • 固定速度:固定速度的时间间隔,单位为秒;
  • 固定延迟:固定延迟的时间间隔,单位为秒;
  1. 任务配置:
  • 运行模式:
    • BEAN模式:以JobHandler方式维护在执行器端;需要结合 “JobHandler” 属性匹配执行器中任务;
    • GLUE模式(Java):任务以源码方式维护在调度中心;该模式的任务实际上是一段继承自IJobHandler的Java类代码并 “groovy” 源码方式维护,它在执行器项目中运行,可使用@Resource/@Autowire注入执行器里中的其他服务;
    • GLUE模式(Shell):任务以源码方式维护在调度中心;该模式的任务实际上是一段 “shell” 脚本;
    • GLUE模式(Python):任务以源码方式维护在调度中心;该模式的任务实际上是一段 “python” 脚本;
    • GLUE模式(PHP):任务以源码方式维护在调度中心;该模式的任务实际上是一段 “php” 脚本;
    • GLUE模式(NodeJS):任务以源码方式维护在调度中心;该模式的任务实际上是一段 “nodejs” 脚本;
    • GLUE模式(PowerShell):任务以源码方式维护在调度中心;该模式的任务实际上是一段 “PowerShell” 脚本;
  • JobHandler:运行模式为 “BEAN模式” 时生效,对应执行器中新开发的JobHandler类“@JobHandler”注解自定义的value值;
  • 执行参数:任务执行所需的参数;
  1. 高级配置:
  • 路由策略:当执行器集群部署时,提供丰富的路由策略,包括;
    • FIRST(第一个):固定选择第一个机器;
    • LAST(最后一个):固定选择最后一个机器;
    • ROUND(轮询):;
    • RANDOM(随机):随机选择在线的机器;
    • CONSISTENT_HASH(一致性HASH):每个任务按照Hash算法固定选择某一台机器,且所有任务均匀散列在不同机器上。
    • LEAST_FREQUENTLY_USED(最不经常使用):使用频率最低的机器优先被选举;
    • LEAST_RECENTLY_USED(最近最久未使用):最久未使用的机器优先被选举;
    • FAILOVER(故障转移):按照顺序依次进行心跳检测,第一个心跳检测成功的机器选定为目标执行器并发起调度;
    • BUSYOVER(忙碌转移):按照顺序依次进行空闲检测,第一个空闲检测成功的机器选定为目标执行器并发起调度;
    • SHARDING_BROADCAST(分片广播):广播触发对应集群中所有机器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务;
  • 子任务:每个任务都拥有一个唯一的任务ID(任务ID可以从任务列表获取),当本任务执行结束并且执行成功时,将会触发子任务ID所对应的任务的一次主动调度。
  • 调度过期策略:
    • 忽略:调度过期后,忽略过期的任务,从当前时间开始重新计算下次触发时间;
    • 立即执行一次:调度过期后,立即执行一次,并从当前时间开始重新计算下次触发时间;
  • 阻塞处理策略:调度过于密集执行器来不及处理时的处理策略;
    • 单机串行(默认):调度请求进入单机执行器后,调度请求进入FIFO队列并以串行方式运行;
    • 丢弃后续调度:调度请求进入单机执行器后,发现执行器存在运行的调度任务,本次请求将会被丢弃并标记为失败;
    • 覆盖之前调度:调度请求进入单机执行器后,发现执行器存在运行的调度任务,将会终止运行中的调度任务并清空队列,然后运行本地调度任务;
  • 任务超时时间:支持自定义任务超时时间,任务运行超时将会主动中断任务;
  • 失败重试次数;支持自定义任务失败重试次数,当任务失败时将会按照预设的失败重试次数主动进行重试;

XXL-JOB实现原理

XXL-JOB 的实现原理主要涉及到调度中心和执行器两个核心组件的工作原理:

1. 调度中心(JobAdmin):

  • 任务管理: 调度中心负责管理所有注册的任务信息,包括任务的配置、调度策略、执行器信息等。
  • 任务调度: 调度中心根据配置的调度策略,定时触发任务的执行请求,并将执行请求发送给相应的执行器。
  • 任务监控: 调度中心实时监控任务的执行情况,包括任务的执行状态、执行日志、执行结果等,提供给用户进行查看和管理。

2. 执行器(JobExecutor):

  • 任务接收: 执行器接收调度中心下发的任务执行请求,并根据任务配置执行相应的任务逻辑。
  • 任务执行: 执行器执行任务逻辑,包括调用业务接口、执行脚本、执行 Shell 命令等,根据任务执行结果将执行结果返回给调度中心。
  • 任务线程池: 执行器维护一个任务线程池,用于执行任务逻辑。任务线程池的大小、任务执行策略等可以根据实际情况进行配置。

工作流程:

  1. 任务注册: 执行器在启动时向调度中心注册自己的信息,包括执行器名称、IP地址、端口号等。
  2. 任务调度: 调度中心根据任务的调度策略,定时触发任务的执行请求,并将执行请求发送给相应的执行器。
  3. 任务执行: 执行器接收到任务执行请求后,根据任务配置执行相应的任务逻辑,并将执行结果返回给调度中心。
  4. 任务监控: 调度中心实时监控任务的执行情况,记录任务的执行日志和执行结果,提供给用户进行查看和管理。

XXL-JOB 通过调度中心和执行器的协作,实现了任务的调度、执行和监控功能,为用户提供了一个简单易用的任务调度平台。

XXL-JOB 系统总体架构

如果自研一个xxl-job分析如下:

时序图:
image.png

xxl-job架构图
image.png
image.png
image.png

JobAdmin服务器启动流程

XXL-JOB 的 JobAdmin 服务器启动流程如下:

  1. 加载配置文件: JobAdmin 服务器启动时,首先加载配置文件,包括数据库连接配置、任务调度配置等。
  2. 初始化数据库: JobAdmin 服务器会连接数据库,并初始化数据库表结构。如果数据库中已经存在 XXL-JOB 相关的表结构,则会检查表结构是否正确,如果表结构不正确,则会进行修复或升级。
  3. 启动任务调度: JobAdmin 服务器启动时,会启动任务调度器,定时触发任务的执行请求。任务调度器根据配置的调度策略,定时触发任务的执行请求,并将执行请求发送给相应的执行器。
  4. 注册执行器: JobAdmin 服务器会尝试连接注册在注册中心的执行器,并获取执行器的信息,包括执行器的名称、IP地址、端口号等。如果注册中心中不存在任何执行器,则 JobAdmin 服务器会等待执行器的注册。
  5. 启动Web服务: JobAdmin 服务器启动后,会提供 Web 服务,包括任务管理、任务监控等功能。用户可以通过浏览器访问 JobAdmin 的 Web 界面,进行任务的配置、管理和监控。
  6. 启动日志服务: JobAdmin 服务器启动后,会启动日志服务,负责记录任务的执行日志和执行结果。用户可以通过 Web 界面查看任务的执行日志和执行结果。
  7. 定时检查任务状态: JobAdmin 服务器会定时检查任务的状态,包括任务的执行状态、执行结果等。如果发现任务执行异常或超时,则会记录相应的日志,并发送告警通知给相关人员。
  8. 提供服务: JobAdmin 服务器启动完成后,开始提供服务,用户可以通过 Web 界面进行任务的配置、管理和监控。

以上是 JobAdmin 服务器启动流程的主要步骤,通过这些步骤,JobAdmin 服务器能够正常运行,并提供任务调度和管理功能。

JobAdmin服务器初始化总体流程

首先找到配置类 XxlJobAdminConfig
image.png
可以发现该类实现 InitializingBean接口,这里直接看 afterPropertiesSet方法即可。

    @Override
    public void afterPropertiesSet() throws Exception {
        //利用静态声明的只会加载一次的特性,初始化一个单例对象。
        adminConfig = this;

        //初始化xxjob调度器
        xxlJobScheduler = new XxlJobScheduler();
        xxlJobScheduler.init();
    }

初始化xxjob调度器中心用到的各个组件

    public void init() throws Exception {
        //1. init i18n
        initI18n();

        //2. admin trigger pool start 初始化下发任务(触发器)线程池
        JobTriggerPoolHelper.toStart();

        //3. admin registry monitor run  初始化注册中心线程池,心跳30秒一次
        JobRegistryHelper.getInstance().start();

        //4. admin lose-monitor run ( depend on JobTriggerPoolHelper ) 初始化失败重试和告警(发邮件)线程,间隔10s执行一次
        JobFailMonitorHelper.getInstance().start();

        //5. admin lose-monitor run ( depend on JobTriggerPoolHelper ) 初始化回调线程池,初始化监控线程,记录运行了10分钟,执行器不在线的任务日志
        JobCompleteHelper.getInstance().start();

        //6. admin log report start  6. 初始化统计日志线程,间隔1分钟执行一次,统计3天日志,应该是管控台要用,如果设置了日志保留天数,清除日志
        JobLogReportHelper.getInstance().start();

        // start-schedule  ( depend on JobTriggerPoolHelper ) 执行调度器
        JobScheduleHelper.getInstance().start();

        logger.info(">>>>>>>>> init xxl-job admin success.");
    }

该方法主要做了如下事情:

  1. 初始化 i18n
  2. 初始化下发任务(触发器)线程池
  3. 初始化注册中心线程池,心跳30秒一次
  4. 初始化失败重试和告警(发邮件)线程,间隔10s执行一次
  5. 初始化回调线程池,初始化监控线程,记录运行了10分钟,执行器不在线的任务日志
  6. 初始化统计日志线程,间隔1分钟执行一次,统计3天日志,应该是管控台要用,如果设置了日志保留天数,清除日志
  7. 执行调度器(下发任务)

初始化JobTriggerPool触发器线程池

XXL-JOB 的 JobTriggerPool 是用于触发任务执行请求的线程池,它负责根据任务的调度策略定时触发任务的执行,并将任务执行请求发送给相应的执行器。以下是 JobTriggerPool 触发器线程池的主要功能和特点:

  1. 任务调度触发: JobTriggerPool 定期触发任务的执行请求,根据任务的调度策略,按照设定的触发频率或触发时间点触发任务执行。
  2. 灵活配置: JobTriggerPool 的参数可以通过配置文件进行灵活配置,包括线程池大小、调度频率、调度策略等,以满足不同场景下的任务调度需求。
  3. 并发执行: JobTriggerPool 支持并发执行多个任务,根据任务的调度情况,同时触发多个任务的执行请求,提高任务执行效率。
  4. 可靠性保障: JobTriggerPool 实现了任务调度的可靠性保障机制,保证任务的准时触发和执行,同时支持任务调度的容错和重试功能,确保任务的可靠执行。
  5. 监控和管理: JobTriggerPool 提供了监控和管理功能,可以实时监控触发器线程池的运行状态和任务执行情况,包括线程池的活跃线程数、任务调度情况、任务执行结果等。
  6. 异常处理: JobTriggerPool 实现了异常处理机制,当触发器线程池发生异常时,可以进行相应的异常处理,如记录日志、发送告警等,保证系统的稳定运行。

JobTriggerPool 触发器线程池能够有效地实现任务的定时调度和触发,保证任务的可靠执行,并提供监控和管理功能,帮助用户更好地管理和运维任务调度系统。

    public static void toStart() {
        helper.start();
    }
 public void start(){
        //最大200线程,最多处理1000任务
        fastTriggerPool = new ThreadPoolExecutor(
                10,
                XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax(),
                60L,
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(1000),
                new ThreadFactory() {
                    @Override
                    public Thread newThread(Runnable r) {
                        return new Thread(r, "xxl-job, admin JobTriggerPoolHelper-fastTriggerPool-" + r.hashCode());
                    }
                });

        //最大100线程,最多处理2000任务
        //一分钟内超时10次,则采用慢触发器执行
        slowTriggerPool = new ThreadPoolExecutor(
                10,
                XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax(),
                60L,
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(2000),
                new ThreadFactory() {
                    @Override
                    public Thread newThread(Runnable r) {
                        return new Thread(r, "xxl-job, admin JobTriggerPoolHelper-slowTriggerPool-" + r.hashCode());
                    }
                });
    }

注意:这里分别初始化了2个线程池,一个快一个慢,

优先选择快,当一分钟以内任务超过10次执行时间超过500ms,则加入慢线程池执行。

初始化registryOrRemoveThreadPool 线程池,剔除超时主机,更新主机列表

XXL-JOB 中的 registryOrRemoveThreadPool 线程池主要用于执行器的注册和注销操作。下面是它的主要功能和特点:

  1. 执行器注册: 当执行器启动时,会向调度中心注册自己的信息,包括执行器的名称、IP 地址、端口号等。registryOrRemoveThreadPool 线程池负责处理执行器注册的请求,确保注册操作的可靠性和及时性。
  2. 执行器注销: 当执行器停止或下线时,需要向调度中心注销自己的信息,以便调度中心不再向该执行器发送任务执行请求。registryOrRemoveThreadPool 线程池也负责处理执行器注销的请求,确保注销操作的及时性和有效性。
  3. 并发处理: registryOrRemoveThreadPool 线程池支持并发处理多个注册和注销请求,根据系统的负载情况和注册/注销请求的数量,动态调整线程池的大小,以提高系统的并发处理能力。
  4. 任务调度保障: 由于执行器的注册和注销操作是任务调度的关键步骤之一,registryOrRemoveThreadPool 线程池的稳定运行对于保障任务调度的正常进行非常重要。它保证了注册和注销操作的及时处理,确保任务调度系统的可靠性和稳定性。
  5. 异常处理: registryOrRemoveThreadPool 线程池实现了异常处理机制,当注册或注销操作发生异常时,会进行相应的异常处理,如记录日志、发送告警等,保证系统的稳定运行。

通过以上功能和特点,registryOrRemoveThreadPool 线程池在 XXL-JOB 中起着至关重要的作用,它保证了执行器注册和注销操作的可靠性和及时性,是任务调度系统的重要组成部分。

初始化registryOrRemoveThreadPool 线程池,剔除超时主机,更新主机列表,这里主要做3个工作,然后启动线程剔除超时主机,更新主机列表

  1. 初始化注册或者删除线程池,主要负责客户端注册或者销毁到xxl_job_registry表
  2. 剔除超时注册机器
  3. 更新xxl_job_group执行器地址列表
public void start(){

		// for registry or remove
		//初始化注册或者删除线程池
		registryOrRemoveThreadPool = new ThreadPoolExecutor(
				2,
				10,
				30L,
				TimeUnit.SECONDS,
				new LinkedBlockingQueue<Runnable>(2000),
				new ThreadFactory() {
					@Override
					public Thread newThread(Runnable r) {
						return new Thread(r, "xxl-job, admin JobRegistryMonitorHelper-registryOrRemoveThreadPool-" + r.hashCode());
					}
				},
				//注意:这里的拒绝策略就是再次执行...^_^'''
				new RejectedExecutionHandler() {
					@Override
					public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
						r.run();
						logger.warn(">>>>>>>>>>> xxl-job, registry or remove too fast, match threadpool rejected handler(run now).");
					}
				});

		// for monitor    30秒执行一次,维护注册表信息, 判断在线超时时间90s
		registryMonitorThread = new Thread(new Runnable() {
			@Override
			public void run() {
				while (!toStop) {
					try {
						// auto registry group
						//查询自动注册的数据
						//这里如果没添加自动注册的数据,则不会进入该方法,然后删除register表中超时注册数据。
						List<XxlJobGroup> groupList = XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().findByAddressType(0);
						if (groupList!=null && !groupList.isEmpty()) {

							// remove dead address (admin/executor)
							// 1):从注册表中删除超时90s的机器,不分是否自动注册
							List<Integer> ids = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().findDead(RegistryConfig.DEAD_TIMEOUT, new Date());
							if (ids!=null && ids.size()>0) {
								//从数据库删除注册机器信息
								XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().removeDead(ids);
							}

							// fresh online address (admin/executor)
							// 获取所有在线机器,注册表: 见"xxl_job_registry"表, "执行器" 在进行任务注册时将会周期性维护一条注册记录,
							// 即机器地址和AppName的绑定关系; "调度中心" 从而可以动态感知每个AppName在线的机器列表;
							HashMap<String, List<String>> appAddressMap = new HashMap<String, List<String>>();  //维护注册表注册key和注册value
							// 不分是否自动注册
							List<XxlJobRegistry> list = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().findAll(RegistryConfig.DEAD_TIMEOUT, new Date());
							if (list != null) {
								for (XxlJobRegistry item: list) {
									// 2):将注册类型为EXECUTOR的XxlJobRegistry集合改装成appname=>设置触发器的ip地址
									if (RegistryConfig.RegistType.EXECUTOR.name().equals(item.getRegistryGroup())) {
										//AppName: 每个执行器机器集群的唯一标示, 任务注册以 "执行器" 为最小粒度进行注册; 每个任务通过其绑定的执行器可感知对应的执行器机器列表;
										String appname = item.getRegistryKey();
										List<String> registryList = appAddressMap.get(appname);
										if (registryList == null) {
											registryList = new ArrayList<String>();
										}

										if (!registryList.contains(item.getRegistryValue())) {
											registryList.add(item.getRegistryValue());
										}
										appAddressMap.put(appname, registryList);
									}
								}
							}

							// fresh group address
							// 3):更新xxl_job_group执行器地址列表
							for (XxlJobGroup group: groupList) {
								List<String> registryList = appAddressMap.get(group.getAppname());
								String addressListStr = null;//将所有配置触发器的ip地址,使用,拼接
								if (registryList!=null && !registryList.isEmpty()) {
									Collections.sort(registryList);
									StringBuilder addressListSB = new StringBuilder();
									for (String item:registryList) {
										addressListSB.append(item).append(",");
									}
									addressListStr = addressListSB.toString();
									addressListStr = addressListStr.substring(0, addressListStr.length()-1);
								}
								group.setAddressList(addressListStr);//更新设置了触发器的ip地址
								group.setUpdateTime(new Date());//更新修改时间

								//将注册表中appname对应的多条ip地址,整成appname-> ips(IP1,IP2,IP3)格式存储xxl_job_group表中,只针对自动注册。
								XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().update(group);
							}
						}
					} catch (Exception e) {
						if (!toStop) {
							logger.error(">>>>>>>>>>> xxl-job, job registry monitor thread error:{}", e);
						}
					}
					try {
						TimeUnit.SECONDS.sleep(RegistryConfig.BEAT_TIMEOUT);
					} catch (InterruptedException e) {
						if (!toStop) {
							logger.error(">>>>>>>>>>> xxl-job, job registry monitor thread error:{}", e);
						}
					}
				}
				logger.info(">>>>>>>>>>> xxl-job, job registry monitor thread stop");
			}
		});
		registryMonitorThread.setDaemon(true);
		registryMonitorThread.setName("xxl-job, admin JobRegistryMonitorHelper-registryMonitorThread");
		registryMonitorThread.start();
	}

执行失败重试和告警(发邮件)

启动监控线程,监控线程做了2件事,10秒间隔执行

  • 失败重试
  • 告警,发送邮件
  1. 失败重试

任务有两个状态

  • trigger_code:服务器下发任务的状态,200表示成功,500失败
  • handle_code:执行器执行任务的状态,200表示成功,500失败

这里判断失败有2种情况

  • 第一种:trigger_code!=(0,200) 且 handle_code!=0
  • 第二种:handle_code!=200

image.png

告警(这里可向spring注入JobAlarm)
public void start(){
		monitorThread = new Thread(new Runnable() {

			@Override
			public void run() {

				// monitor
				while (!toStop) {
					try {
						//获取执行失败的日志 调度日志表: 用于保存XXL-JOB任务调度的历史信息,如调度结果、执行结果、调度入参、调度机器和执行器等等;
						List<Long> failLogIds = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().findFailJobLogIds(1000);
						if (failLogIds!=null && !failLogIds.isEmpty()) {//1:执行触发器成功,返回值失败.2:触发器失败
							for (long failLogId: failLogIds) {

								// lock log   加锁,乐观修改alarm_status=-1
								int lockRet = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateAlarmStatus(failLogId, 0, -1);
								if (lockRet < 1) {
									continue;
								}
								XxlJobLog log = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().load(failLogId); //获取失败日志具体信息
								XxlJobInfo info = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().loadById(log.getJobId()); //加载job信息

								// 1、fail retry monitor
								if (log.getExecutorFailRetryCount() > 0) { //若可重试次数>0,则再次执行触发器
									JobTriggerPoolHelper.trigger(log.getJobId(), TriggerTypeEnum.RETRY, (log.getExecutorFailRetryCount()-1), log.getExecutorShardingParam(), log.getExecutorParam(), null);
									String retryMsg = "<br><br><span style=\"color:#F39C12;\" > >>>>>>>>>>>"+ I18nUtil.getString("jobconf_trigger_type_retry") +"<<<<<<<<<<< </span><br>";
									log.setTriggerMsg(log.getTriggerMsg() + retryMsg);
									XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateTriggerInfo(log); //修改触发器执行信息
								}

								// 2、fail alarm monitor 失败警告监视器
								int newAlarmStatus = 0;		// 告警状态:0-默认、-1=锁定状态、1-无需告警、2-告警成功、3-告警失败
								if (info!=null && info.getAlarmEmail()!=null && info.getAlarmEmail().trim().length()>0) {//若设置报警邮箱,则执行报警
									boolean alarmResult = XxlJobAdminConfig.getAdminConfig().getJobAlarmer().alarm(info, log);  //发送警报
									newAlarmStatus = alarmResult?2:3; //获取警报执行状态
								} else {//没设置报警邮箱,则更改状态为不需要告警
									newAlarmStatus = 1;
								}
								//释放锁
								XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateAlarmStatus(failLogId, -1, newAlarmStatus);
							}
						}

					} catch (Exception e) {
						if (!toStop) {
							logger.error(">>>>>>>>>>> xxl-job, job fail monitor thread error:{}", e);
						}
					}

                    try {
                        TimeUnit.SECONDS.sleep(10);
                    } catch (Exception e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }
                    }

                }

				logger.info(">>>>>>>>>>> xxl-job, job fail monitor thread stop");

			}
		});
		monitorThread.setDaemon(true);
		monitorThread.setName("xxl-job, admin JobFailMonitorHelper");
		monitorThread.start();
	}

在这里可通过实现JobAlarm接口,注入spring容器执行报警。

boolean alarmResult = XxlJobAdminConfig.getAdminConfig().getJobAlarmer().alarm(info, log);
 public boolean alarm(XxlJobInfo info, XxlJobLog jobLog) {

        boolean result = false;
        if (jobAlarmList!=null && jobAlarmList.size()>0) {
            result = true;  // success means all-success
            for (JobAlarm alarm: jobAlarmList) {
                boolean resultItem = false;
                try {
                    resultItem = alarm.doAlarm(info, jobLog);
                } catch (Exception e) {
                    logger.error(e.getMessage(), e);
                }
                if (!resultItem) {
                    result = false;
                }
            }
        }

        return result;
    }

这里jobAlarmList集合里面有很多JobAlarm对象, 都是执行初始化方法从spring注入的,

也就是说扩展的话,只需要实现JobAlarm接口,注入spring即可。

image.png

初始化callbackThreadPool回调线程池,更改丢失主机的任务状态为失败

这里做了两件事:

  • 初始化回调线程池
  • 启动线程,每个1分钟执行一次,将丢失主机信息的调度日志更改状态
public void start(){

		// for callback  针对回调函数处理的线程池
		callbackThreadPool = new ThreadPoolExecutor(
				2,
				20,
				30L,
				TimeUnit.SECONDS,
				new LinkedBlockingQueue<Runnable>(3000),
				new ThreadFactory() {
					@Override
					public Thread newThread(Runnable r) {
						return new Thread(r, "xxl-job, admin JobLosedMonitorHelper-callbackThreadPool-" + r.hashCode());
					}
				},
				new RejectedExecutionHandler() {
					@Override
					public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
						r.run();
						logger.warn(">>>>>>>>>>> xxl-job, callback too fast, match threadpool rejected handler(run now).");
					}
				});


		// for monitor
		monitorThread = new Thread(new Runnable() {

			@Override
			public void run() {

				// wait for JobTriggerPoolHelper-init
				try {
					TimeUnit.MILLISECONDS.sleep(50);
				} catch (InterruptedException e) {
					if (!toStop) {
						logger.error(e.getMessage(), e);
					}
				}

				// monitor
				while (!toStop) {
					try {
						// 任务结果丢失处理:调度记录停留在 "运行中" 状态超过10min,且对应执行器心跳注册失败不在线,则将本地调度主动标记失败;
						Date losedTime = DateUtil.addMinutes(new Date(), -10); //调度日志表: 用于保存XXL-JOB任务调度的历史信息,如调度结果、执行结果、调度入参、调度机器和执行器等等;
						List<Long> losedJobIds  = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().findLostJobIds(losedTime);

						if (losedJobIds!=null && losedJobIds.size()>0) {
							for (Long logId: losedJobIds) {

								XxlJobLog jobLog = new XxlJobLog();
								jobLog.setId(logId);

								jobLog.setHandleTime(new Date());
								jobLog.setHandleCode(ReturnT.FAIL_CODE);
								jobLog.setHandleMsg( I18nUtil.getString("joblog_lost_fail") );

								//更改处理状态
								XxlJobCompleter.updateHandleInfoAndFinish(jobLog);
							}

						}
					} catch (Exception e) {
						if (!toStop) {
							logger.error(">>>>>>>>>>> xxl-job, job fail monitor thread error:{}", e);
						}
					}

                    try {
                        TimeUnit.SECONDS.sleep(60);
                    } catch (Exception e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }
                    }

                }

				logger.info(">>>>>>>>>>> xxl-job, JobLosedMonitorHelper stop");

			}
		});
		monitorThread.setDaemon(true);
		monitorThread.setName("xxl-job, admin JobLosedMonitorHelper");
		monitorThread.start();
	}

任务结果丢失处理:调度记录停留在 “运行中” 状态超过10min,且对应执行器心跳注册失败不在线,则将本地调度主动标记失败;

   public static int updateHandleInfoAndFinish(XxlJobLog xxlJobLog) {

        // finish 若父任务正常结束,则终止子任务,以及设置Childmsg
        finishJob(xxlJobLog);

        // text最大64kb 避免长度过长 截断超过长度限制字符
        if (xxlJobLog.getHandleMsg().length() > 15000) {
            xxlJobLog.setHandleMsg( xxlJobLog.getHandleMsg().substring(0, 15000) );
        }

        // fresh handle 更新超时joblog
        return XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateHandleInfo(xxlJobLog);
    }

启动统计日志线程

  • 启动线程,间隔1分钟执行一次
  • 统计“前三天”失败和成功的报表数据,管控台要用
  • 如果设置了日志保留天数,比如1天,清除超时日志
  public void start(){
        logrThread = new Thread(new Runnable() {
            //每分钟刷新一次
            @Override
            public void run() {

                // last clean log time   记录上次清除日志时间
                long lastCleanLogTime = 0;


                while (!toStop) {

                    // 1、log-report refresh: refresh log report in 3 days
                    try {

                        for (int i = 0; i < 3; i++) {

                            // today  分别统计今天,昨天,前天0~24点的数据
                            Calendar itemDay = Calendar.getInstance();
                            itemDay.add(Calendar.DAY_OF_MONTH, -i);
                            itemDay.set(Calendar.HOUR_OF_DAY, 0);
                            itemDay.set(Calendar.MINUTE, 0);
                            itemDay.set(Calendar.SECOND, 0);
                            itemDay.set(Calendar.MILLISECOND, 0);

                            Date todayFrom = itemDay.getTime();

                            itemDay.set(Calendar.HOUR_OF_DAY, 23);
                            itemDay.set(Calendar.MINUTE, 59);
                            itemDay.set(Calendar.SECOND, 59);
                            itemDay.set(Calendar.MILLISECOND, 999);

                            Date todayTo = itemDay.getTime();

                            // refresh log-report every minute
                            //设置默认值
                            XxlJobLogReport xxlJobLogReport = new XxlJobLogReport();
                            xxlJobLogReport.setTriggerDay(todayFrom);
                            xxlJobLogReport.setRunningCount(0);
                            xxlJobLogReport.setSucCount(0);
                            xxlJobLogReport.setFailCount(0);

                            //查询失败, 成功,总的调用次数
                            Map<String, Object> triggerCountMap = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().findLogReport(todayFrom, todayTo);
                            if (triggerCountMap!=null && triggerCountMap.size()>0) {
                                int triggerDayCount = triggerCountMap.containsKey("triggerDayCount")?Integer.valueOf(String.valueOf(triggerCountMap.get("triggerDayCount"))):0;
                                int triggerDayCountRunning = triggerCountMap.containsKey("triggerDayCountRunning")?Integer.valueOf(String.valueOf(triggerCountMap.get("triggerDayCountRunning"))):0;
                                int triggerDayCountSuc = triggerCountMap.containsKey("triggerDayCountSuc")?Integer.valueOf(String.valueOf(triggerCountMap.get("triggerDayCountSuc"))):0;
                                int triggerDayCountFail = triggerDayCount - triggerDayCountRunning - triggerDayCountSuc;

                                xxlJobLogReport.setRunningCount(triggerDayCountRunning);
                                xxlJobLogReport.setSucCount(triggerDayCountSuc);
                                xxlJobLogReport.setFailCount(triggerDayCountFail);
                            }

                            // do refresh
                            //刷新调用次数,若找不到则默认都是0
                            int ret = XxlJobAdminConfig.getAdminConfig().getXxlJobLogReportDao().update(xxlJobLogReport);
                            if (ret < 1) {
                                //没数据则保存
                                XxlJobAdminConfig.getAdminConfig().getXxlJobLogReportDao().save(xxlJobLogReport);
                            }
                        }

                    } catch (Exception e) {
                        if (!toStop) {
                            logger.error(">>>>>>>>>>> xxl-job, job log report thread error:{}", e);
                        }
                    }

                    // 2、log-clean: switch open & once each day
                    //设置了保留日志天数且日志保留了24小时,则进入
                    if (XxlJobAdminConfig.getAdminConfig().getLogretentiondays()>0
                            && System.currentTimeMillis() - lastCleanLogTime > 24*60*60*1000) {

                        // expire-time
                        //通过日志保留天数算出清除log时间
                        Calendar expiredDay = Calendar.getInstance();
                        expiredDay.add(Calendar.DAY_OF_MONTH, -1 * XxlJobAdminConfig.getAdminConfig().getLogretentiondays());
                        expiredDay.set(Calendar.HOUR_OF_DAY, 0);
                        expiredDay.set(Calendar.MINUTE, 0);
                        expiredDay.set(Calendar.SECOND, 0);
                        expiredDay.set(Calendar.MILLISECOND, 0);
                        Date clearBeforeTime = expiredDay.getTime();

                        // clean expired log
                        List<Long> logIds = null;
                        do {
                            //这里传了3个0表示查询所有,而不是单个任务id
                            logIds = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().findClearLogIds(0, 0, clearBeforeTime, 0, 1000);
                            //删除过期数据
                            if (logIds!=null && logIds.size()>0) {
                                XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().clearLog(logIds);
                            }
                        } while (logIds!=null && logIds.size()>0);

                        // update clean time
                        lastCleanLogTime = System.currentTimeMillis();
                    }

                    try {
                        TimeUnit.MINUTES.sleep(1);
                    } catch (Exception e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }
                    }

                }

                logger.info(">>>>>>>>>>> xxl-job, job log report thread stop");

            }
        });
        logrThread.setDaemon(true);
        logrThread.setName("xxl-job, admin JobLogReportHelper");
        logrThread.start();
    }

启动调度器

服务器调度器有两个线程

  • scheduleThread:添加任务到时间轮
  • ringThread:执行任务的时间轮线程
1 添加任务到时间轮

这里首先基于mysql数据库,获取分布式锁,然后查询出下次执行时间在未来5秒以内的所有任务,

一次最多获取6000条。

image.png

通过任务的下次触发时间判断任务是否过期,根据过期时间会分成三种对应处理。

  • 触发器下次执行时间过期时间 > 5S
  • 触发器下次执行时间过期时间 < 5S
  • 触发器下次执行时间在未来5S以内。

具体如下:
image.png

源码:

   public void start(){

        // schedule thread
        scheduleThread = new Thread(new Runnable() {
            @Override
            public void run() {

                try {  
                  //下5秒之后执行一次,等待服务器启动。
                    TimeUnit.MILLISECONDS.sleep(5000 - System.currentTimeMillis()%1000 );
                } catch (InterruptedException e) {
                    if (!scheduleThreadToStop) {
                        logger.error(e.getMessage(), e);
                    }
                }
                logger.info(">>>>>>>>> init xxl-job admin scheduler success.");

                // pre-read count: treadpool-size * trigger-qps (each trigger cost 50ms, qps = 1000/50 = 20)  每个触发器花费50ms,每个线程单位时间内处理20任务,最多同时处理300*20=6000任务
                int preReadCount = (XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax() + XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax()) * 20;

                while (!scheduleThreadToStop) {

                    // Scan Job
                    long start = System.currentTimeMillis();

                    Connection conn = null;
                    Boolean connAutoCommit = null;
                    PreparedStatement preparedStatement = null;

                    boolean preReadSuc = true;
                    try {
                        //设置手动提交
                        conn = XxlJobAdminConfig.getAdminConfig().getDataSource().getConnection();
                        connAutoCommit = conn.getAutoCommit();
                        conn.setAutoCommit(false);
                        //获取任务调度锁表内数据信息,加写锁
                        preparedStatement = conn.prepareStatement(  "select * from xxl_job_lock where lock_name = 'schedule_lock' for update" );
                        preparedStatement.execute();

                        // tx start

                        // 1、pre read
                        long nowTime = System.currentTimeMillis();   //获取当前时间后5秒,同时最多负载的分页数
                        List<XxlJobInfo> scheduleList = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleJobQuery(nowTime + PRE_READ_MS, preReadCount);
                        if (scheduleList!=null && scheduleList.size()>0) {
                            // 2、push time-ring
                            for (XxlJobInfo jobInfo: scheduleList) {

                                // time-ring jump
                                if (nowTime > jobInfo.getTriggerNextTime() + PRE_READ_MS) {  //触发器过期时间>5s
                                    // 2.1、trigger-expire > 5s:pass && make next-trigger-time
                                    logger.warn(">>>>>>>>>>> xxl-job, schedule misfire, jobId = " + jobInfo.getId());

                                    // 1、misfire match
//                                    - 调度过期策略:
//                                    - 忽略:调度过期后,忽略过期的任务,从当前时间开始重新计算下次触发时间;
//                                    - 立即执行一次:调度过期后,立即执行一次,并从当前时间开始重新计算下次触发时间;
                                    MisfireStrategyEnum misfireStrategyEnum = MisfireStrategyEnum.match(jobInfo.getMisfireStrategy(), MisfireStrategyEnum.DO_NOTHING);
                                    if (MisfireStrategyEnum.FIRE_ONCE_NOW == misfireStrategyEnum) { //若过期策略为FIRE_ONCE_NOW,则立即执行一次
                                        // FIRE_ONCE_NOW 》 trigger //执行触发器
                                        JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.MISFIRE, -1, null, null, null);
                                        logger.debug(">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId() );
                                    }

                                    // 2、fresh next  更新下次执行时间
                                    refreshNextValidTime(jobInfo, new Date());

                                } else if (nowTime > jobInfo.getTriggerNextTime()) { //触发器过期时间<5s
                                    // 2.2、trigger-expire < 5s:direct-trigger && make next-trigger-time

                                    // 1、trigger //执行触发器
                                    JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.CRON, -1, null, null, null);
                                    logger.debug(">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId() );

                                    // 2、fresh next 更新下次执行时间
                                    refreshNextValidTime(jobInfo, new Date());

                                    // next-trigger-time in 5s, pre-read again 下次触发时间在当前时间往后5秒范围内
                                    if (jobInfo.getTriggerStatus()==1 && nowTime + PRE_READ_MS > jobInfo.getTriggerNextTime()) {

                                        // 1、make ring second 获取下次执行秒
                                        int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);

                                        // 2、push time ring
                                        pushTimeRing(ringSecond, jobInfo.getId());

                                        // 3、fresh next 更新下次执行时间
                                        refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));

                                    }

                                } else {
                                    // 2.3、trigger-pre-read:time-ring trigger && make next-trigger-time
                                    //未来五秒以内执行的所有任务添加到ringData
                                    // 1、make ring second
                                    int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);

                                    // 2、push time ring
                                    pushTimeRing(ringSecond, jobInfo.getId());

                                    // 3、fresh next
                                    refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));

                                }

                            }

                            // 3、update trigger info 更新执行时间和上次执行时间到数据库
                            for (XxlJobInfo jobInfo: scheduleList) {
                                XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleUpdate(jobInfo);
                            }

                        } else {
                            preReadSuc = false;
                        }

                        // tx stop


                    } catch (Exception e) {
                        if (!scheduleThreadToStop) {
                            logger.error(">>>>>>>>>>> xxl-job, JobScheduleHelper#scheduleThread error:{}", e);
                        }
                    } finally {

                        // commit
                        if (conn != null) {
                            try {
                                conn.commit();
                            } catch (SQLException e) {
                                if (!scheduleThreadToStop) {
                                    logger.error(e.getMessage(), e);
                                }
                            }
                            try {
                                conn.setAutoCommit(connAutoCommit);
                            } catch (SQLException e) {
                                if (!scheduleThreadToStop) {
                                    logger.error(e.getMessage(), e);
                                }
                            }
                            try {
                                conn.close();
                            } catch (SQLException e) {
                                if (!scheduleThreadToStop) {
                                    logger.error(e.getMessage(), e);
                                }
                            }
                        }

                        // close PreparedStatement
                        if (null != preparedStatement) {
                            try {
                                preparedStatement.close();
                            } catch (SQLException e) {
                                if (!scheduleThreadToStop) {
                                    logger.error(e.getMessage(), e);
                                }
                            }
                        }
                    }
                    long cost = System.currentTimeMillis()-start;


                    // Wait seconds, align second
                    if (cost < 1000) {  // scan-overtime, not wait
                        try {
                            // pre-read period: success > scan each second; fail > skip this period;
                            //若执行成功,下一秒继续执行。执行失败或没查询出数据则5秒执行一次。
                            TimeUnit.MILLISECONDS.sleep((preReadSuc?1000:PRE_READ_MS) - System.currentTimeMillis()%1000);
                        } catch (InterruptedException e) {
                            if (!scheduleThreadToStop) {
                                logger.error(e.getMessage(), e);
                            }
                        }
                    }

                }

                logger.info(">>>>>>>>>>> xxl-job, JobScheduleHelper#scheduleThread stop");
            }
        });
        scheduleThread.setDaemon(true);
        scheduleThread.setName("xxl-job, admin JobScheduleHelper#scheduleThread");
        scheduleThread.start();

    }

2 时间轮线程执行任务

当数据添加到ringData后,ringThread会一秒执行一次,然后从ringData获取数据。

ringData本质是一个ConcurrentHashMap,容量60,key = 触发器下次执行时间(秒为单位)%60,value = 触发器jobId。这里采用了时间轮的思想,定时任务一秒执行一次,会以当前时间秒往前递推2秒。

将数据从ringData取出来,然后执行任务。

比如现在时间为0点0分0秒,当前时间秒为0,则会将ringData中索引为59和58的数据捞出来。

xxl-job这样做是为了避免处理耗时太长,所以会跨过刻度,多向前校验一个刻度。

        // ring thread
        ringThread = new Thread(new Runnable() {
            @Override
            public void run() {

                while (!ringThreadToStop) {

                    // align second
                    try {
                        TimeUnit.MILLISECONDS.sleep(1000 - System.currentTimeMillis() % 1000);
                    } catch (InterruptedException e) {
                        if (!ringThreadToStop) {
                            logger.error(e.getMessage(), e);
                        }
                    }

                    try {
                        // second data
                        List<Integer> ringItemData = new ArrayList<>();
                        int nowSecond = Calendar.getInstance().get(Calendar.SECOND);   // 避免处理耗时太长,跨过刻度,向前校验一个刻度;
                        for (int i = 0; i < 2; i++) {
                                                      //假设现在为1秒,那么执行任务之后,5秒之后的任务分别会添加到23456下标位置。
                            // i=1:(1+60-1)%60=0
                            // i=2:(1+60-2)%60=59
                            List<Integer> tmpData = ringData.remove( (nowSecond+60-i)%60 );
                            if (tmpData != null) {
                                ringItemData.addAll(tmpData);
                            }
                        }

                        // ring trigger
                        logger.debug(">>>>>>>>>>> xxl-job, time-ring beat : " + nowSecond + " = " + Arrays.asList(ringItemData) );
                        if (ringItemData.size() > 0) {
                            // do trigger
                            for (int jobId: ringItemData) {
                                // do trigger //执行触发器
                                JobTriggerPoolHelper.trigger(jobId, TriggerTypeEnum.CRON, -1, null, null, null);
                            }
                            // clear
                            ringItemData.clear();
                        }
                    } catch (Exception e) {
                        if (!ringThreadToStop) {
                            logger.error(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread error:{}", e);
                        }
                    }
                }
                logger.info(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread stop");
            }
        });
        ringThread.setDaemon(true);
        ringThread.setName("xxl-job, admin JobScheduleHelper#ringThread");
        ringThread.start();

服务器下发任务大致分为两种情况

  • 手动触发:管控台手动启动一次任务
  • 自动触发:根据触发条件触发任务,有时间轮线程处理

服务器下发任务整体流程的时序图如下:

image.png

3 时间轮算法

时间轮这个技术其实出来很久了,在kafka、zookeeper、Netty、Dubbo等高性能组件中都有时间轮使
用的方式。

时间轮的数据结构, 其实类似hashmap,时间轮每一个时间刻度,可以理解为一个槽位,同一时刻存在多个任务 ,放在双向链表中。如下图所示:
image.png
和hashmap不同的是,时间轮的key,是时间刻度值,并且,时间轮不做hash运算,直接使用时间作为key,比如秒,时间轮在同一时刻存在多个任务时,只要把该刻度对应的链表全部遍历一遍,执行其中的任务即可。

当然, 时钟调度的线程,和 执行任务的线程,一般是需要解耦的。

为什么要采用时间轮算法?

考虑如果不用时间轮算法可以有两个方案

  • 方案1:每个任务对应一个调度线程,然后让线程等待间隔时间,然后执行,但是xxl-job任务太多的话,会创建太多线程,所以采用时间轮,也就是让一个线程执行多个任务
  • 方案2:采用DelayQueue延迟队列,大量入队和出队对性能影响应该比较大。

故此这里采用简易时间轮算法
image.png

在xxl-job中,作者是将时间轮分为了60个槽,索引分别是0,1,2…,59,其实就是一分钟的60s,

然后每一个槽对应着一个任务List集合(如上图),通过(nextTriggerTime/1000)%60来找到每个任务对应的执行时间秒,由于这些任务都是在将来5秒内需要执行的,所以,这个时间轮中最多有4个槽有数据,这样所有的待执行的任务都放入了时间轮中。

任务的执行,需要另一个线程来每秒捞一次时间轮,而作者避免处理耗时太长,导致有一秒的任务被遗落,所以每次都会向前校验一个,比如说,当前需要捞取索引8的槽,那么就会同时尝试去捞取7,8两个槽的数据,如果7号槽有数据证明前面的任务执行慢了导致7号被跳过,此时捞出来执行。

这样一个简易的时间轮就实现了。

之所以说这是一个简易的时间轮是因为,xxl-job中考虑的情况很简单,因为它是扫描的未来5秒内将要执行的任务,所以以秒为最小单位就可以了,而且不需要考虑执行时间大于一轮的这种情况

执行器Executor 启动流程

构建作业执行器,这里将 com.xxl.job.core.executor.impl.XxlJobSpringExecutor 交由容器托管

    @Bean
    public XxlJobSpringExecutor xxlJobExecutor() {
        logger.info(">>>>>>>>>>> xxl-job config init.");
        XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
        //xxjob管理地址
        xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
        //注册应用名称
        xxlJobSpringExecutor.setAppname(appname);
        //xxl作业执行器注册表地址
        xxlJobSpringExecutor.setAddress(address);
        xxlJobSpringExecutor.setIp(ip);
        xxlJobSpringExecutor.setPort(port);
        //注册地址的token
        xxlJobSpringExecutor.setAccessToken(accessToken);
        //日志路径
        xxlJobSpringExecutor.setLogPath(logPath);
        //日志保存天数
        xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);

        return xxlJobSpringExecutor;
    }

接口继承了 ApplicationContext 对象。以及实现了 SmartInitializingSingleton 接口,实现该接口的当spring容器初始完成,紧接着执行监听器发送监听后,就会遍历所有的Bean然后初始化所有单例非懒加载的bean,最后在实例化阶段结束时触发回调接口。
image.png

 // start
    @Override
    public void afterSingletonsInstantiated() {

        // init JobHandler Repository
        /*initJobHandlerRepository(applicationContext);*/

        // init JobHandler Repository (for method)   初始化调度器资源管理器
        initJobHandlerMethodRepository(applicationContext);

        // refresh GlueFactory 刷新GlueFactory
        GlueFactory.refreshInstance(1);

        // super start
        try {
            super.start();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

该方法主要做了如下事情:

  1. 初始化调度器资源管理器(从spring容器中将标记了XxlJob注解的方法,将其封装并添加到map中。)
  2. 刷新GlueFactory
  3. 启动服务,接收服务器请求。
 // start
    @Override
    public void afterSingletonsInstantiated() {

        // init JobHandler Repository
        /*initJobHandlerRepository(applicationContext);*/

        // init JobHandler Repository (for method)   初始化调度器资源管理器(从spring容器中将标记了XxlJob注解的方法,将其封装并添加到map中。)
        initJobHandlerMethodRepository(applicationContext);

        // refresh GlueFactory 刷新GlueFactory
        GlueFactory.refreshInstance(1);

        // super start
        try {
            super.start();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

1 遍历XxlJob注解查找方法

该方法主要做了如下事情:

  1. 从spring容器获取所有对象,并遍历查找方法上标记XxlJob注解的方法
  2. 将xxljob配置的jobname作为key,对象,反射的执行,初始,销毁方法作为value注册jobHandlerRepository 中
  private void initJobHandlerMethodRepository(ApplicationContext applicationContext) {
        if (applicationContext == null) {
            return;
        }
        // init job handler from method
        String[] beanDefinitionNames = applicationContext.getBeanNamesForType(Object.class, false, true);
        for (String beanDefinitionName : beanDefinitionNames) {//遍历每个容器对象
            Object bean = applicationContext.getBean(beanDefinitionName);

            Map<Method, XxlJob> annotatedMethods = null;   // referred to :org.springframework.context.event.EventListenerMethodProcessor.processBean
            try { //获取每个注解XxlJob方法
                annotatedMethods = MethodIntrospector.selectMethods(bean.getClass(),
                        new MethodIntrospector.MetadataLookup<XxlJob>() {
                            @Override
                            public XxlJob inspect(Method method) {
                                return AnnotatedElementUtils.findMergedAnnotation(method, XxlJob.class);
                            }
                        });
            } catch (Throwable ex) {
                logger.error("xxl-job method-jobhandler resolve error for bean[" + beanDefinitionName + "].", ex);
            }
            if (annotatedMethods==null || annotatedMethods.isEmpty()) {
                continue;
            }
            //遍历标记了XxlJob注解的方法
            for (Map.Entry<Method, XxlJob> methodXxlJobEntry : annotatedMethods.entrySet()) {
                Method executeMethod = methodXxlJobEntry.getKey();
                XxlJob xxlJob = methodXxlJobEntry.getValue();
                if (xxlJob == null) {
                    continue;
                }

                String name = xxlJob.value();//获取配置xxjob的触发器名称
                if (name.trim().length() == 0) {
                    throw new RuntimeException("xxl-job method-jobhandler name invalid, for[" + bean.getClass() + "#" + executeMethod.getName() + "] .");
                }
                if (loadJobHandler(name) != null) { //工作处理资源库是否有相同命名
                    throw new RuntimeException("xxl-job jobhandler[" + name + "] naming conflicts.");
                }

                // execute method
                /*if (!(method.getParameterTypes().length == 1 && method.getParameterTypes()[0].isAssignableFrom(String.class))) {
                    throw new RuntimeException("xxl-job method-jobhandler param-classtype invalid, for[" + bean.getClass() + "#" + method.getName() + "] , " +
                            "The correct method format like \" public ReturnT<String> execute(String param) \" .");
                }
                if (!method.getReturnType().isAssignableFrom(ReturnT.class)) {
                    throw new RuntimeException("xxl-job method-jobhandler return-classtype invalid, for[" + bean.getClass() + "#" + method.getName() + "] , " +
                            "The correct method format like \" public ReturnT<String> execute(String param) \" .");
                }*/

                executeMethod.setAccessible(true);//设置可访问,设置后可通过反射调用私有方法

                // init and destory
                Method initMethod = null;
                Method destroyMethod = null;

                if (xxlJob.init().trim().length() > 0) {
                    try {  //获取XxlJob标记的方法,配置的init方法
                        initMethod = bean.getClass().getDeclaredMethod(xxlJob.init());
                        initMethod.setAccessible(true);
                    } catch (NoSuchMethodException e) {
                        throw new RuntimeException("xxl-job method-jobhandler initMethod invalid, for[" + bean.getClass() + "#" + executeMethod.getName() + "] .");
                    }
                }
                if (xxlJob.destroy().trim().length() > 0) {
                    try {//获取XxlJob标记的方法,配置的destroy方法
                        destroyMethod = bean.getClass().getDeclaredMethod(xxlJob.destroy());
                        destroyMethod.setAccessible(true);
                    } catch (NoSuchMethodException e) {
                        throw new RuntimeException("xxl-job method-jobhandler destroyMethod invalid, for[" + bean.getClass() + "#" + executeMethod.getName() + "] .");
                    }
                }

                // registry jobhandler 将xxljob配置的jobname作为key,对象,反射的执行,初始,销毁方法作为value注册jobHandlerRepository中
                registJobHandler(name, new MethodJobHandler(bean, executeMethod, initMethod, destroyMethod));
            }
        }

    }

2 启动客户端执行器

执行器启动,主要做了如下事情:

  1. 初始化日志文件
  2. 封装调度中心请求路径,用于访问调度中心
  3. 清除过期日志
  4. 回调调度中心任务,汇报执行状态
  5. 执行内嵌服务
    public void start() throws Exception {

        // init logpath
        XxlJobFileAppender.initLogPath(logPath);//初始化日志文件

        // init invoker, admin-client
        initAdminBizList(adminAddresses, accessToken); //初始化admin链接路径存储集合


        // init JobLogFileCleanThread  清除过期日志
        JobLogFileCleanThread.getInstance().start(logRetentionDays);

        // init TriggerCallbackThread
        TriggerCallbackThread.getInstance().start();

        // init executor-server 执行内嵌服务
        initEmbedServer(address, ip, port, appname, accessToken);
    }

2.1 初始化日志文件
	public static void initLogPath(String logPath){
		// init
		if (logPath!=null && logPath.trim().length()>0) {
			logBasePath = logPath;
		}
		// mk base dir 日志文件不存在则创建
		File logPathDir = new File(logBasePath);
		if (!logPathDir.exists()) {
			logPathDir.mkdirs();
		}
		logBasePath = logPathDir.getPath();

		// mk glue dir 创建glue目录
		File glueBaseDir = new File(logPathDir, "gluesource");
		if (!glueBaseDir.exists()) {
			glueBaseDir.mkdirs();
		}
		glueSrcPath = glueBaseDir.getPath();
	}
2.2 封装调度中心请求路径,用于访问调度中心
   private void initAdminBizList(String adminAddresses, String accessToken) throws Exception {
        if (adminAddresses!=null && adminAddresses.trim().length()>0) {
            for (String address: adminAddresses.trim().split(",")) { //多个admin地址以,分隔
                if (address!=null && address.trim().length()>0) {

                    AdminBiz adminBiz = new AdminBizClient(address.trim(), accessToken);

                    if (adminBizList == null) {
                        adminBizList = new ArrayList<AdminBiz>();
                    } //将admin地址以及token添加adminBiz中
                    adminBizList.add(adminBiz);
                }
            }
        }
    }
2.3 清除过期日志
 public void start(final long logRetentionDays){

        // limit min value 日志最大保存天数<3天,直接退出
        if (logRetentionDays < 3 ) {
            return;
        }
        //一天执行一次
        localThread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (!toStop) {
                    try {
                        // clean log dir, over logRetentionDays 查询目录下所有子文件(包含目录)
                        File[] childDirs = new File(XxlJobFileAppender.getLogPath()).listFiles();
                        if (childDirs!=null && childDirs.length>0) {

                            // today   获取今天0点时间
                            Calendar todayCal = Calendar.getInstance();
                            todayCal.set(Calendar.HOUR_OF_DAY,0);
                            todayCal.set(Calendar.MINUTE,0);
                            todayCal.set(Calendar.SECOND,0);
                            todayCal.set(Calendar.MILLISECOND,0);

                            Date todayDate = todayCal.getTime();

                            for (File childFile: childDirs) {

                                // valid  不是目录跳过
                                if (!childFile.isDirectory()) {
                                    continue;
                                }//查询不到'-'则跳过
                                if (childFile.getName().indexOf("-") == -1) {
                                    continue;
                                }

                                // file create date  获取文件创建时间,文件都是以年-月-日命名的
                                Date logFileCreateDate = null;
                                try {
                                    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
                                    logFileCreateDate = simpleDateFormat.parse(childFile.getName());
                                } catch (ParseException e) {
                                    logger.error(e.getMessage(), e);
                                }
                                if (logFileCreateDate == null) {
                                    continue;
                                }
                                //大于日志最大存活时间则清除
                                if ((todayDate.getTime()-logFileCreateDate.getTime()) >= logRetentionDays * (24 * 60 * 60 * 1000) ) {
                                    FileUtil.deleteRecursively(childFile);//超过保存天数则清除日志
                                }

                            }
                        }

                    } catch (Exception e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }

                    }

                    try { //睡眠一天处理
                        TimeUnit.DAYS.sleep(1);
                    } catch (InterruptedException e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }
                    }
                }
                logger.info(">>>>>>>>>>> xxl-job, executor JobLogFileCleanThread thread destory.");

            }
        });
        localThread.setDaemon(true);
        localThread.setName("xxl-job, executor JobLogFileCleanThread");
        localThread.start();
    }
2.4 回调调度中心,汇报执行状态
 public void start() {

        // valid 是否有配置admin路径
        if (XxlJobExecutor.getAdminBizList() == null) {
            logger.warn(">>>>>>>>>>> xxl-job, executor callback config fail, adminAddresses is null.");
            return;
        }

        // callback
        triggerCallbackThread = new Thread(new Runnable() {

            @Override
            public void run() {

                // normal callback
                while(!toStop){
                    try {
                        //获取回调参数
                        HandleCallbackParam callback = getInstance().callBackQueue.take();
                        if (callback != null) {

                            // callback list param
                            List<HandleCallbackParam> callbackParamList = new ArrayList<HandleCallbackParam>();
                            //移除队列中所有元素到callbackParamList中
                            int drainToNum = getInstance().callBackQueue.drainTo(callbackParamList);
                            callbackParamList.add(callback);

                            // callback, will retry if error 通知admin
                            if (callbackParamList!=null && callbackParamList.size()>0) {
                                doCallback(callbackParamList);
                            }
                        }
                    } catch (Exception e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }
                    }
                }

                // last callback
                try {
                    List<HandleCallbackParam> callbackParamList = new ArrayList<HandleCallbackParam>();
                    int drainToNum = getInstance().callBackQueue.drainTo(callbackParamList);
                    if (callbackParamList!=null && callbackParamList.size()>0) {
                        doCallback(callbackParamList);
                    }
                } catch (Exception e) {
                    if (!toStop) {
                        logger.error(e.getMessage(), e);
                    }
                }
                logger.info(">>>>>>>>>>> xxl-job, executor callback thread destory.");

            }
        });
        triggerCallbackThread.setDaemon(true);
        triggerCallbackThread.setName("xxl-job, executor TriggerCallbackThread");
        triggerCallbackThread.start();


        // retry 回调调度中心,失败的任务日志重试
        triggerRetryCallbackThread = new Thread(new Runnable() {
            @Override
            public void run() {
                while(!toStop){
                    try {
                        retryFailCallbackFile();
                    } catch (Exception e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }

                    }
                    try {
                        TimeUnit.SECONDS.sleep(RegistryConfig.BEAT_TIMEOUT);
                    } catch (InterruptedException e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }
                    }
                }
                logger.info(">>>>>>>>>>> xxl-job, executor retry callback thread destory.");
            }
        });
        triggerRetryCallbackThread.setDaemon(true);
        triggerRetryCallbackThread.start();

    }
2.5 执行内嵌服务

内嵌服务主要做了如下事情:

  1. 使用netty开放端口,等待服务端调用
  2. 注册到服务端(心跳30S)
  3. 向服务端申请剔除服务
    private void initEmbedServer(String address, String ip, int port, String appname, String accessToken) throws Exception {

        // fill ip port  若没设置端口,则寻找可用端口
        port = port>0?port: NetUtil.findAvailablePort(9999);
        //若没设置IP,则获取本机Ip
        ip = (ip!=null&&ip.trim().length()>0)?ip: IpUtil.getIp(); 

        // generate address 构造地址,若没设置地址,则将ip,port拼接成地址
        if (address==null || address.trim().length()==0) {
            String ip_port_address = IpUtil.getIpPort(ip, port);   // registry-address:default use address to registry , otherwise use ip:port if address is null
            address = "http://{ip_port}/".replace("{ip_port}", ip_port_address);
        }

        // accessToken
        if (accessToken==null || accessToken.trim().length()==0) {
            logger.warn(">>>>>>>>>>> xxl-job accessToken is empty. To ensure system security, please set the accessToken.");
        }

        // start  启动嵌入服务器 ,向服务端注册,以及监听端口,主要服务于服务端调用。
        embedServer = new EmbedServer();
        embedServer.start(address, port, appname, accessToken);
    }

启动内嵌服务

 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() {

                // param
                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-rpc, EmbedServer bizThreadPool-" + r.hashCode());
                            }
                        },
                        new RejectedExecutionHandler() {
                            @Override
                            public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
                                throw new RuntimeException("xxl-job, EmbedServer bizThreadPool is EXHAUSTED!");
                            }
                        });


                try {
                    // start server
                    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 异步绑定port上
                    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) {
                    if (e instanceof InterruptedException) {
                        logger.info(">>>>>>>>>>> xxl-job remoting server stop.");
                    } else {
                        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();
    }

向服务端注册,默认30秒执行一次

    public void startRegistry(final String appname, final String address) {
        // start registry
        ExecutorRegistryThread.getInstance().start(appname, address);
    }
    public void start(final String appname, final String address){

        // valid appname不允许为null
        if (appname==null || appname.trim().length()==0) {
            logger.warn(">>>>>>>>>>> xxl-job, executor registry config fail, appname is null.");
            return;
        }//服务端地址不能为null
        if (XxlJobExecutor.getAdminBizList() == null) {
            logger.warn(">>>>>>>>>>> xxl-job, executor registry config fail, adminAddresses is null.");
            return;
        }

        registryThread = new Thread(new Runnable() {
            @Override
            public void run() {

                // registry
                while (!toStop) {
                    try {
                        RegistryParam registryParam = new RegistryParam(RegistryConfig.RegistType.EXECUTOR.name(), appname, address);
                        for (AdminBiz adminBiz: XxlJobExecutor.getAdminBizList()) {
                            try {
                                ReturnT<String> registryResult = adminBiz.registry(registryParam); //向server注册服务(http请求),注册内容appname,当前服务监听地址
                                if (registryResult!=null && ReturnT.SUCCESS_CODE == registryResult.getCode()) { //访问成功
                                    registryResult = ReturnT.SUCCESS;
                                    logger.debug(">>>>>>>>>>> xxl-job registry success, registryParam:{}, registryResult:{}", new Object[]{registryParam, registryResult});
                                    break;
                                } else {
                                    logger.info(">>>>>>>>>>> xxl-job registry fail, registryParam:{}, registryResult:{}", new Object[]{registryParam, registryResult});
                                }
                            } catch (Exception e) {
                                logger.info(">>>>>>>>>>> xxl-job registry error, registryParam:{}", registryParam, e);
                            }

                        }
                    } catch (Exception e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }

                    }

                    try {
                        if (!toStop) { //心跳时间30秒
                            TimeUnit.SECONDS.sleep(RegistryConfig.BEAT_TIMEOUT);
                        }
                    } catch (InterruptedException e) {
                        if (!toStop) {
                            logger.warn(">>>>>>>>>>> xxl-job, executor registry thread interrupted, error msg:{}", e.getMessage());
                        }
                    }
                }

                // registry remove  删除注册
                try {
                    RegistryParam registryParam = new RegistryParam(RegistryConfig.RegistType.EXECUTOR.name(), appname, address);
                    for (AdminBiz adminBiz: XxlJobExecutor.getAdminBizList()) {
                        try {  
                            ReturnT<String> registryResult = adminBiz.registryRemove(registryParam);
                            if (registryResult!=null && ReturnT.SUCCESS_CODE == registryResult.getCode()) {
                                registryResult = ReturnT.SUCCESS;
                                logger.info(">>>>>>>>>>> xxl-job registry-remove success, registryParam:{}, registryResult:{}", new Object[]{registryParam, registryResult});
                                break;
                            } else {
                                logger.info(">>>>>>>>>>> xxl-job registry-remove fail, registryParam:{}, registryResult:{}", new Object[]{registryParam, registryResult});
                            }
                        } catch (Exception e) {
                            if (!toStop) {
                                logger.info(">>>>>>>>>>> xxl-job registry-remove error, registryParam:{}", registryParam, e);
                            }

                        }

                    }
                } catch (Exception e) {
                    if (!toStop) {
                        logger.error(e.getMessage(), e);
                    }
                }
                logger.info(">>>>>>>>>>> xxl-job, executor registry thread destory.");

            }
        });
        registryThread.setDaemon(true);
        registryThread.setName("xxl-job, executor ExecutorRegistryThread");
        registryThread.start();
    }

XXL-JOB的服务注册

每间隔30秒,客户端发送post请求访问调度中心,上报心跳结果。

image.png

客户端

com.xxl.job.core.thread.ExecutorRegistryThread#start
image.png

com.xxl.job.core.biz.client.AdminBizClient#registry

@Override
public ReturnT<String> registry(RegistryParam registryParam) {
    return XxlJobRemotingUtil.postBody(addressUrl + "api/registry", accessToken, timeout, registryParam, String.class);
}
服务端

com.xxl.job.admin.controller.JobApiController#api
image.png

AdminBizImpl#registry

    @Override
    public ReturnT<String> registry(RegistryParam registryParam) {
        return JobRegistryHelper.getInstance().registry(registryParam);
    }

JobRegistryHelper#registry

	public ReturnT<String> registry(RegistryParam registryParam) {

		// valid  校验
		if (!StringUtils.hasText(registryParam.getRegistryGroup())
				|| !StringUtils.hasText(registryParam.getRegistryKey())
				|| !StringUtils.hasText(registryParam.getRegistryValue())) {
			return new ReturnT<String>(ReturnT.FAIL_CODE, "Illegal Argument.");
		}

		// async execute 异步注册
		registryOrRemoveThreadPool.execute(new Runnable() {
			@Override
			public void run() { //更新修改时间
				int ret = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().registryUpdate(registryParam.getRegistryGroup(), registryParam.getRegistryKey(), registryParam.getRegistryValue(), new Date());
				if (ret < 1) {//说明暂未数据,才新增
					XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().registrySave(registryParam.getRegistryGroup(), registryParam.getRegistryKey(), registryParam.getRegistryValue(), new Date());

					// fresh  空实现
					freshGroupRegistryInfo(registryParam);
				}
			}
		});

		return ReturnT.SUCCESS;
	}

  1. 客户端接收响应

image.png

XXL-JOB的 执行任务

从调度中心到执行器执行一个任务的路径如下:

  1. 调度中心向客户端发起post请求
  2. client 通过内嵌服务netty接收,异步线程处理
  3. 找到job绑定的线程,将任务丢到阻塞队列中。然后返回结果给调度中心。
  4. 调度中心更改任务状态。
  5. 客户端执行任务后,将执行结果丢到回调线程的阻塞队列处理。
  6. 回调线程通过post请求访问调度中心,调度中心更改job最终结果。
  7. 倘若超过10分钟调度中心没收到回调线程的请求,则设置job最终结果失败。

调度中心下发任务到执行器时序图如下:
image.png

服务端下发任务

触发地址:com.xxl.job.admin.controller.JobInfoController#triggerJob

	@RequestMapping("/trigger")
	@ResponseBody
	//@PermissionLimit(limit = false)
	public ReturnT<String> triggerJob(int id, String executorParam, String addressList) {
		// force cover job param 设置默认值
		if (executorParam == null) {
			executorParam = "";
		}
		//触发器类型,手动 ,重试次数,'执行器任务分片参数,格式如 1/2',任务参数,机器地址
		JobTriggerPoolHelper.trigger(id, TriggerTypeEnum.MANUAL, -1, null, executorParam, addressList);
		return ReturnT.SUCCESS;
	}

手动执行任务和通过调度自动执行任务最终都会走到这。

JobTriggerPoolHelper#trigger

public static void trigger(int jobId, TriggerTypeEnum triggerType, int failRetryCount, String executorShardingParam, String executorParam, String addressList) {
    helper.addTrigger(jobId, triggerType, failRetryCount, executorShardingParam, executorParam, addressList);
}

添加一个触发任务丢到线程池执行,这里jobTimeoutCountMap会记录一分钟以内每个job执行超过500ms的次数。

当某个job一分钟以内多于10次时间超过500ms,则采用慢触发器触发。

这里每分钟会清除一次jobTimeoutCountMap。

这里通过线程隔离的手段优化执行时间。

   public void addTrigger(final int jobId,
                           final TriggerTypeEnum triggerType,
                           final int failRetryCount,
                           final String executorShardingParam,
                           final String executorParam,
                           final String addressList) {

        // choose thread pool  获取线程池
        ThreadPoolExecutor triggerPool_ = fastTriggerPool;
        //获取超时次数
        AtomicInteger jobTimeoutCount = jobTimeoutCountMap.get(jobId);
        //一分钟内超时10次,则采用慢触发器执行
        if (jobTimeoutCount!=null && jobTimeoutCount.get() > 10) {      // job-timeout 10 times in 1 min
            triggerPool_ = slowTriggerPool;
        }

        // trigger
        triggerPool_.execute(new Runnable() {
            @Override
            public void run() {

                long start = System.currentTimeMillis();

                try {
                    // do trigger //执行触发器
                    XxlJobTrigger.trigger(jobId, triggerType, failRetryCount, executorShardingParam, executorParam, addressList);
                } catch (Exception e) {
                    logger.error(e.getMessage(), e);
                } finally {

                    // check timeout-count-map  更新成为下一分钟
                    long minTim_now = System.currentTimeMillis()/60000;
                    if (minTim != minTim_now) {
                        minTim = minTim_now; //当达到下一分钟则清除超时任务
                        jobTimeoutCountMap.clear();
                    }

                    // incr timeout-count-map
                    long cost = System.currentTimeMillis()-start;
                    if (cost > 500) {       // ob-timeout threshold 500ms
                        //执行时间超过500ms,则记录执行次数
                        AtomicInteger timeoutCount = jobTimeoutCountMap.putIfAbsent(jobId, new AtomicInteger(1));
                        if (timeoutCount != null) {
                            timeoutCount.incrementAndGet();
                        }
                    }

                }

            }
        });
    }

XxlJobTrigger#trigger
    public static void trigger(int jobId,
                               TriggerTypeEnum triggerType,
                               int failRetryCount,
                               String executorShardingParam,
                               String executorParam,
                               String addressList) {

        // load data  从数据库获取任务详情信息
        XxlJobInfo jobInfo = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().loadById(jobId);
        if (jobInfo == null) {
            logger.warn(">>>>>>>>>>>> trigger fail, jobId invalid,jobId={}", jobId);
            return;
        }
        if (executorParam != null) {
            //设置任务参数
            jobInfo.setExecutorParam(executorParam);
        }
        //获取失败重试次数
        int finalFailRetryCount = failRetryCount>=0?failRetryCount:jobInfo.getExecutorFailRetryCount();
        //获取job分组信息
        XxlJobGroup group = XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().load(jobInfo.getJobGroup());

        // cover addressList  设置地址集合
        if (addressList!=null && addressList.trim().length()>0) {
            group.setAddressType(1);
            group.setAddressList(addressList.trim());
        }

        // sharding param  拆分executorParam任务参数,填入shardingParam数组
        int[] shardingParam = null;
        if (executorShardingParam!=null){
            String[] shardingArr = executorParam.split("/");
            if (shardingArr.length==2 && isNumeric(shardingArr[0]) && isNumeric(shardingArr[1])) {
                shardingParam = new int[2];
                shardingParam[0] = Integer.valueOf(shardingArr[0]);
                shardingParam[1] = Integer.valueOf(shardingArr[1]);
            }
        } //如果路由策略是分片广播模式,同时注册地址不为空
        if (ExecutorRouteStrategyEnum.SHARDING_BROADCAST==ExecutorRouteStrategyEnum.match(jobInfo.getExecutorRouteStrategy(), null)
                && group.getRegistryList()!=null && !group.getRegistryList().isEmpty()
                && shardingParam==null) {
            for (int i = 0; i < group.getRegistryList().size(); i++) {  //遍历执行每个注册地址,集群广播
                processTrigger(group, jobInfo, finalFailRetryCount, triggerType, i, group.getRegistryList().size());
            }
        } else {
            if (shardingParam == null) {  //当shardingParam为空,设置默认值,分别标识
                shardingParam = new int[]{0, 1};
            } //执行触发器
            processTrigger(group, jobInfo, finalFailRetryCount, triggerType, shardingParam[0], shardingParam[1]);
        }

    }

这里会依据路由策略模式,选择对应的路由方式处理,采用了策略模式.

  private static void processTrigger(XxlJobGroup group, XxlJobInfo jobInfo, int finalFailRetryCount, TriggerTypeEnum triggerType, int index, int total){

        // param  获取阻塞处理策略
        ExecutorBlockStrategyEnum blockStrategy = ExecutorBlockStrategyEnum.match(jobInfo.getExecutorBlockStrategy(), ExecutorBlockStrategyEnum.SERIAL_EXECUTION);  // block strategy
        // route strategy 获取路由策略,默认first
        ExecutorRouteStrategyEnum executorRouteStrategyEnum = ExecutorRouteStrategyEnum.match(jobInfo.getExecutorRouteStrategy(), null);
        String shardingParam = (ExecutorRouteStrategyEnum.SHARDING_BROADCAST==executorRouteStrategyEnum)?String.valueOf(index).concat("/").concat(String.valueOf(total)):null;

        // 1、save log-id 保存执行日志
        XxlJobLog jobLog = new XxlJobLog();
        jobLog.setJobGroup(jobInfo.getJobGroup());
        jobLog.setJobId(jobInfo.getId());
        jobLog.setTriggerTime(new Date());
        XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().save(jobLog);
        logger.debug(">>>>>>>>>>> xxl-job trigger start, jobId:{}", jobLog.getId());

        // 2、init trigger-param
        TriggerParam triggerParam = new TriggerParam();
        triggerParam.setJobId(jobInfo.getId());
        triggerParam.setExecutorHandler(jobInfo.getExecutorHandler());
        triggerParam.setExecutorParams(jobInfo.getExecutorParam());
        triggerParam.setExecutorBlockStrategy(jobInfo.getExecutorBlockStrategy());
        triggerParam.setExecutorTimeout(jobInfo.getExecutorTimeout());
        triggerParam.setLogId(jobLog.getId());
        triggerParam.setLogDateTime(jobLog.getTriggerTime().getTime());
        triggerParam.setGlueType(jobInfo.getGlueType());
        triggerParam.setGlueSource(jobInfo.getGlueSource());
        triggerParam.setGlueUpdatetime(jobInfo.getGlueUpdatetime().getTime());
        triggerParam.setBroadcastIndex(index);
        triggerParam.setBroadcastTotal(total);

        // 3、init address  获取触发器执行地址
        String address = null;
        ReturnT<String> routeAddressResult = null;
        if (group.getRegistryList()!=null && !group.getRegistryList().isEmpty()) {  //如果是集群广播模式
            if (ExecutorRouteStrategyEnum.SHARDING_BROADCAST == executorRouteStrategyEnum) {
                if (index < group.getRegistryList().size()) {  //查询匹配地址执行
                    address = group.getRegistryList().get(index);
                } else { //超过size,则默认执行第一个.
                    address = group.getRegistryList().get(0);
                }
            } else {
                //根据设置的路由策略,执行路由器,获取返回结果   ,这里用到了策略模式
                routeAddressResult = executorRouteStrategyEnum.getRouter().route(triggerParam, group.getRegistryList());
                if (routeAddressResult.getCode() == ReturnT.SUCCESS_CODE) {
                    address = routeAddressResult.getContent();
                }
            }
        } else { //获取不到注册地址,返回失败值
            routeAddressResult = new ReturnT<String>(ReturnT.FAIL_CODE, I18nUtil.getString("jobconf_trigger_address_empty"));
        }

        // 4、trigger remote executor
        ReturnT<String> triggerResult = null;
        if (address != null) { //这里真正的执行触发器
            triggerResult = runExecutor(triggerParam, address);
        } else {//获取不到执行地址直接返回
            triggerResult = new ReturnT<String>(ReturnT.FAIL_CODE, null);
        }

        // 5、collection trigger info
        StringBuffer triggerMsgSb = new StringBuffer();
        triggerMsgSb.append(I18nUtil.getString("jobconf_trigger_type")).append(":").append(triggerType.getTitle());
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobconf_trigger_admin_adress")).append(":").append(IpUtil.getIp());
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobconf_trigger_exe_regtype")).append(":")
                .append( (group.getAddressType() == 0)?I18nUtil.getString("jobgroup_field_addressType_0"):I18nUtil.getString("jobgroup_field_addressType_1") );
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobconf_trigger_exe_regaddress")).append(":").append(group.getRegistryList());
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobinfo_field_executorRouteStrategy")).append(":").append(executorRouteStrategyEnum.getTitle());
        if (shardingParam != null) {
            triggerMsgSb.append("("+shardingParam+")");
        }
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobinfo_field_executorBlockStrategy")).append(":").append(blockStrategy.getTitle());
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobinfo_field_timeout")).append(":").append(jobInfo.getExecutorTimeout());
        triggerMsgSb.append("<br>").append(I18nUtil.getString("jobinfo_field_executorFailRetryCount")).append(":").append(finalFailRetryCount);

        triggerMsgSb.append("<br><br><span style=\"color:#00c0ef;\" > >>>>>>>>>>>"+ I18nUtil.getString("jobconf_trigger_run") +"<<<<<<<<<<< </span><br>")
                .append((routeAddressResult!=null&&routeAddressResult.getMsg()!=null)?routeAddressResult.getMsg()+"<br><br>":"").append(triggerResult.getMsg()!=null?triggerResult.getMsg():"");

        // 6、save log trigger-info  更改执行日志状态
        jobLog.setExecutorAddress(address);
        jobLog.setExecutorHandler(jobInfo.getExecutorHandler());
        jobLog.setExecutorParam(jobInfo.getExecutorParam());
        jobLog.setExecutorShardingParam(shardingParam);
        jobLog.setExecutorFailRetryCount(finalFailRetryCount);
        //jobLog.setTriggerTime();
        jobLog.setTriggerCode(triggerResult.getCode());//设置执行触发器返回值
        jobLog.setTriggerMsg(triggerMsgSb.toString());//设置返回结果信息
        XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateTriggerInfo(jobLog);

        logger.debug(">>>>>>>>>>> xxl-job trigger end, jobId:{}", jobLog.getId());
    }

路由策略
  • FIRST(第一个):固定选择第一个机器;
  • LAST(最后一个):固定选择最后一个机器;
  • ROUND(轮询):;
  • RANDOM(随机):随机选择在线的机器;
  • CONSISTENT_HASH(一致性HASH):每个任务按照Hash算法固定选择某一台机器,且所有任务均匀散列在不同机器上。
  • LEAST_FREQUENTLY_USED(最不经常使用):使用频率最低的机器优先被选举;
  • LEAST_RECENTLY_USED(最近最久未使用):最久未使用的机器优先被选举;
  • FAILOVER(故障转移):按照顺序依次进行心跳检测,第一个心跳检测成功的机器选定为目标执行器并发起调度;
  • BUSYOVER(忙碌转移):按照顺序依次进行空闲检测,第一个空闲检测成功的机器选定为目标执行器并发起调度;
  • SHARDING_BROADCAST(分片广播):广播触发对应集群中所有机器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务;

这里默认固定选择第一个机器 ExecutorRouteFirst#route

    @Override
    public ReturnT<String> route(TriggerParam triggerParam, List<String> addressList){
        return new ReturnT<String>(addressList.get(0));
    }

XxlJobTrigger#runExecutor执行触发

    public static ReturnT<String> runExecutor(TriggerParam triggerParam, String address){
        ReturnT<String> runResult = null;
        try {
            ExecutorBiz executorBiz = XxlJobScheduler.getExecutorBiz(address);//获取业务执行器地址,就执行器地址后面拼接token
          //通过post请求客户端执行job
            runResult = executorBiz.run(triggerParam);
        } catch (Exception e) {
            logger.error(">>>>>>>>>>> xxl-job trigger error, please check if the executor[{}] is running.", address, e);
            runResult = new ReturnT<String>(ReturnT.FAIL_CODE, ThrowableUtil.toString(e));
        }
        //返回结果设置msg
        StringBuffer runResultSB = new StringBuffer(I18nUtil.getString("jobconf_trigger_run") + ":");
        runResultSB.append("<br>address:").append(address);
        runResultSB.append("<br>code:").append(runResult.getCode());
        runResultSB.append("<br>msg:").append(runResult.getMsg());

        runResult.setMsg(runResultSB.toString());
        return runResult;
    }

获取客户端地址,简单拼接http+token地址。com.xxl.job.admin.core.scheduler.XxlJobScheduler#getExecutorBiz

public static ExecutorBiz getExecutorBiz(String address) throws Exception {
    // valid
    if (address==null || address.trim().length()==0) {
        return null;
    }

    // load-cache
    //从缓冲中通过地址获取ExecutorBiz
    address = address.trim();
    ExecutorBiz executorBiz = executorBizRepository.get(address);
    if (executorBiz != null) {
        return executorBiz;
    }

    // set-cache 找不到就新建
    executorBiz = new ExecutorBizClient(address, XxlJobAdminConfig.getAdminConfig().getAccessToken());

    //添加缓存
    executorBizRepository.put(address, executorBiz);
    return executorBiz;
}

通过post请求客户端执行job,ExecutorBizClient#run

  @Override
    public ReturnT<String> run(TriggerParam triggerParam) {
        return XxlJobRemotingUtil.postBody(addressUrl + "run", accessToken, timeout, triggerParam, String.class);
    }

客户端处理任务

执行器执行任务时序图:

image.png

客户端的入站处理器:

EmbedServer#EmbedHttpServerHandler#channelRead0

   @Override
        protected void channelRead0(final ChannelHandlerContext ctx, FullHttpRequest msg) throws Exception {

            // request parse
            //final byte[] requestBytes = ByteBufUtil.getBytes(msg.content());    // byteBuf.toString(io.netty.util.CharsetUtil.UTF_8);
            String requestData = msg.content().toString(CharsetUtil.UTF_8);//解析请求数据
            String uri = msg.uri();//获取uri,后面通过uri来处理不同的请求
            HttpMethod httpMethod = msg.method();//获取请求方式,Post/Get
            boolean keepAlive = HttpUtil.isKeepAlive(msg);//保持长连接
            String accessTokenReq = msg.headers().get(XxlJobRemotingUtil.XXL_JOB_ACCESS_TOKEN);

            // invoke
            bizThreadPool.execute(new Runnable() {
                @Override
                public void run() {
                    // do invoke
                    Object responseObj = process(httpMethod, uri, requestData, accessTokenReq);

                    // to json  响应结果转json
                    String responseJson = GsonTool.toJson(responseObj);

                    // write response
                    writeResponse(ctx, keepAlive, responseJson);
                }
            });
        }

执行触发器

   private Object process(HttpMethod httpMethod, String uri, String requestData, String accessTokenReq) {

            // valid 不是POST直接返回异常
            if (HttpMethod.POST != httpMethod) {
                return new ReturnT<String>(ReturnT.FAIL_CODE, "invalid request, HttpMethod not support.");
            }
            if (uri==null || uri.trim().length()==0) { //校验uri
                return new ReturnT<String>(ReturnT.FAIL_CODE, "invalid request, uri-mapping empty.");
            }//校验token是否正确
            if (accessToken!=null
                    && accessToken.trim().length()>0
                    && !accessToken.equals(accessTokenReq)) {
                return new ReturnT<String>(ReturnT.FAIL_CODE, "The access token is wrong.");
            }

            // services mapping
            try {
                if ("/beat".equals(uri)) {
                    return executorBiz.beat();
                } else if ("/idleBeat".equals(uri)) {
                    IdleBeatParam idleBeatParam = GsonTool.fromJson(requestData, IdleBeatParam.class);
                    return executorBiz.idleBeat(idleBeatParam);
                } else if ("/run".equals(uri)) { //执行触发器
                    TriggerParam triggerParam = GsonTool.fromJson(requestData, TriggerParam.class);//请求参数解析成TriggerParam
                    return executorBiz.run(triggerParam);
                } else if ("/kill".equals(uri)) {
                    KillParam killParam = GsonTool.fromJson(requestData, KillParam.class);
                    return executorBiz.kill(killParam);
                } else if ("/log".equals(uri)) {
                    LogParam logParam = GsonTool.fromJson(requestData, LogParam.class);
                    return executorBiz.log(logParam);
                } else {
                    return new ReturnT<String>(ReturnT.FAIL_CODE, "invalid request, uri-mapping("+ uri +") not found.");
                }
            } catch (Exception e) {
                logger.error(e.getMessage(), e);
                return new ReturnT<String>(ReturnT.FAIL_CODE, "request error:" + ThrowableUtil.toString(e));
            }
        }


接下来两个工作
  1. 绑定作业到具体线程JobThread,启动线程
  2. 任务丢入线程处理JobThread#triggerQueue
 @Override
    public ReturnT<String> run(TriggerParam triggerParam) {
        // load old:jobHandler + jobThread
        //获取job绑定线程
        JobThread jobThread = XxlJobExecutor.loadJobThread(triggerParam.getJobId());
        //获取作业处理器
        IJobHandler jobHandler = jobThread!=null?jobThread.getHandler():null;
        String removeOldReason = null;

        // valid:jobHandler + jobThread
        //获取任务运行模式
        GlueTypeEnum glueTypeEnum = GlueTypeEnum.match(triggerParam.getGlueType());
        //任务以JobHandler方式维护在执行器端;需要结合 "JobHandler" 属性匹配执行器中任务;
        if (GlueTypeEnum.BEAN == glueTypeEnum) {
            // new jobhandler  获取job处理器,也就是方法声明
            IJobHandler newJobHandler = XxlJobExecutor.loadJobHandler(triggerParam.getExecutorHandler());

            // valid old jobThread 如果job处理器不一样,则kill旧处理器绑定的线程
            if (jobThread!=null && jobHandler != newJobHandler) {
                // change handler, need kill old thread
                removeOldReason = "change jobhandler or glue type, and terminate the old job thread.";

                jobThread = null;
                jobHandler = null;
            }

            // valid handler
            //执行到这儿,要么新旧处理器不一致,要么没有绑定过任何线程
            if (jobHandler == null) {
                jobHandler = newJobHandler;
                //没找到处理器,直接返回异常
                if (jobHandler == null) {
                    return new ReturnT<String>(ReturnT.FAIL_CODE, "job handler [" + triggerParam.getExecutorHandler() + "] not found.");
                }
            }

        } else if (GlueTypeEnum.GLUE_GROOVY == glueTypeEnum) {

            // valid old jobThread
            if (jobThread != null &&
                    !(jobThread.getHandler() instanceof GlueJobHandler
                        && ((GlueJobHandler) jobThread.getHandler()).getGlueUpdatetime()==triggerParam.getGlueUpdatetime() )) {
                // change handler or gluesource updated, need kill old thread
                removeOldReason = "change job source or glue type, and terminate the old job thread.";

                jobThread = null;
                jobHandler = null;
            }

            // valid handler
            if (jobHandler == null) {
                try {
                    IJobHandler originJobHandler = GlueFactory.getInstance().loadNewInstance(triggerParam.getGlueSource());
                    jobHandler = new GlueJobHandler(originJobHandler, triggerParam.getGlueUpdatetime());
                } catch (Exception e) {
                    logger.error(e.getMessage(), e);
                    return new ReturnT<String>(ReturnT.FAIL_CODE, e.getMessage());
                }
            }
        } else if (glueTypeEnum!=null && glueTypeEnum.isScript()) {

            // valid old jobThread
            if (jobThread != null &&
                    !(jobThread.getHandler() instanceof ScriptJobHandler
                            && ((ScriptJobHandler) jobThread.getHandler()).getGlueUpdatetime()==triggerParam.getGlueUpdatetime() )) {
                // change script or gluesource updated, need kill old thread
                removeOldReason = "change job source or glue type, and terminate the old job thread.";

                jobThread = null;
                jobHandler = null;
            }

            // valid handler
            if (jobHandler == null) {
                jobHandler = new ScriptJobHandler(triggerParam.getJobId(), triggerParam.getGlueUpdatetime(), triggerParam.getGlueSource(), GlueTypeEnum.match(triggerParam.getGlueType()));
            }
        } else {
            return new ReturnT<String>(ReturnT.FAIL_CODE, "glueType[" + triggerParam.getGlueType() + "] is not valid.");
        }

        // executor block strategy
        //获取任务阻塞策略
        if (jobThread != null) {
            ExecutorBlockStrategyEnum blockStrategy = ExecutorBlockStrategyEnum.match(triggerParam.getExecutorBlockStrategy(), null);
            if (ExecutorBlockStrategyEnum.DISCARD_LATER == blockStrategy) {
                // discard when running
                if (jobThread.isRunningOrHasQueue()) {
                    return new ReturnT<String>(ReturnT.FAIL_CODE, "block strategy effect:"+ExecutorBlockStrategyEnum.DISCARD_LATER.getTitle());
                }
            } else if (ExecutorBlockStrategyEnum.COVER_EARLY == blockStrategy) {
                // kill running jobThread
                if (jobThread.isRunningOrHasQueue()) {
                    removeOldReason = "block strategy effect:" + ExecutorBlockStrategyEnum.COVER_EARLY.getTitle();

                    jobThread = null;
                }
            } else {
                // just queue trigger
            }
        }

        // replace thread (new or exists invalid)
        //作业没绑定过线程,则绑定作业到具体线程
        if (jobThread == null) {
            jobThread = XxlJobExecutor.registJobThread(triggerParam.getJobId(), jobHandler, removeOldReason);
        }

        // push data to queue  任务丢入线程处理
        ReturnT<String> pushResult = jobThread.pushTriggerQueue(triggerParam);
        return pushResult;
    }

绑定作业到具体线程,启动线程

    public static JobThread registJobThread(int jobId, IJobHandler handler, String removeOldReason){
        JobThread newJobThread = new JobThread(jobId, handler);//启动新线程处理工作任务
        newJobThread.start();
        logger.info(">>>>>>>>>>> xxl-job regist JobThread success, jobId:{}, handler:{}", new Object[]{jobId, handler});
        //存储jobId与绑定工作的线程
        JobThread oldJobThread = jobThreadRepository.put(jobId, newJobThread);	// putIfAbsent | oh my god, map's put method return the old value!!!
        if (oldJobThread != null) {//中断并删除旧线程
            oldJobThread.toStop(removeOldReason);
            oldJobThread.interrupt();
        }

        return newJobThread;
    }

任务丢入线程处理

	public ReturnT<String> pushTriggerQueue(TriggerParam triggerParam) {
		// avoid repeat  若包含,则说明重复执行
		if (triggerLogIdSet.contains(triggerParam.getLogId())) {
			logger.info(">>>>>>>>>>> repeate trigger job, logId:{}", triggerParam.getLogId());
			return new ReturnT<String>(ReturnT.FAIL_CODE, "repeate trigger job, logId:" + triggerParam.getLogId());
		}

		triggerLogIdSet.add(triggerParam.getLogId());
		triggerQueue.add(triggerParam);
        return ReturnT.SUCCESS;
	}

JobThread执行触发器逻辑MethodJobHandler#execute方法

    public void execute() throws Exception {
        //利用反射调用方法
        Class<?>[] paramTypes = method.getParameterTypes();
        if (paramTypes.length > 0) {
            method.invoke(target, new Object[paramTypes.length]);       // method-param can not be primitive-types
        } else {
            method.invoke(target);
        }
    }

bean模式handler执行时利用反射调用方法。com.xxl.job.core.handler.impl.MethodJobHandler

@Override
public void execute() throws Exception {
    Class<?>[] paramTypes = method.getParameterTypes();
    if (paramTypes.length > 0) {
        method.invoke(target, new Object[paramTypes.length]);       // method-param can not be primitive-types
    } else {
        method.invoke(target);
    }
}

客户端回调服务端

当执行器拿到调度任务后,放到队列,任务去异步处理,结果转成json,设置常长连接,刷入channel,直接返回
image.png
com.xxl.job.core.biz.impl.ExecutorBizImpl#run

	public ReturnT<String> pushTriggerQueue(TriggerParam triggerParam) {
		// avoid repeat  若包含,则说明重复执行
		if (triggerLogIdSet.contains(triggerParam.getLogId())) {
			logger.info(">>>>>>>>>>> repeate trigger job, logId:{}", triggerParam.getLogId());
			return new ReturnT<String>(ReturnT.FAIL_CODE, "repeate trigger job, logId:" + triggerParam.getLogId());
		}

		triggerLogIdSet.add(triggerParam.getLogId());
		triggerQueue.add(triggerParam);
        return ReturnT.SUCCESS;
	}

当客户端执行完成后,会将处理回调参数任务加入触发器回调处理线程
com.xxl.job.core.thread.JobThread#run

image.png

com.xxl.job.core.thread.TriggerCallbackThread#start
image.png

调用服务端回调com.xxl.job.core.thread.TriggerCallbackThread#doCallback

    private void doCallback(List<HandleCallbackParam> callbackParamList){
        boolean callbackRet = false;
        // callback, will retry if error 获取admin地址
        for (AdminBiz adminBiz: XxlJobExecutor.getAdminBizList()) {
            try { //回调admin,返回执行结果
                ReturnT<String> callbackResult = adminBiz.callback(callbackParamList);
                if (callbackResult!=null && ReturnT.SUCCESS_CODE == callbackResult.getCode()) {
                    callbackLog(callbackParamList, "<br>----------- xxl-job job callback finish.");
                    callbackRet = true;
                    break;
                } else {
                    callbackLog(callbackParamList, "<br>----------- xxl-job job callback fail, callbackResult:" + callbackResult);
                }
            } catch (Exception e) {
                callbackLog(callbackParamList, "<br>----------- xxl-job job callback error, errorMsg:" + e.getMessage());
            }
        }
        if (!callbackRet) {
            appendFailCallbackFile(callbackParamList);
        }
    }

com.xxl.job.core.biz.client.AdminBizClient#callback

    public ReturnT<String> callback(List<HandleCallbackParam> callbackParamList) {
        return XxlJobRemotingUtil.postBody(addressUrl+"api/callback", accessToken, timeout, callbackParamList, String.class);
    }

调度中心处理回调

JobApiController#api
image.png

JobCompleteHelper#callback

	public ReturnT<String> callback(List<HandleCallbackParam> callbackParamList) {

		callbackThreadPool.execute(new Runnable() {
			@Override
			public void run() {
				for (HandleCallbackParam handleCallbackParam: callbackParamList) {
					ReturnT<String> callbackResult = callback(handleCallbackParam);
					logger.debug(">>>>>>>>> JobApiController.callback {}, handleCallbackParam={}, callbackResult={}",
							(callbackResult.getCode()== ReturnT.SUCCESS_CODE?"success":"fail"), handleCallbackParam, callbackResult);
				}
			}
		});

		return ReturnT.SUCCESS;
	}

	private ReturnT<String> callback(HandleCallbackParam handleCallbackParam) {
		// valid log item  加载log
		XxlJobLog log = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().load(handleCallbackParam.getLogId());
		if (log == null) {  //日志没找到返回异常
			return new ReturnT<String>(ReturnT.FAIL_CODE, "log item not found.");
		}
		if (log.getHandleCode() > 0) { //说明重复执行
			return new ReturnT<String>(ReturnT.FAIL_CODE, "log repeate callback.");     // avoid repeat callback, trigger child job etc
		}

		// handle msg
		StringBuffer handleMsg = new StringBuffer();
		if (log.getHandleMsg()!=null) {
			handleMsg.append(log.getHandleMsg()).append("<br>");
		}
		if (handleCallbackParam.getHandleMsg() != null) {
			handleMsg.append(handleCallbackParam.getHandleMsg());
		}

		// success, save log  更改
		log.setHandleTime(new Date());
		log.setHandleCode(handleCallbackParam.getHandleCode()); //更改处理状态,200正常,500错误
		log.setHandleMsg(handleMsg.toString());
		XxlJobCompleter.updateHandleInfoAndFinish(log);

		return ReturnT.SUCCESS;
	}

执行子任务

在服务器接收到任务执行结束的回调后,执行子任务

image.png

XxlJobCompleter#updateHandleInfoAndFinish

    public static int updateHandleInfoAndFinish(XxlJobLog xxlJobLog) {

        // finish 若父任务正常结束,则终止子任务,以及设置Childmsg
        finishJob(xxlJobLog);

        // text最大64kb 避免长度过长 截断超过长度限制字符
        if (xxlJobLog.getHandleMsg().length() > 15000) {
            xxlJobLog.setHandleMsg( xxlJobLog.getHandleMsg().substring(0, 15000) );
        }

        // fresh handle 更新joblog
        return XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateHandleInfo(xxlJobLog);
    }

XxlJobCompleter#finishJob

private static void finishJob(XxlJobLog xxlJobLog){

        // 1、handle success, to trigger child job
        String triggerChildMsg = null;
        if (XxlJobContext.HANDLE_COCE_SUCCESS == xxlJobLog.getHandleCode()) {
            XxlJobInfo xxlJobInfo = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().loadById(xxlJobLog.getJobId());
            if (xxlJobInfo!=null && xxlJobInfo.getChildJobId()!=null && xxlJobInfo.getChildJobId().trim().length()>0) {
                triggerChildMsg = "<br><br><span style=\"color:#00c0ef;\" > >>>>>>>>>>>"+ I18nUtil.getString("jobconf_trigger_child_run") +"<<<<<<<<<<< </span><br>";
                //获取子任务id
                String[] childJobIds = xxlJobInfo.getChildJobId().split(",");
                for (int i = 0; i < childJobIds.length; i++) {
                    int childJobId = (childJobIds[i]!=null && childJobIds[i].trim().length()>0 && isNumeric(childJobIds[i]))?Integer.valueOf(childJobIds[i]):-1;
                    if (childJobId > 0) {

                        //触发子任务
                        JobTriggerPoolHelper.trigger(childJobId, TriggerTypeEnum.PARENT, -1, null, null, null);
                        ReturnT<String> triggerChildResult = ReturnT.SUCCESS;

                        // add msg
                        triggerChildMsg += MessageFormat.format(I18nUtil.getString("jobconf_callback_child_msg1"),
                                (i+1),
                                childJobIds.length,
                                childJobIds[i],
                                (triggerChildResult.getCode()==ReturnT.SUCCESS_CODE?I18nUtil.getString("system_success"):I18nUtil.getString("system_fail")),
                                triggerChildResult.getMsg());
                    } else {
                        triggerChildMsg += MessageFormat.format(I18nUtil.getString("jobconf_callback_child_msg2"),
                                (i+1),
                                childJobIds.length,
                                childJobIds[i]);
                    }
                }

            }
        }

        if (triggerChildMsg != null) {
            xxlJobLog.setHandleMsg( xxlJobLog.getHandleMsg() + triggerChildMsg );
        }

        // 2、fix_delay trigger next
        // on the way

    }

XXL-JOB的mysql分布式锁

当服务器存在集群部署的时候,存在多个线程争抢查询数据库,会造成下发任务重复执行。

为了防止这种情况,设置手动提交(这里spring会默认自动提交),查询添加写锁。

在此期间其他线程访问的时候都会阻塞等待。

这是mysql实现分布式锁的方式

这里可能考虑易用性,xxljob采用的是mysql分布式锁,如果考虑性能可以考虑使用redis分布式锁。

JobScheduleHelper#start

image.png

表数据结构

当调度中心是集群的情况下,是怎么保证调度不会重复的呢?

在xxl-job 的官方文档中是这样说的

基于数据库的集群方案,数据库选用Mysql;集群分布式并发环境中进行定时任务调度时,会在各个节点会上报任务,存到数据库中,执行时会从数据库中取出触发器来执行,如果触发器的名称和执行时间相同,则只有一个节点去执行此任务。

其中表结构是这样的

在这里插入图片描述

表结构如下

CREATE TABLE `xxl_job_lock` (
  `lock_name` varchar(50) NOT NULL COMMENT '锁名称',
  PRIMARY KEY (`lock_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这个表里只有一条记录

在这里插入图片描述

然后到xxl的源码中(因为整个类代码太长了,所以直截取了一小段)

public class JobScheduleHelper {
    private static Logger logger = LoggerFactory.getLogger(JobScheduleHelper.class);

    private static JobScheduleHelper instance = new JobScheduleHelper();
    public static JobScheduleHelper getInstance(){
        return instance;
    }

    public static final long PRE_READ_MS = 5000;    // pre read

    private Thread scheduleThread;
    private Thread ringThread;
    private volatile boolean scheduleThreadToStop = false;
    private volatile boolean ringThreadToStop = false;
    private volatile static Map<Integer, List<Integer>> ringData = new ConcurrentHashMap<>();

    public void start(){

        // schedule thread
        scheduleThread = new Thread(new Runnable() {
            @Override
            public void run() {

                try {
                    TimeUnit.MILLISECONDS.sleep(5000 - System.currentTimeMillis()%1000 );
                } catch (InterruptedException e) {
                    if (!scheduleThreadToStop) {
                        logger.error(e.getMessage(), e);
                    }
                }
                logger.info(">>>>>>>>> init xxl-job admin scheduler success.");

                // pre-read count: treadpool-size * trigger-qps (each trigger cost 50ms, qps = 1000/50 = 20)
                int preReadCount = (XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax() + XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax()) * 20;

                while (!scheduleThreadToStop) {

                    // Scan Job
                    long start = System.currentTimeMillis();

                    Connection conn = null;
                    Boolean connAutoCommit = null;
                    PreparedStatement preparedStatement = null;


                    boolean preReadSuc = true;
                    try {

                        conn = XxlJobAdminConfig.getAdminConfig().getDataSource().getConnection();
                        connAutoCommit = conn.getAutoCommit();
                        conn.setAutoCommit(false);

                        preparedStatement = conn.prepareStatement(  "select * from xxl_job_lock where lock_name = 'schedule_lock' for update" );
                        preparedStatement.execute();

调度开始之前,这个调度锁表就用到了.

这里用到了数据库的悲观锁,for update,防止了集群同时调度的情况。

百亿级海量任务调度平台的架构与实现

对xxl-job进行大的架构改造,完成百亿级海量任务调度平台的架构与实现

具体请关注技术自由圈的后续文章…

说在最后:有问题找老架构取经

百亿级海量任务调度平台,一定是一个超级牛掰的简历亮点项目,黄金项目,稍微晚点把全量的架构方案和视频进行发布。

这个项目写入简历,面试的时候如果大家能对答如流,如数家珍,基本上 面试官会被你 震惊到、吸引到。

最终,让面试官爱到 “不能自已、口水直流”。offer, 也就来了。

在面试之前,建议大家系统化的刷一波 5000页《尼恩Java面试宝典》V174,在刷题过程中,如果有啥问题,大家可以来 找 40岁老架构师尼恩交流。

另外,如果没有面试机会,可以找尼恩来帮扶、领路。

  • 大龄男的最佳出路是 架构+ 管理
  • 大龄女的最佳出路是 DPM,

在这里插入图片描述

女程序员如何成为DPM,请参见:

DPM (双栖)陪跑,助力小白一步登天,升格 产品经理+研发经理

领跑模式,尼恩已经指导了大量的就业困难的小伙伴上岸。

前段时间,领跑一个40岁+就业困难小伙伴拿到了一个年薪100W的offer,小伙伴实现了 逆天改命

另外,尼恩也给一线企业提供 《DDD 的架构落地》企业内部培训,目前给不少企业做过内部的咨询和培训,效果非常好。

在这里插入图片描述

尼恩技术圣经系列PDF

  • 《NIO圣经:一次穿透NIO、Selector、Epoll底层原理》
  • 《Docker圣经:大白话说Docker底层原理,6W字实现Docker自由》
  • 《K8S学习圣经:大白话说K8S底层原理,14W字实现K8S自由》
  • 《SpringCloud Alibaba 学习圣经,10万字实现SpringCloud 自由》
  • 《大数据HBase学习圣经:一本书实现HBase学习自由》
  • 《大数据Flink学习圣经:一本书实现大数据Flink自由》
  • 《响应式圣经:10W字,实现Spring响应式编程自由》
  • 《Go学习圣经:Go语言实现高并发CRUD业务开发》

……完整版尼恩技术圣经PDF集群,请找尼恩领取

《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》PDF,请到下面公号【技术自由圈】取↓↓↓

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1677838.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

深度学习面试问题 | 降维

本文给大家带来的百面算法工程师是深度学习降维面试总结&#xff0c;文章内总结了常见的提问问题&#xff0c;旨在为广大学子模拟出更贴合实际的面试问答场景。在这篇文章中&#xff0c;我们还将介绍一些常见的深度学习面试问题&#xff0c;并提供参考的回答及其理论基础&#…

免费/低价服务资源的使用经验谈

互联网之所以吸引人的其中一个原因就是“免费”&#xff0c;不过免费却不好用的话&#xff0c;我想也不入多数人的法眼。如果可以给予少量的费用却有不错的服务资源&#xff0c;那么也是最好不过的事情。现在就让我们看看互联网有哪些免费或者低价的开发服务器资源。 首先列出…

C++自定义脚本文件执行

FunctionCall.h&#xff1a; #include <sstream> #include <string> #include <vector> // 函数调用 class FunctionCall { public: FunctionCall(); ~FunctionCall(); std::string call(const st…

MySQL数据库从入门到精通(下)

对表做了修改之后&#xff0c;记得点击对应图标按钮重新执行一下。 1.创建角色表 数据库一开始就要设计好&#xff0c;轻易不要改动。一个账号下可能有多个角色&#xff0c;所以我们单独再创建另一个表role用来存储所有的角色信息。其中idrole表示角色id&#xff0c;name表示名…

【Linux】进程间通信(一)---- 匿名管道

【Linux】进程间通信&#xff08;一&#xff09;---- 匿名管道 一.序1什么是进程间通信2.进程间通信的标准3.为什么需要进程通信 二.匿名管道1.原理2.使用3.四种情况4.五个特点 一.序 1什么是进程间通信 进程间通信 通信我们大致知道是啥&#xff0c;就是互相传递信息 那进程…

探索智慧生活:百度Comate引领人工智能助手新潮流

文章目录 百度Comate介绍1. 什么是百度Comate&#xff1f;主要特点 2. Comate的核心功能智能问答功能语音识别功能语音助手功能个性化服务 3. Comate 支持哪些语言&#xff1f; 使用教程(以vscode为例)1. 下载和安装Comate3. 常用操作快捷键(windows) 使用体验自然语言生成代码…

【全开源】国际版JAVA多商户运营版商城系统源码地摊兄源码多商户源码社交电商源码支持Android+IOS+H5

国际版多商户运营版商城系统&#xff1a;打造全球电商新生态 随着全球化趋势的深入发展&#xff0c;跨境电商已成为推动世界经济增长的重要力量。为了满足不同国家、地区商户的多样化需求&#xff0c;我们隆重推出“国际版多商户运营版商城系统”&#xff0c;旨在为全球商户搭…

天锐绿盾 | 如何防止电脑内文件遭到泄露?

天锐绿盾是一款专为企业设计的数据防泄漏软件系统&#xff0c;它通过一系列综合性的安全措施来有效防止电脑内文件遭到泄露。 PC地址&#xff1a; https://isite.baidu.com/site/wjz012xr/2eae091d-1b97-4276-90bc-6757c5dfedee 以下是天锐绿盾防止文件泄露的主要功能和方法&a…

性价比王者HUSB237,极简PD Sink的“瘦身秘籍”

在小型化、高集成的要求下&#xff0c;慧能泰取电芯片进行技术升级后“瘦身成功”&#xff0c;推出最新一代极具性价比的最简PD Sink取电芯片——HUSB237。 图1&#xff1a;HUSB237 demo及封装图 HUSB237 是一款极具性价比的最简PD Sink取电芯片&#xff0c;支持PD3.1协议包含…

IT行业的现状、未来发展趋势及无限可能

不可能的可能 一、引言二、IT行业的现状三、IT行业的未来发展趋势四、结语 一、引言 在全球化浪潮的推动下&#xff0c;IT行业正以前所未有的速度发展&#xff0c;成为推动全球经济和社会进步的重要引擎。云计算、大数据、人工智能、物联网、5G通信和区块链等技术的不断涌现&am…

【软考】设计模式之组合模式

目录 1. 说明2. 应用场景3. 结构图4. 构成5. 优点6. 缺点7. java示例 1. 说明 1.将对象组合成树型结构以表示“部分-整体”的层次结构。2.Composite使得用户对单个对象和组合对象的使用具有一致性。3.组合模式&#xff08;Composite Pattern&#xff09;是一种结构型设计模式 …

玩转大模型 企业AI着陆新正解 神州问学AI原生赋能平台正式发布

在人工智能技术日新月异的今天&#xff0c;神州数码凭借深厚的行业洞察和技术积累&#xff0c;揭开了AI原生赋能平台——神州问学的神秘面纱。作为企业AI着陆的加速引擎&#xff0c;神州问学致力于通过AI原生场景赋能&#xff0c;为企业开辟一条通往智能未来的坦途。 神州问学—…

【全开源】JAVA城市向导同城达人系统源码支持微信小程序+微信公众号+H5+APP

城市向导同城达人系统&#xff1a;探索城市的新视角 随着城市化进程的加快&#xff0c;人们对于城市的了解和探索需求日益增长。为了满足这一需求&#xff0c;我们精心打造了城市向导同城达人系统&#xff0c;旨在为广大市民和游客提供一个全面、便捷、有趣的城市导览平台。 …

【git】通过JetBrains IDE对git的操作

应该适用于所有jetbrains产品。 一、拉取(pull)代码 上方工具栏-Git-克隆。然后填写git地址与本地存放地址。 二、搁置 修改代码后搁置代码&#xff08;不提交&#xff0c;但是也不撤销已修改的代码&#xff0c;把它暂存起来&#xff09;。 界面的左上角。1->2->3。…

【MIT 6.5840(6.824)学习笔记】分布式系统介绍

1 概念 当我们谈论分布式系统时&#xff0c;我们指的是一组通过网络连接的计算机&#xff0c;它们协同工作以完成某种共同的任务或目标。 在分布式系统中&#xff0c;通信是通过消息传递进行的。这意味着各个计算节点之间通过发送和接收消息来进行通信&#xff0c;而不是通过…

系统思考—团队学习

结束昨日435期JSTO“探索学习的新视界&#xff1a;硬核工具分享”&#xff0c;有伙伴分享的提升效率的AI工具&#xff0c;也有自我发现团队问题解决的工具&#xff0c;伙伴们都在各自的领域实践、吸收、反馈、复盘。这次的团队学习不仅是知识的传递&#xff0c;更是一场脑力激荡…

Linux修炼之路之gcc/g++,动静态链接及动静态库

目录 一&#xff1a;Linux编译器-gcc/g 预处理-编译-汇编-链接 1.预处理 2.编译 (生成汇编) 3.汇编(生成机器可识别代码) 4.链接(生成可执行文件或库文件) 三:动静态链接和动静态库 动静态库 动静态链接 1.动态链接 2.静态链接 3.注意点 4.各自优缺点 5.ldd和fil…

20240514基于深度学习的弹性超材料色散关系预测与结构逆设计

论文&#xff1a;Dispersion relation prediction and structure inverse design of elastic metamaterials via deep learning DOI&#xff1a;https://doi.org/10.1016/j.mtphys.2022.100616 1、摘要 精心设计的超材料结构给予前所未有的性能&#xff0c;保证了各种各样的具…

安防视频汇聚/智能分析云平台EasyCVR调用localfile接口会返回日志的问题该如何解决?

视频汇聚/安防视频融合云平台EasyCVR视频监控系统支持多协议接入、兼容多类型设备&#xff0c;平台能在复杂的网络环境中&#xff08;专网、局域网、广域网、VPN、公网等&#xff09;将前端海量的设备进行统一集中接入与视频汇聚管理。视频监控/集中存储系统EasyCVR平台可支持国…

QCustomPlot - 柱状堆积图

参考链接 显示柱状图的值 QCustomPlot下载 下载地址&#xff1a;https://www.qcustomplot.com/index.php/download选择版本2.1.0 QCustomPlot.tar.gzQCustomPlot 的使用 解压下载的文件 把qcustomplot.h和qcustomplot.cpp放到自己的项目工程&#xff08;复制文件并qt 的目录…