XXL-JOB
XXL-JOB 是 Java 商业项目里常见的分布式任务调度平台。它适合微服务场景下统一管理定时任务,例如订单超时关闭、T+1 对账、报表生成、ES 同步补偿、数据清理、会员等级刷新、库存同步、营销活动状态变更。
本文以官方正式版 3.4.0 为示例。Admin、初始化 SQL 和业务应用中的 xxl-job-core 应保持相同版本;升级时先阅读对应 Release Note,并使用目标版本源码中的数据库脚本核对表结构。不要把旧版博客中的 ReturnT Handler 写法和新版 XxlJobHelper 写法混用。
它解决的核心问题是:
任务什么时候触发、由哪台机器执行、执行结果在哪里看、失败怎么重试、任务多了怎么分片、执行器挂了怎么处理。
为什么需要 XXL-JOB
@Scheduled 写起来很简单,但商业系统很快会遇到这些问题:
| 问题 | @Scheduled 的困难 | XXL-JOB 的能力 |
|---|---|---|
| 多实例重复执行 | 每个实例都会跑 | 调度中心统一触发 |
| 任务配置变更 | 改代码、重启服务 | 控制台修改 cron 和参数 |
| 执行日志 | 只能查应用日志 | 调度中心查看每次执行记录 |
| 失败重试 | 要自己写 | 支持失败重试 |
| 告警 | 要自己接入 | 支持负责人和告警 |
| 大数据量 | 单机慢 | 分片广播并行处理 |
| 执行器故障 | 不容易转移 | 路由策略选择可用执行器 |
XXL-JOB 的定位不是替代业务代码,而是负责“调度治理”。真正的业务逻辑仍然写在你的 Spring Boot 服务里。
架构组成
flowchart TD
A["XXL-JOB Admin<br/>调度中心"] --> B["调度数据库"]
A --> C["执行器注册表"]
A --> D["任务配置和调度线程"]
D --> E["通过 HTTP 触发执行器"]
E --> F["Executor A<br/>订单服务"]
E --> G["Executor B<br/>订单服务"]
E --> H["Executor C<br/>订单服务"]
F --> I["执行业务方法"]
G --> I
H --> I
I --> J["回调执行结果和日志"]
J --> A| 组件 | 作用 |
|---|---|
| Admin 调度中心 | 提供控制台、保存任务、计算触发时间、发起调度 |
| Executor 执行器 | 集成在业务服务中,真正执行业务方法 |
| 调度数据库 | 保存任务配置、执行日志、注册信息 |
| Registry 注册 | 执行器启动后注册到 Admin |
| JobHandler | 业务任务方法,例如 closeTimeoutOrderJob |
Executor 是不是业务 Application
通常是。Executor 不是必须单独部署的一种特殊服务,而是业务 Spring Boot Application 引入 xxl-job-core、创建 XxlJobSpringExecutor 后获得的任务执行能力。业务应用进程启动后,会以 Executor 实例的身份向 Admin 注册,并接收 Admin 下发的任务。
例如订单服务接入 XXL-JOB 后,仍然是同一个应用、同一个 JVM:
flowchart LR
A["XXL-JOB Admin"] -->|"调度请求"| B["order-service Application"]
subgraph B["order-service Spring Boot Application"]
C["Controller"] --> D["OrderService"]
E["XXL-JOB Executor"] --> F["@XxlJob JobHandler"]
F --> D
end可以这样区分几个容易混淆的概念:
| 概念 | 示例 | 含义 |
|---|---|---|
| 业务 Application | order-service | 实际运行的 Spring Boot 应用 |
| Executor AppName | order-service-executor | Admin 中的一组逻辑执行器 |
| Executor 实例 | 10.0.1.11:9999 | 一个实际运行的应用进程、容器或 Pod |
| JobHandler | closeTimeoutOrderJob | Application 中被 @XxlJob 标记的具体任务入口 |
假设 order-service 部署了 3 个 Pod,并且配置相同的 appname:
order-service-executor
├── 10.0.1.11:9999
├── 10.0.1.12:9999
└── 10.0.1.13:9999在 Admin 看来,这是一个 Executor 逻辑分组和三个在线 Executor 实例。普通路由策略通常选择其中一个实例执行;分片广播会向全部在线实例下发任务。
Executor 端口和业务端口
Executor 和业务 Application 可以处于同一个 JVM,但不一定共用端口:
server:
port: 8081
xxl:
job:
executor:
port: 9999这里 8081 是 Spring Boot Web 接口端口,9999 是 Executor 接收 Admin 调度请求的端口。部署时不仅要保证用户或网关能访问 8081,还要保证 Admin 能访问执行器注册的 9999 地址。Docker 或 Kubernetes 环境中不要错误注册只有容器自身才能访问的 127.0.0.1。
放在业务应用还是独立拆分
任务与当前服务业务强相关、执行时间短、资源消耗可控,并且可以直接复用现有 Service 时,通常把 Executor 接入现有业务 Application,保持链路简单:
HTTP 请求 → Controller → Service
定时调度 → JobHandler → ServiceJobHandler 和 Controller 都应保持为薄入口,只负责获取参数、校验、记录必要日志和调用 Service,不要堆积业务逻辑。
报表生成、大批量扫描、文件处理或长时间运行的任务会明显占用 CPU、内存、线程池或数据库连接时,可以拆成独立 Job Application,以便隔离在线流量并独立扩容。但拆分后应通过 RPC、消息队列或共享的领域模块复用业务能力,不要复制两套业务规则。
一句话记忆:
Admin 决定什么时候做,Executor Application 决定在哪里做,JobHandler 是任务入口,Service 承载真正的业务逻辑。
执行流程
flowchart TD
A["控制台配置任务"] --> B["Admin 保存任务信息"]
B --> C["调度线程扫描到期任务"]
C --> D["根据路由策略选择执行器"]
D --> E["Admin 调用执行器接口"]
E --> F["执行器找到 JobHandler"]
F --> G["执行业务代码"]
G --> H{"执行结果"}
H -- "成功" --> I["回调成功日志"]
H -- "失败" --> J["回调失败日志"]
J --> K["按配置重试或告警"]理解这个流程后,排查问题就有方向了:
- 任务没触发,看 Admin 任务配置和调度日志。
- 任务触发失败,看执行器是否注册、网络是否通。
- 任务执行失败,看业务日志和回调日志。
- 任务重复执行,看路由策略、阻塞策略和业务幂等。
启动和注册全过程
XXL-JOB 的第一步不是“到点执行”,而是执行器先把自己注册到调度中心。否则 Admin 根本不知道哪些机器可以执行任务。
flowchart TD
A["业务服务启动"] --> B["创建 XxlJobSpringExecutor"]
B --> C["扫描 @XxlJob 方法"]
C --> D["建立 JobHandler 映射"]
D --> E["启动执行器内置 HTTP 服务"]
E --> F["向 Admin 注册 appname 和地址"]
F --> G["Admin 保存执行器地址"]
G --> H["执行器定时续约心跳"]扫描 JobHandler
业务服务里写了:
@XxlJob("closeTimeoutOrderJob")
public void closeTimeoutOrderJob() {
// 关单逻辑
}执行器启动时会把这个方法注册成一个 Handler 映射,类似:
| Handler 名称 | 实际方法 |
|---|---|
closeTimeoutOrderJob | OrderJobHandler.closeTimeoutOrderJob() |
productEsSyncCompensateJob | ProductJobHandler.productEsSyncCompensateJob() |
Admin 控制台配置任务时填的 JobHandler 名称,必须和这里的名称一致。否则 Admin 虽然能触发执行器,但执行器找不到业务方法,任务会失败。
为什么 appname 很重要
appname 是一组执行器的逻辑名称。订单服务部署 3 个实例,它们应该使用同一个 appname:
xxl:
job:
executor:
appname: order-service-executorAdmin 通过 appname 找到这组执行器地址,再按路由策略选择其中一台或多台执行。
如果 appname 写错,会出现:
| 错误 | 后果 |
|---|---|
| 控制台任务 appname 和服务配置不一致 | 执行器一直显示不在线 |
| 不同服务用了同一个 appname | 任务可能路由到错误服务 |
| 每个实例 appname 不一致 | 分片广播和路由都不符合预期 |
| 多环境 appname 混用 | 测试任务可能打到生产服务 |
地址注册和自动发现
执行器地址有两种来源:
| 方式 | 含义 | 适合 |
|---|---|---|
| 自动注册 | 执行器启动后把自己的地址报给 Admin | 大多数 Spring Boot 服务 |
| 手动录入 | 在控制台固定写执行器地址 | 网络固定、机器少的场景 |
自动注册不是注册到 Nacos 或 Eureka,而是注册到 XXL-JOB Admin 自己的注册表。Admin 后续调度时读取这个注册表,判断有哪些在线执行器。
Admin 调度扫描全过程
任务到点不是浏览器控制台触发的,而是 Admin 后台调度线程周期性扫描数据库中的任务配置。新版本仍然采用“数据库轮询 + 全局调度锁 + 5 秒预读 + 内存时间轮”的调度模型,不是数据库主动通知 Admin,也不是每个任务独占一个等待线程。
flowchart TD
A["Admin 调度线程启动"] --> B["读取任务配置表"]
B --> C["找出即将到期的任务"]
C --> D["计算本次触发时间"]
D --> E["生成调度日志"]
E --> F["选择路由策略"]
F --> G["下发触发请求给执行器"]
G --> H["更新任务下一次触发时间"]可以把 Admin 理解成一个“集中式闹钟 + 调度数据库 + 调用客户端”。
它做的事包括:
- 保存任务的 cron、路由策略、阻塞策略、超时时间、失败重试次数。
- 根据时间扫描应该触发的任务。
- 为每次触发生成日志记录。
- 根据 appname 找执行器地址。
- 按路由策略选择执行器。
- 调用执行器的接口。
- 记录触发是否成功。
新版本如何扫描到期任务
当前版本的核心预读窗口是 5 秒。Admin 获得调度权后,查询正在运行且下一次触发时间不晚于“当前时间 + 5 秒”的任务,逻辑接近:
SELECT ...
FROM xxl_job_info
WHERE trigger_status = 1
AND trigger_next_time <= :nowPlusFiveSeconds
ORDER BY id ASC
LIMIT :preReadCount;它不是无条件扫描整张表。繁忙时调度线程大约每秒扫描一次;本轮没有预读到任务时,大约等待 5 秒再扫描。单轮预读数量也受 Admin 快、慢触发线程池容量限制,避免一次加载过多任务。
查出的任务分三类处理:
| 任务状态 | Admin 的处理 |
|---|---|
| 已过期超过 5 秒 | 按 Misfire 策略忽略或补偿一次,再计算下一次时间 |
| 已到期但未超过 5 秒 | 直接提交触发线程池,再计算下一次时间 |
| 未来 5 秒内到期 | 放入内存时间轮的对应秒槽,再计算下一次时间 |
时间轮线程每秒读取当前秒槽,并向前补查最近的秒槽,降低 JVM 停顿或线程抖动造成的漏触发风险。任务最终仍由触发线程池通过网络调用 Executor,因此这是秒级调度,不是硬实时调度。
多 Admin 为什么不会同时推进同一个任务
Admin 集群会在事务中锁定 xxl_job_lock 的全局调度记录,逻辑接近:
SELECT *
FROM xxl_job_lock
WHERE lock_name = 'schedule_lock'
FOR UPDATE;只有取得行锁的 Admin 才能在该轮查询任务、计算时间并更新 trigger_next_time,事务提交后释放锁。因此,多 Admin 主要提供高可用,不会按节点自动分片并行扫描;增加 Admin 副本也不会让核心调度吞吐线性增长。
数据库锁只能降低 Admin 重复推进任务的概率,不能提供业务上的严格 exactly-once。触发响应丢失、Executor 已执行但回调失败、自动重试和人工重跑都可能造成重复执行,业务仍然必须使用状态条件、唯一索引或幂等流水防重。
为什么 Admin 要先生成调度日志
调度日志是排查链路的起点。一次任务从“被调度”到“执行完成”,至少要留下:
| 日志信息 | 排查价值 |
|---|---|
| 任务 ID | 知道是哪一个任务配置 |
| 触发时间 | 知道是否按时触发 |
| 执行器地址 | 知道任务下发到哪台机器 |
| 路由策略 | 知道为什么选了这台机器 |
| 触发结果 | 判断 Admin 到 Executor 是否成功 |
| 执行结果 | 判断业务代码是否成功 |
| 失败原因 | 用于重试和告警 |
如果没有调度日志,只能去每台业务机器翻应用日志,排查成本会非常高。
路由选择全过程
当一个 appname 下有多个执行器时,Admin 必须决定“这次任务发给谁”。
flowchart TD
A["读取 appname"] --> B["查询在线执行器地址列表"]
B --> C{"路由策略"}
C -- "轮询" --> D["按顺序选择下一台"]
C -- "故障转移" --> E["逐台探测可用性"]
C -- "分片广播" --> F["全部执行器都下发"]
C -- "一致性 Hash" --> G["按任务维度稳定路由"]
D --> H["生成触发请求"]
E --> H
F --> H
G --> H常见误区:路由策略只决定“调度请求发给谁”,不等于业务一定不会重复,也不等于数据一定分片正确。
例如普通轮询只会选一台执行器执行一次;分片广播会让每个在线执行器都执行一次。分片广播时,业务代码必须根据 shardIndex 和 shardTotal 处理自己的数据,否则每台机器都会全量扫描。
路由策略背后的取舍
| 路由策略 | 原理 | 优点 | 风险 |
|---|---|---|---|
| 第一个 | 总是选择地址列表第一台 | 简单稳定 | 第一台压力大,挂了容易失败 |
| 轮询 | 每次选择下一台 | 压力相对均衡 | 不保证任务亲和性 |
| 随机 | 随机选择一台 | 实现简单 | 短时间可能不均匀 |
| 一致性 Hash | 同一任务尽量落到同一台 | 适合有本地缓存的任务 | 节点变化时仍会迁移 |
| 故障转移 | 逐台探测,找可用节点 | 可用性更好 | 探测耗时更高 |
| 忙碌转移 | 跳过忙碌节点 | 避免打到正在执行的机器 | 判断忙碌也有成本 |
| 分片广播 | 所有节点都执行 | 适合大批量并行 | 业务必须正确分片 |
执行器接收请求全过程
Admin 选好执行器后,会通过 HTTP 请求触发执行器。执行器收到请求后,并不是直接随便执行方法,而是要完成鉴权、参数解析、阻塞判断、线程调度和结果回调。
flowchart TD
A["Admin 发送触发请求"] --> B["Executor 接收 HTTP 请求"]
B --> C["校验 accessToken"]
C --> D["解析 jobId、handler、param"]
D --> E["查找 JobHandler"]
E --> F{"阻塞策略判断"}
F -- "允许执行" --> G["提交到任务线程"]
F -- "拒绝或覆盖" --> H["按阻塞策略处理"]
G --> I["执行业务方法"]
I --> J["写本地执行日志"]
J --> K["回调 Admin 执行结果"]accessToken 的作用
accessToken 是 Admin 和 Executor 之间的简单鉴权手段。它不能替代完整的网络安全,但能避免没有 token 的请求随意触发任务。
如果不配置或泄露:
- 内网其他服务可能伪造调度请求。
- 重要任务可能被恶意触发。
- 带参数任务可能被传入危险参数。
生产环境还应该配合:
- Admin 控制台登录认证。
- 网络白名单。
- 只允许 Admin 访问 Executor 端口。
- 任务参数不要放密码、密钥、身份证等敏感信息。
JobHandler 找不到会怎样
如果控制台配置 closeTimeoutOrderJob,但代码里没有同名 @XxlJob,执行器会返回失败。
常见原因:
| 原因 | 例子 |
|---|---|
| 名称拼错 | 控制台写 closeOrderJob,代码是 closeTimeoutOrderJob |
| Bean 没被 Spring 扫描 | Handler 类不在扫描包下 |
| 环境版本不一致 | Admin 配的是新任务,执行器还是旧代码 |
| appname 指错 | 任务发到了没有该 Handler 的服务 |
日志回调全过程
XXL-JOB 的一个重要价值是“能在控制台看到每次任务日志”。这背后有两类日志:触发日志和执行日志。
flowchart TD
A["Admin 创建触发日志"] --> B["Executor 执行业务"]
B --> C["XxlJobHelper.log 写本地日志文件"]
B --> D["执行结束生成结果"]
D --> E["Executor 回调 Admin"]
E --> F["Admin 更新调度日志状态"]
F --> G["控制台展示成功或失败"]| 日志 | 存在哪里 | 说明 |
|---|---|---|
| 触发日志 | Admin 数据库 | 记录任务是否被调度、发给谁 |
| 执行日志 | Executor 本地文件 | 记录业务过程日志 |
| 回调结果 | Admin 数据库 | 记录最终成功或失败 |
这也是为什么执行器配置里有:
xxl:
job:
executor:
logpath: ./logs/xxl-job/jobhandler
logretentiondays: 30如果执行器本地日志路径不可写,控制台可能看不到完整执行日志。生产环境要确保目录存在、磁盘空间充足、日志保留天数合理。
失败重试全过程
失败重试不是简单“再跑一次”。它会再次触发业务代码,因此必须和幂等设计绑定。
flowchart TD
A["任务执行失败"] --> B["Executor 回调失败"]
B --> C["Admin 更新失败日志"]
C --> D{"是否配置重试次数"}
D -- "否" --> E["进入失败状态并告警"]
D -- "是" --> F["生成重试触发"]
F --> G["再次选择执行器"]
G --> H["重新执行 JobHandler"]
H --> I{"是否成功"}
I -- "成功" --> J["更新成功"]
I -- "失败" --> K["继续重试或告警"]重试带来的问题:
| 问题 | 例子 | 解决 |
|---|---|---|
| 重复处理 | 第一次已关单但回调失败,重试又关一次 | SQL 带状态条件 |
| 重复发消息 | 第一次已发券,重试又发券 | 业务流水唯一索引 |
| 下游雪崩 | 下游已故障,重试加大压力 | 限制重试次数,失败转人工补偿 |
| 错误参数反复失败 | JSON 参数写错 | 参数校验失败直接终止 |
判断是否适合重试:
| 异常类型 | 是否适合重试 | 原因 |
|---|---|---|
| 网络超时 | 适合有限重试 | 可能是瞬时故障 |
| 数据库连接抖动 | 适合有限重试 | 可能恢复 |
| 空指针 | 不适合盲目重试 | 代码 bug |
| 参数格式错误 | 不适合重试 | 重试参数仍然错 |
| 库存不足 | 看业务 | 可能需要补偿或人工处理 |
阻塞策略执行过程
阻塞策略解决的是“同一个执行器上,同一个任务上一次还没跑完,本次又来了怎么办”。
flowchart TD
A["新的触发请求到达 Executor"] --> B["检查同一 jobId 是否正在运行"]
B --> C{"是否正在运行"}
C -- "否" --> D["直接提交执行"]
C -- "是" --> E{"阻塞策略"}
E -- "单机串行" --> F["进入队列等待"]
E -- "丢弃后续" --> G["本次触发标记失败或丢弃"]
E -- "覆盖之前" --> H["终止旧任务并执行新任务"]这几个策略的本质区别:
| 策略 | 保护什么 | 风险 |
|---|---|---|
| 单机串行 | 保护任务不并发 | 队列越来越长 |
| 丢弃后续 | 保护系统不堆积 | 可能漏掉某次扫描 |
| 覆盖之前 | 追求最新任务 | 旧任务被中断可能留下半成品 |
订单、对账、结算这种数据一致性要求高的任务,一般不要覆盖之前调度。更好的做法是:拆小批次、加超时、加分片、加补偿,而不是强杀正在执行的业务。
分片广播执行过程
分片广播不是 Admin 把数据切好了发给执行器,而是 Admin 只告诉每个执行器“你是第几个分片,总共有几个分片”。真正的数据切分由业务代码自己完成。
flowchart TD
A["Admin 获取在线执行器列表"] --> B["计算 shardTotal"]
B --> C["给 Executor A 发送 shardIndex=0"]
B --> D["给 Executor B 发送 shardIndex=1"]
B --> E["给 Executor C 发送 shardIndex=2"]
C --> F["业务 SQL 只查自己的数据"]
D --> G["业务 SQL 只查自己的数据"]
E --> H["业务 SQL 只查自己的数据"]如果业务代码不使用分片参数,每个执行器都会全量扫表,结果就是并行越多,重复越多,数据库压力越大。
正确示例:
select id
from t_order
where status = 'WAIT_PAY'
and created_at < now() - interval 30 minute
and mod(id, ?) = ?
order by id
limit ?;错误示例:
select id
from t_order
where status = 'WAIT_PAY'
and created_at < now() - interval 30 minute
order by id
limit ?;错误示例的问题是:3 台执行器都会查同一批订单。即使更新 SQL 有幂等条件,数据库也会承受 3 倍无效查询和锁竞争。
核心概念
| 概念 | 说明 | 商业例子 |
|---|---|---|
| 执行器 AppName | 一类业务服务的执行器标识 | order-service-executor |
| JobHandler | 具体任务方法名 | closeTimeoutOrderJob |
| Cron | 触发时间表达式 | 每 5 分钟关单 |
| 路由策略 | 多个执行器时选哪台执行 | 第一个、轮询、故障转移、分片广播 |
| 阻塞策略 | 上次没跑完又触发怎么办 | 单机串行、丢弃后续、覆盖之前 |
| 失败重试 | 失败后再执行几次 | 下游接口抖动时重试 |
| 任务参数 | 调度时传给任务的字符串 | {"batchSize":500} |
| 分片参数 | 当前分片序号和总分片数 | 10 台机器并行扫订单 |
部署思路
XXL-JOB 通常至少包含:
- 一个调度中心
xxl-job-admin。 - 一个 MySQL 数据库保存调度配置。
- 多个业务服务作为执行器。
初始化调度数据库
从官方仓库检出与目标版本一致的源码,先在 MySQL 中执行:
doc/db/tables_xxl_job.sql该脚本会创建调度任务、执行器分组、注册信息、调度日志和全局调度锁等表。不要根据其他版本的文章手工拼表结构,否则 Admin 代码和数据库字段可能不匹配。数据库建议使用 utf8mb4,生产环境使用独立的最小权限账号,并通过环境变量或配置中心注入密码。
学习环境可以用 Docker 启动 Admin。下面是示例配置,版本号按项目实际版本调整。
version: "3.8"
services:
mysql:
image: mysql:8.0
container_name: xxl-job-mysql
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: xxl_job
ports:
- "3306:3306"
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
xxl-job-admin:
image: xuxueli/xxl-job-admin:3.4.0
container_name: xxl-job-admin
depends_on:
- mysql
ports:
- "8080:8080"
environment:
PARAMS: >
--spring.datasource.url=jdbc:mysql://mysql:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
--spring.datasource.username=root
--spring.datasource.password=root真实生产环境要注意:
- MySQL 账号不能用 root。
- Admin 要做高可用部署。
- 控制台要接入认证和网络访问控制。
- 调度数据库要备份。
- Admin 和 Executor 之间的网络要稳定。
- Admin 与 Executor 配置相同的非空
accessToken,并限制管理页面的网络访问范围。
Spring Boot 集成执行器
引入依赖:
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
<version>3.4.0</version>
</dependency>配置文件:
xxl:
job:
admin:
addresses: http://127.0.0.1:8080/xxl-job-admin
accessToken: ${XXL_JOB_ACCESS_TOKEN}
executor:
appname: order-service-executor
address:
ip:
port: 9999
logpath: ./logs/xxl-job/jobhandler
logretentiondays: 30注册执行器 Bean:
import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class XxlJobConfig {
@Bean
public XxlJobSpringExecutor xxlJobExecutor(
@Value("${xxl.job.admin.addresses}") String adminAddresses,
@Value("${xxl.job.accessToken}") String accessToken,
@Value("${xxl.job.executor.appname}") String appName,
@Value("${xxl.job.executor.address:}") String address,
@Value("${xxl.job.executor.ip:}") String ip,
@Value("${xxl.job.executor.port}") int port,
@Value("${xxl.job.executor.logpath}") String logPath,
@Value("${xxl.job.executor.logretentiondays}") int logRetentionDays) {
XxlJobSpringExecutor executor = new XxlJobSpringExecutor();
executor.setAdminAddresses(adminAddresses);
executor.setAccessToken(accessToken);
executor.setAppname(appName);
executor.setAddress(address);
executor.setIp(ip);
executor.setPort(port);
executor.setLogPath(logPath);
executor.setLogRetentionDays(logRetentionDays);
return executor;
}
}启动业务应用后,先到 Admin 的“执行器管理”新增执行器:
| 配置项 | 示例 | 要求 |
|---|---|---|
| AppName | order-service-executor | 必须与应用配置完全一致 |
| 名称 | 订单服务执行器 | 便于识别业务归属 |
| 注册方式 | 自动注册 | 大多数 Spring Boot 服务使用此方式 |
等待执行器地址出现在控制台后,再创建调度任务。地址为空时应先检查 Token、网络、端口和 appname,不要继续用“启动任务”掩盖注册问题。
在 Admin 创建第一个任务
先用一个只打印日志的 Handler 跑通链路:
import com.xxl.job.core.context.XxlJobHelper;
import com.xxl.job.core.handler.annotation.XxlJob;
import org.springframework.stereotype.Component;
@Component
public class HelloJobHandler {
@XxlJob("helloJobHandler")
public void execute() {
String param = XxlJobHelper.getJobParam();
XxlJobHelper.log("helloJobHandler 开始执行,参数:{}", param);
}
}在 Admin 的“任务管理”中新增任务:
| 配置项 | 示例 |
|---|---|
| 执行器 | order-service-executor |
| 调度类型 | CRON |
| Cron | 0/10 * * * * ?,即每 10 秒一次 |
| 运行模式 | BEAN |
| JobHandler | helloJobHandler |
| 任务参数 | hello |
| 路由策略 | 轮询 |
| 阻塞策略 | 单机串行 |
| 失败重试次数 | 0,跑通后再按业务配置 |
保存后先点击“执行一次”,依次确认 Admin 调度日志、Executor 本地日志和执行结果;手动执行成功后再启动 Cron。这样可以把“接入配置错误”和“Cron 时间错误”分开排查。
订单超时关闭 JobHandler
下面是商业系统常见场景:扫描超过 30 分钟未支付的订单并关闭。
import com.xxl.job.core.context.XxlJobHelper;
import com.xxl.job.core.handler.annotation.XxlJob;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderJobHandler {
private final OrderRepository orderRepository;
@XxlJob("closeTimeoutOrderJob")
public void closeTimeoutOrderJob() {
int shardIndex = XxlJobHelper.getShardIndex();
int shardTotal = XxlJobHelper.getShardTotal();
String param = XxlJobHelper.getJobParam();
int batchSize = JobParamParser.getInt(param, "batchSize", 500);
long lastId = 0L;
int totalClosed = 0;
while (true) {
List<Long> orderIds = orderRepository.findTimeoutWaitPayOrders(
lastId, batchSize, shardIndex, shardTotal);
if (orderIds.isEmpty()) {
break;
}
for (Long orderId : orderIds) {
boolean closed = orderRepository.closeIfWaitPay(orderId);
if (closed) {
totalClosed++;
}
}
lastId = orderIds.get(orderIds.size() - 1);
}
XxlJobHelper.log("close timeout order finished, shardIndex={}, shardTotal={}, totalClosed={}",
shardIndex, shardTotal, totalClosed);
}
}查询 SQL:
select id
from t_order
where id > ?
and status = 'WAIT_PAY'
and created_at < now() - interval 30 minute
and mod(id, ?) = ?
order by id
limit ?;幂等更新 SQL:
update t_order
set status = 'CLOSED',
close_reason = 'PAY_TIMEOUT',
updated_at = now()
where id = ?
and status = 'WAIT_PAY';这里有三个关键点:
- 分页用
id > lastId,避免大 offset。 - 分片用
mod(id, shardTotal) = shardIndex,多执行器可以并行处理。 - 更新带
status = 'WAIT_PAY',防止用户刚支付成功却被关单。
分片广播
分片广播适合大数据量批处理,例如几百万订单、几千万商品同步。
flowchart TD
A["Admin 触发分片广播任务"] --> B["Executor A<br/>shardIndex=0"]
A --> C["Executor B<br/>shardIndex=1"]
A --> D["Executor C<br/>shardIndex=2"]
B --> E["处理 id % 3 = 0 的数据"]
C --> F["处理 id % 3 = 1 的数据"]
D --> G["处理 id % 3 = 2 的数据"]分片要注意:
- 每个分片只处理自己的数据,不能全量扫描。
- 分片总数变化时,下一轮任务会重新分配,不要把分片结果长期固化。
- 业务逻辑仍然要幂等,因为某个分片失败后可能重试。
路由策略怎么选
| 路由策略 | 适合场景 | 注意 |
|---|---|---|
| 第一个 | 低频任务、执行器稳定 | 第一台压力偏大 |
| 轮询 | 多实例均衡执行 | 单次任务只选一台 |
| 随机 | 简单分散压力 | 不保证均匀 |
| 故障转移 | 任务必须尽量找可用节点 | 会多次探测执行器 |
| 分片广播 | 大批量并行处理 | 每个执行器都会执行一次 |
大多数普通任务用轮询或故障转移;大批量任务用分片广播。
阻塞策略怎么选
阻塞策略解决的是:上一次任务还没执行完,下一次触发又来了怎么办。
| 阻塞策略 | 含义 | 适合 |
|---|---|---|
| 单机串行 | 同一个执行器上排队执行 | 不能并发的任务 |
| 丢弃后续调度 | 上次没跑完,本次丢弃 | 周期短、只要最新一轮即可 |
| 覆盖之前调度 | 终止旧任务再跑新任务 | 谨慎使用,业务必须能安全中断 |
订单关单、对账、结算这类任务通常不要覆盖旧任务,优先单机串行或丢弃后续,并把任务拆小。
失败重试和告警
失败重试适合临时问题:
- 网络抖动。
- 下游服务短暂不可用。
- 数据库连接瞬时失败。
不适合用重试掩盖代码 bug。如果参数错误、SQL 错误、空指针异常,重试只会制造更多失败日志。
建议:
- 设置合理重试次数,比如 1 到 3 次。
- 失败日志要包含业务关键参数。
- 连续失败必须告警到负责人。
- 对资金、库存、发券任务,失败后要进入人工可补偿流程。
任务参数设计
XXL-JOB 的任务参数是字符串。建议用 JSON:
{
"batchSize": 500,
"timeoutMinutes": 30,
"dryRun": false
}解析示例:
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public class JobParamParser {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
public static int getInt(String param, String field, int defaultValue) {
try {
if (param == null || param.isBlank()) {
return defaultValue;
}
JsonNode node = OBJECT_MAPPER.readTree(param);
return node.has(field) ? node.get(field).asInt() : defaultValue;
} catch (Exception ex) {
return defaultValue;
}
}
}参数不要放敏感信息,例如数据库密码、生产密钥。控制台参数可能被运维、开发、测试多人看到。
ES 同步补偿任务 Demo
商品变更通常通过 MQ 同步 ES,但消息可能失败。可以用 XXL-JOB 定时扫描同步失败记录。
@XxlJob("productEsSyncCompensateJob")
public void productEsSyncCompensateJob() {
int batchSize = 300;
long lastId = 0L;
while (true) {
List<ProductSyncRecord> records = productSyncRepository.findFailedRecords(lastId, batchSize);
if (records.isEmpty()) {
break;
}
for (ProductSyncRecord record : records) {
try {
ProductSearchDoc doc = productService.buildSearchDoc(record.productId());
productSearchGateway.upsert(doc);
productSyncRepository.markSuccess(record.id());
} catch (Exception ex) {
productSyncRepository.increaseRetryCount(record.id(), ex.getMessage());
XxlJobHelper.log("sync product es failed, recordId={}, productId={}, error={}",
record.id(), record.productId(), ex.getMessage());
}
}
lastId = records.get(records.size() - 1).id();
}
}这类任务要控制重试次数。永久失败的数据要进入人工处理列表,不能无限重试压垮系统。
控制台配置建议
| 配置项 | 建议 |
|---|---|
| 任务描述 | 写清业务含义,例如“订单超时关闭补偿任务” |
| 负责人 | 写真实负责人,方便告警 |
| Cron | 不要所有任务都挤在整点 |
| 路由策略 | 普通任务轮询,大批量任务分片广播 |
| 阻塞策略 | 优先单机串行或丢弃后续 |
| 超时时间 | 必须设置,避免卡死 |
| 失败重试 | 1 到 3 次,不要无限重试 |
| 任务参数 | 用 JSON,写清 batchSize、dryRun 等 |
线上排查
| 现象 | 可能原因 | 排查 |
|---|---|---|
| 任务不触发 | Cron 未启用、任务暂停、Admin 时间异常 | 看任务状态和下次触发时间 |
| 执行器不在线 | appname 不一致、网络不通、端口未开放 | 看执行器注册列表 |
| 触发失败 | Admin 调不到 Executor | 看 Admin 日志、防火墙、accessToken |
| 执行失败 | 业务异常、参数错误、DB 慢 | 看执行日志和应用日志 |
| 重复执行 | 多任务配置、重试、分片逻辑错误 | 查调度日志和业务幂等 |
| 执行很慢 | 单批太大、SQL 无索引、下游慢 | explain SQL,减小 batchSize |
| 日志爆炸 | 循环里打印过多日志 | 只打印摘要和异常上下文 |
XXL-JOB 和其他方案对比
| 方案 | 适合 | 不适合 |
|---|---|---|
@Scheduled | 单机简单任务 | 分布式治理 |
| Quartz | 应用内持久化调度 | 需要统一控制台的多服务任务 |
| XXL-JOB | 微服务分布式任务 | 极简单单机脚本 |
| MQ 延迟消息 | 单条数据到期处理 | 大批量定时报表 |
| K8s CronJob | 容器脚本和批处理 | Java 方法级任务治理 |
本章小结
XXL-JOB 的核心价值是把分散在各个服务里的后台任务统一治理起来。
学习重点不是背配置项,而是理解:
- Admin 负责调度,Executor 负责执行业务。
- JobHandler 是业务任务入口。
- 多实例下要靠路由策略和分片控制执行。
- 任务必须幂等,不能把“不重复”完全寄托给调度框架。
- 生产任务一定要有日志、超时、重试、告警和人工补偿手段。
