Skip to content

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 服务里。

架构组成

mermaid
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:

mermaid
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

可以这样区分几个容易混淆的概念:

概念示例含义
业务 Applicationorder-service实际运行的 Spring Boot 应用
Executor AppNameorder-service-executorAdmin 中的一组逻辑执行器
Executor 实例10.0.1.11:9999一个实际运行的应用进程、容器或 Pod
JobHandlercloseTimeoutOrderJobApplication 中被 @XxlJob 标记的具体任务入口

假设 order-service 部署了 3 个 Pod,并且配置相同的 appname

text
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,但不一定共用端口:

yaml
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,保持链路简单:

text
HTTP 请求 → Controller → Service
定时调度 → JobHandler → Service

JobHandlerController 都应保持为薄入口,只负责获取参数、校验、记录必要日志和调用 Service,不要堆积业务逻辑。

报表生成、大批量扫描、文件处理或长时间运行的任务会明显占用 CPU、内存、线程池或数据库连接时,可以拆成独立 Job Application,以便隔离在线流量并独立扩容。但拆分后应通过 RPC、消息队列或共享的领域模块复用业务能力,不要复制两套业务规则。

一句话记忆:

Admin 决定什么时候做,Executor Application 决定在哪里做,JobHandler 是任务入口,Service 承载真正的业务逻辑。

执行流程

mermaid
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["按配置重试或告警"]

理解这个流程后,排查问题就有方向了:

  1. 任务没触发,看 Admin 任务配置和调度日志。
  2. 任务触发失败,看执行器是否注册、网络是否通。
  3. 任务执行失败,看业务日志和回调日志。
  4. 任务重复执行,看路由策略、阻塞策略和业务幂等。

启动和注册全过程

XXL-JOB 的第一步不是“到点执行”,而是执行器先把自己注册到调度中心。否则 Admin 根本不知道哪些机器可以执行任务。

mermaid
flowchart TD
    A["业务服务启动"] --> B["创建 XxlJobSpringExecutor"]
    B --> C["扫描 @XxlJob 方法"]
    C --> D["建立 JobHandler 映射"]
    D --> E["启动执行器内置 HTTP 服务"]
    E --> F["向 Admin 注册 appname 和地址"]
    F --> G["Admin 保存执行器地址"]
    G --> H["执行器定时续约心跳"]

扫描 JobHandler

业务服务里写了:

java
@XxlJob("closeTimeoutOrderJob")
public void closeTimeoutOrderJob() {
    // 关单逻辑
}

执行器启动时会把这个方法注册成一个 Handler 映射,类似:

Handler 名称实际方法
closeTimeoutOrderJobOrderJobHandler.closeTimeoutOrderJob()
productEsSyncCompensateJobProductJobHandler.productEsSyncCompensateJob()

Admin 控制台配置任务时填的 JobHandler 名称,必须和这里的名称一致。否则 Admin 虽然能触发执行器,但执行器找不到业务方法,任务会失败。

为什么 appname 很重要

appname 是一组执行器的逻辑名称。订单服务部署 3 个实例,它们应该使用同一个 appname:

yaml
xxl:
  job:
    executor:
      appname: order-service-executor

Admin 通过 appname 找到这组执行器地址,再按路由策略选择其中一台或多台执行。

如果 appname 写错,会出现:

错误后果
控制台任务 appname 和服务配置不一致执行器一直显示不在线
不同服务用了同一个 appname任务可能路由到错误服务
每个实例 appname 不一致分片广播和路由都不符合预期
多环境 appname 混用测试任务可能打到生产服务

地址注册和自动发现

执行器地址有两种来源:

方式含义适合
自动注册执行器启动后把自己的地址报给 Admin大多数 Spring Boot 服务
手动录入在控制台固定写执行器地址网络固定、机器少的场景

自动注册不是注册到 Nacos 或 Eureka,而是注册到 XXL-JOB Admin 自己的注册表。Admin 后续调度时读取这个注册表,判断有哪些在线执行器。

Admin 调度扫描全过程

任务到点不是浏览器控制台触发的,而是 Admin 后台调度线程周期性扫描数据库中的任务配置。新版本仍然采用“数据库轮询 + 全局调度锁 + 5 秒预读 + 内存时间轮”的调度模型,不是数据库主动通知 Admin,也不是每个任务独占一个等待线程。

mermaid
flowchart TD
    A["Admin 调度线程启动"] --> B["读取任务配置表"]
    B --> C["找出即将到期的任务"]
    C --> D["计算本次触发时间"]
    D --> E["生成调度日志"]
    E --> F["选择路由策略"]
    F --> G["下发触发请求给执行器"]
    G --> H["更新任务下一次触发时间"]

可以把 Admin 理解成一个“集中式闹钟 + 调度数据库 + 调用客户端”。

它做的事包括:

  1. 保存任务的 cron、路由策略、阻塞策略、超时时间、失败重试次数。
  2. 根据时间扫描应该触发的任务。
  3. 为每次触发生成日志记录。
  4. 根据 appname 找执行器地址。
  5. 按路由策略选择执行器。
  6. 调用执行器的接口。
  7. 记录触发是否成功。

新版本如何扫描到期任务

当前版本的核心预读窗口是 5 秒。Admin 获得调度权后,查询正在运行且下一次触发时间不晚于“当前时间 + 5 秒”的任务,逻辑接近:

sql
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 的全局调度记录,逻辑接近:

sql
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 必须决定“这次任务发给谁”。

mermaid
flowchart TD
    A["读取 appname"] --> B["查询在线执行器地址列表"]
    B --> C{"路由策略"}
    C -- "轮询" --> D["按顺序选择下一台"]
    C -- "故障转移" --> E["逐台探测可用性"]
    C -- "分片广播" --> F["全部执行器都下发"]
    C -- "一致性 Hash" --> G["按任务维度稳定路由"]
    D --> H["生成触发请求"]
    E --> H
    F --> H
    G --> H

常见误区:路由策略只决定“调度请求发给谁”,不等于业务一定不会重复,也不等于数据一定分片正确。

例如普通轮询只会选一台执行器执行一次;分片广播会让每个在线执行器都执行一次。分片广播时,业务代码必须根据 shardIndexshardTotal 处理自己的数据,否则每台机器都会全量扫描。

路由策略背后的取舍

路由策略原理优点风险
第一个总是选择地址列表第一台简单稳定第一台压力大,挂了容易失败
轮询每次选择下一台压力相对均衡不保证任务亲和性
随机随机选择一台实现简单短时间可能不均匀
一致性 Hash同一任务尽量落到同一台适合有本地缓存的任务节点变化时仍会迁移
故障转移逐台探测,找可用节点可用性更好探测耗时更高
忙碌转移跳过忙碌节点避免打到正在执行的机器判断忙碌也有成本
分片广播所有节点都执行适合大批量并行业务必须正确分片

执行器接收请求全过程

Admin 选好执行器后,会通过 HTTP 请求触发执行器。执行器收到请求后,并不是直接随便执行方法,而是要完成鉴权、参数解析、阻塞判断、线程调度和结果回调。

mermaid
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 的请求随意触发任务。

如果不配置或泄露:

  1. 内网其他服务可能伪造调度请求。
  2. 重要任务可能被恶意触发。
  3. 带参数任务可能被传入危险参数。

生产环境还应该配合:

  1. Admin 控制台登录认证。
  2. 网络白名单。
  3. 只允许 Admin 访问 Executor 端口。
  4. 任务参数不要放密码、密钥、身份证等敏感信息。

JobHandler 找不到会怎样

如果控制台配置 closeTimeoutOrderJob,但代码里没有同名 @XxlJob,执行器会返回失败。

常见原因:

原因例子
名称拼错控制台写 closeOrderJob,代码是 closeTimeoutOrderJob
Bean 没被 Spring 扫描Handler 类不在扫描包下
环境版本不一致Admin 配的是新任务,执行器还是旧代码
appname 指错任务发到了没有该 Handler 的服务

日志回调全过程

XXL-JOB 的一个重要价值是“能在控制台看到每次任务日志”。这背后有两类日志:触发日志和执行日志。

mermaid
flowchart TD
    A["Admin 创建触发日志"] --> B["Executor 执行业务"]
    B --> C["XxlJobHelper.log 写本地日志文件"]
    B --> D["执行结束生成结果"]
    D --> E["Executor 回调 Admin"]
    E --> F["Admin 更新调度日志状态"]
    F --> G["控制台展示成功或失败"]
日志存在哪里说明
触发日志Admin 数据库记录任务是否被调度、发给谁
执行日志Executor 本地文件记录业务过程日志
回调结果Admin 数据库记录最终成功或失败

这也是为什么执行器配置里有:

yaml
xxl:
  job:
    executor:
      logpath: ./logs/xxl-job/jobhandler
      logretentiondays: 30

如果执行器本地日志路径不可写,控制台可能看不到完整执行日志。生产环境要确保目录存在、磁盘空间充足、日志保留天数合理。

失败重试全过程

失败重试不是简单“再跑一次”。它会再次触发业务代码,因此必须和幂等设计绑定。

mermaid
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
参数格式错误不适合重试重试参数仍然错
库存不足看业务可能需要补偿或人工处理

阻塞策略执行过程

阻塞策略解决的是“同一个执行器上,同一个任务上一次还没跑完,本次又来了怎么办”。

mermaid
flowchart TD
    A["新的触发请求到达 Executor"] --> B["检查同一 jobId 是否正在运行"]
    B --> C{"是否正在运行"}
    C -- "否" --> D["直接提交执行"]
    C -- "是" --> E{"阻塞策略"}
    E -- "单机串行" --> F["进入队列等待"]
    E -- "丢弃后续" --> G["本次触发标记失败或丢弃"]
    E -- "覆盖之前" --> H["终止旧任务并执行新任务"]

这几个策略的本质区别:

策略保护什么风险
单机串行保护任务不并发队列越来越长
丢弃后续保护系统不堆积可能漏掉某次扫描
覆盖之前追求最新任务旧任务被中断可能留下半成品

订单、对账、结算这种数据一致性要求高的任务,一般不要覆盖之前调度。更好的做法是:拆小批次、加超时、加分片、加补偿,而不是强杀正在执行的业务。

分片广播执行过程

分片广播不是 Admin 把数据切好了发给执行器,而是 Admin 只告诉每个执行器“你是第几个分片,总共有几个分片”。真正的数据切分由业务代码自己完成。

mermaid
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 只查自己的数据"]

如果业务代码不使用分片参数,每个执行器都会全量扫表,结果就是并行越多,重复越多,数据库压力越大。

正确示例:

sql
select id
from t_order
where status = 'WAIT_PAY'
  and created_at < now() - interval 30 minute
  and mod(id, ?) = ?
order by id
limit ?;

错误示例:

sql
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 通常至少包含:

  1. 一个调度中心 xxl-job-admin
  2. 一个 MySQL 数据库保存调度配置。
  3. 多个业务服务作为执行器。

初始化调度数据库

从官方仓库检出与目标版本一致的源码,先在 MySQL 中执行:

text
doc/db/tables_xxl_job.sql

该脚本会创建调度任务、执行器分组、注册信息、调度日志和全局调度锁等表。不要根据其他版本的文章手工拼表结构,否则 Admin 代码和数据库字段可能不匹配。数据库建议使用 utf8mb4,生产环境使用独立的最小权限账号,并通过环境变量或配置中心注入密码。

学习环境可以用 Docker 启动 Admin。下面是示例配置,版本号按项目实际版本调整。

yaml
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

真实生产环境要注意:

  1. MySQL 账号不能用 root。
  2. Admin 要做高可用部署。
  3. 控制台要接入认证和网络访问控制。
  4. 调度数据库要备份。
  5. Admin 和 Executor 之间的网络要稳定。
  6. Admin 与 Executor 配置相同的非空 accessToken,并限制管理页面的网络访问范围。

Spring Boot 集成执行器

引入依赖:

xml
<dependency>
    <groupId>com.xuxueli</groupId>
    <artifactId>xxl-job-core</artifactId>
    <version>3.4.0</version>
</dependency>

配置文件:

yaml
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:

java
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 的“执行器管理”新增执行器:

配置项示例要求
AppNameorder-service-executor必须与应用配置完全一致
名称订单服务执行器便于识别业务归属
注册方式自动注册大多数 Spring Boot 服务使用此方式

等待执行器地址出现在控制台后,再创建调度任务。地址为空时应先检查 Token、网络、端口和 appname,不要继续用“启动任务”掩盖注册问题。

在 Admin 创建第一个任务

先用一个只打印日志的 Handler 跑通链路:

java
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
Cron0/10 * * * * ?,即每 10 秒一次
运行模式BEAN
JobHandlerhelloJobHandler
任务参数hello
路由策略轮询
阻塞策略单机串行
失败重试次数0,跑通后再按业务配置

保存后先点击“执行一次”,依次确认 Admin 调度日志、Executor 本地日志和执行结果;手动执行成功后再启动 Cron。这样可以把“接入配置错误”和“Cron 时间错误”分开排查。

订单超时关闭 JobHandler

下面是商业系统常见场景:扫描超过 30 分钟未支付的订单并关闭。

java
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:

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:

sql
update t_order
set status = 'CLOSED',
    close_reason = 'PAY_TIMEOUT',
    updated_at = now()
where id = ?
  and status = 'WAIT_PAY';

这里有三个关键点:

  1. 分页用 id > lastId,避免大 offset。
  2. 分片用 mod(id, shardTotal) = shardIndex,多执行器可以并行处理。
  3. 更新带 status = 'WAIT_PAY',防止用户刚支付成功却被关单。

分片广播

分片广播适合大数据量批处理,例如几百万订单、几千万商品同步。

mermaid
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 的数据"]

分片要注意:

  1. 每个分片只处理自己的数据,不能全量扫描。
  2. 分片总数变化时,下一轮任务会重新分配,不要把分片结果长期固化。
  3. 业务逻辑仍然要幂等,因为某个分片失败后可能重试。

路由策略怎么选

路由策略适合场景注意
第一个低频任务、执行器稳定第一台压力偏大
轮询多实例均衡执行单次任务只选一台
随机简单分散压力不保证均匀
故障转移任务必须尽量找可用节点会多次探测执行器
分片广播大批量并行处理每个执行器都会执行一次

大多数普通任务用轮询或故障转移;大批量任务用分片广播。

阻塞策略怎么选

阻塞策略解决的是:上一次任务还没执行完,下一次触发又来了怎么办。

阻塞策略含义适合
单机串行同一个执行器上排队执行不能并发的任务
丢弃后续调度上次没跑完,本次丢弃周期短、只要最新一轮即可
覆盖之前调度终止旧任务再跑新任务谨慎使用,业务必须能安全中断

订单关单、对账、结算这类任务通常不要覆盖旧任务,优先单机串行或丢弃后续,并把任务拆小。

失败重试和告警

失败重试适合临时问题:

  1. 网络抖动。
  2. 下游服务短暂不可用。
  3. 数据库连接瞬时失败。

不适合用重试掩盖代码 bug。如果参数错误、SQL 错误、空指针异常,重试只会制造更多失败日志。

建议:

  1. 设置合理重试次数,比如 1 到 3 次。
  2. 失败日志要包含业务关键参数。
  3. 连续失败必须告警到负责人。
  4. 对资金、库存、发券任务,失败后要进入人工可补偿流程。

任务参数设计

XXL-JOB 的任务参数是字符串。建议用 JSON:

json
{
  "batchSize": 500,
  "timeoutMinutes": 30,
  "dryRun": false
}

解析示例:

java
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 定时扫描同步失败记录。

java
@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 的核心价值是把分散在各个服务里的后台任务统一治理起来。

学习重点不是背配置项,而是理解:

  1. Admin 负责调度,Executor 负责执行业务。
  2. JobHandler 是业务任务入口。
  3. 多实例下要靠路由策略和分片控制执行。
  4. 任务必须幂等,不能把“不重复”完全寄托给调度框架。
  5. 生产任务一定要有日志、超时、重试、告警和人工补偿手段。