做后端开发的朋友,几乎都会遇到定时任务的场景。用户签到提醒、对账跑批、数据同步、订单超时关闭……这些业务里都藏着定时任务。很多人一开始图省事,直接在SpringBoot里用@Scheduled,本地跑得好好的,到了线上两台实例一部署,问题全来了——同一批数据被两个节点同时处理,轻则重复发通知,重则库存扣两次。这时候就需要一个统一的任务调度平台来兜底,xxl-job就是目前Java生态里用得最多的开源方案。
这篇文章我会把整个流程完整走一遍:从零搭建xxl-job服务端(调度中心),再写一个SpringBoot项目把它集成进来,创建一个真实可跑的定时任务。中间会重点讲清楚几个关键选择背后的原因——比如为什么调度中心要单独部署、执行器端口为什么不能乱填、路由策略和阻塞策略在不同场景下该怎么选。这些内容既是给第一次接触xxl-job的同学做入门参考,也是给已经在用的朋友一份排查手册。
1. 为什么要用xxl-job:先搞懂分布式任务调度解决的是什么问题
1.1 单体时代的定时任务,到底卡在哪
先聊聊大家最熟悉的@Scheduled。SpringBoot项目里写个定时任务确实简单,一个注解加一个方法就完事:
@Component public class SimpleTask { @Scheduled(cron = "0 0 2 * * ?") public void dailyReport() { // 每天凌晨2点生成报表 } }单机部署的时候,这套方案没有任何问题。但一旦业务量上来,服务需要横向扩展,变成两台、三台实例同时运行,@Scheduled的缺陷就暴露得很彻底:
- 每个实例都会执行一次定时任务,同一个报表被生成三份,还好只是浪费资源;如果是扣款、发券、推送这类操作,就是严重的生产事故。
- 任务没有统一的管理界面,谁改了Cron表达式、上次执行成没成功、这次跑了多久,全都不可见。
- 某台机器宕机了,它上面跑的任务没人接管。
- 想临时停掉一个任务、手动触发一次,都要改代码重新发版。
有人说那我用Quartz啊,Quartz确实支持集群部署,通过数据库锁来保证任务不重复执行。但Quartz的问题在于它只是个任务调度库,没有现成的管理界面,集群配置也偏重,对中小团队来说学习成本和维护成本都不低。
1.2 xxl-job的架构设计与核心优势
xxl-job是一个轻量级的分布式任务调度平台,它的设计思路很清晰:把"调度"和"执行"拆成两个独立的角色。
- 调度中心(xxl-job-admin):独立部署的Web应用,负责任务管理、调度触发、日志查看、执行器管理。它本身不执行业务代码。
- 执行器(Executor):嵌入在业务项目里,负责接收调度中心的指令,真正执行业务逻辑。
- 任务(Job):在调度中心配置,指定用哪个执行器、调哪个Handler、按什么频率触发。
三者关系可以理解为:调度中心是大脑,执行器是手脚,任务是大脑给手脚下的指令。调度中心通过HTTP接口和执行器通信,执行器启动后会主动注册到调度中心,把自己标记为"存活"状态。
这套架构带来的直接好处是显而易见的:
- 任务和业务代码分离,改调度配置不需要动业务服务。
- 天然支持集群,同一个执行器部署多个实例,调度中心通过路由策略决定把任务派给哪个实例,或者用分片广播让所有实例各自处理一部分数据。
- 自带管理界面,任务的启停、触发、日志查看、执行报表全都有。
2. 前面准备:安装环境与版本选型
2.1 环境清单
搭建之前先确认一下需要的东西:
- JDK 8 及以上(推荐JDK 8或者JDK 11,不要图新鲜直接上JDK 17,除非你确认用的版本兼容)
- Maven 3.6+
- MySQL 5.7 及以上(MySQL 8.0亲测可用,但要注意驱动和时区配置)
- 一个Linux服务器或者本机环境都行,只要能跑Java进程
版本方面,目前生产环境用得比较多的是2.3.1和2.4.0。2.4.0相对较新,功能完整,文档也全。如果你用的是SpringBoot 2.x,建议选2.4.0;如果项目还在用SpringBoot 1.5.x这种老版本,那选2.3.1更稳妥。我下面以2.4.0为例。
2.2 从哪拿源码和发行包
xxl-job的源码托管在GitHub上,搜索xuxueli/xxl-job就能找到。直接下载对应release版本的源码包,解压后目录结构大概是这样的:
xxl-job-master ├── doc │ └── db │ └── tables_xxl_job.sql # 建库建表脚本 ├── xxl-job-admin # 调度中心,SpringBoot项目 ├── xxl-job-core # 公共依赖,集成时需要引入这个包 └── xxl-job-executor-samples # 官方示例执行器核心的其实就三个部分:调度中心、核心包、示例执行器。
3. 一步步搭建xxl-job调度中心
3.1 初始化数据库
先把数据库建好。手动建一个名为xxl_job的库,字符集用utf8mb4:
CREATE DATABASE `xxl_job` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;然后把你下载的源码包里doc/db/tables_xxl_job.sql这个脚本导入进去。脚本会自动创建8张表,每张都有用:
xxl_job_info:任务信息表,记录每个任务的调度配置。xxl_job_log:调度日志表,每次任务触发都会写一条记录。xxl_job_registry:执行器注册表,执行器启动后在这里登记。xxl_job_group:执行器分组表。xxl_job_lock:调度锁,保证集群环境下调度逻辑不冲突。- 其他几张是用户表、报表表、GLUE代码表。
导入完成后可以执行SHOW TABLES;看一眼,表都齐了才继续往下走,避免后面调度中心启动了一堆报错找不到表。
3.2 修改调度中心配置文件
用IDEA打开xxl-job-admin这个模块,核心配置文件在src/main/resources/application.properties。要改的地方就三个:
# 调度中心端口,默认8080,线上建议换掉 server.port=8080 # 数据库连接,注意时区配置 spring.datasource.url=jdbc:mysql://127.0.0.1:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai spring.datasource.username=root spring.datasource.password=123456 # 调度中心和执行器之间的通信令牌 xxl.job.accessToken=default_token重点说两个容易被忽略的地方。第一是时区,serverTimezone必须明确指定,否则MySQL 8.0连接会报时区错误;第二是accessToken,这个相当于调度中心和执行器之间的暗号,两边不一致的时候任务会执行失败,它默认是default_token,生产环境一定要改掉,改成一段足够复杂的随机字符串。
3.3 编译、打包、启动
直接在xxl-job根目录下执行Maven打包命令:
mvn clean package -DskipTests打包完成后在xxl-job-admin/target/目录下会生成一个xxl-job-admin-2.4.0.jar,这就是调度中心的完整可执行包。启动方式有两种:
# 方式一:直接Java命令启动 java -jar xxl-job-admin-2.4.0.jar # 方式二:Linux后台运行,日志输出到文件 nohup java -jar xxl-job-admin-2.4.0.jar --server.port=8080 > logs/admin.log 2>&1 &启动过程中重点看日志里有没有Started XxlJobAdminApplication这行字,看到就说明启动成功了。浏览器访问http://localhost:8080/xxl-job-admin,会跳到登录页,默认账号是admin/admin123(2.4.0默认密码是admin123,不是网上有些教程写的123456,登录后建议立即修改)。
注意:2.4.0版本密码安全策略加强了,如果你用的是2.3.x版本,默认密码才是
123456。密码错误时别慌,先确认版本。
登录成功后会看到调度中心的主界面,左侧菜单包括执行器管理、任务管理、调度日志、用户管理等。到这里,调度中心已经搭建完毕,接下来就是把业务项目变成执行器接进来。
4. SpringBoot项目集成xxl-job:完整实操示例
4.1 引入依赖
在执行器项目(也就是你的业务SpringBoot项目)的pom.xml中加入xxl-job核心依赖:
<dependency> <groupId>com.xuxueli</groupId> <artifactId>xxl-job-core</artifactId> <version>2.4.0</version> </dependency>注意版本号必须和调度中心保持一致。如果你调度中心用的2.3.1,这里写的2.4.0,可能会因为接口不兼容导致注册失败。
4.2 配置application.yml
在application.yml里新增xxl-job相关配置:
xxl: job: admin: # 调度中心地址,注意后面要带上下文路径 addresses: http://127.0.0.1:8080/xxl-job-admin accessToken: default_token executor: # 执行器名称,和调度中心配置执行器时保持一致 appname: xxl-job-executor-demo # 执行器注册地址,一般留空,自动获取本机IP address: # 执行器IP,多网卡环境下需要手动指定 ip: # 执行器端口,不要和项目主端口重复 port: 9999 # 日志保存路径 logpath: /data/applogs/xxl-job/jobhandler # 日志保存天数,30天合理 logretentiondays: 30这里有几个配置项要特别说明。
executor.port:这是执行器和调度中心通信的HTTP端口,默认9999。两个容易踩坑的地方:一是这个端口不能和SpringBoot主端口(比如8080)重复,否则启动直接报端口冲突;二是端口一旦定了,后续改动需要重启执行器并且清理调度中心注册表里残留的旧地址,不然调度中心可能还在往旧的端口发请求。
executor.address和executor.ip:正常情况下一个项目绑定一个IP,这两个配置不需要人工干预。但如果你的服务器有多个网卡(比如有内网IP也有外网IP),执行器自动注册时可能注册错IP,导致调度中心连不上执行器。这时候就必须手动指定ip为内网通信IP。
4.3 编写XxlJobConfig配置类
在SpringBoot的配置包下创建一个配置类,用来生成执行器的Spring Bean:
package com.example.demo.config; import com.xxl.job.core.executor.impl.XxlJobSpringExecutor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class XxlJobConfig { private Logger logger = LoggerFactory.getLogger(XxlJobConfig.class); @Value("${xxl.job.admin.addresses}") private String adminAddresses; @Value("${xxl.job.accessToken}") private String accessToken; @Value("${xxl.job.executor.appname}") private String appname; @Value("${xxl.job.executor.address}") private String address; @Value("${xxl.job.executor.ip}") private String ip; @Value("${xxl.job.executor.port}") private int port; @Value("${xxl.job.executor.logpath}") private String logPath; @Value("${xxl.job.executor.logretentiondays}") private int logRetentionDays; @Bean public XxlJobSpringExecutor xxlJobExecutor() { logger.info(">>>>>>>>>>> xxl-job config init."); XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor(); xxlJobSpringExecutor.setAdminAddresses(adminAddresses); xxlJobSpringExecutor.setAppname(appname); xxlJobSpringExecutor.setAddress(address); xxlJobSpringExecutor.setIp(ip); xxlJobSpringExecutor.setPort(port); xxlJobSpringExecutor.setAccessToken(accessToken); xxlJobSpringExecutor.setLogPath(logPath); xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays); return xxlJobSpringExecutor; } }这个类的作用就是初始化一个XxlJobSpringExecutor,它启动后会做两件事:一是把自己的信息注册到调度中心;二是开启一个HTTP服务监听executor.port端口,等待调度中心派发任务。
4.4 编写第一个JobHandler
配置类搞定后,写一个真正的任务Handler。创建一个组件类,定义定时任务方法时用@XxlJob注解,注解里的值是任务的唯一标识,调度中心配置任务时也要用这个标识:
package com.example.demo.job; import com.xxl.job.core.handler.annotation.XxlJob; import com.xxl.job.core.context.XxlJobHelper; import org.springframework.stereotype.Component; @Component public class OrderJobHandler { /** * 超时订单关闭任务 */ @XxlJob("orderTimeoutCloseHandler") public void orderTimeoutCloseHandler() { // 打印日志,会显示在调度中心的调度日志里 XxlJobHelper.log("开始执行超时订单关闭任务..."); // 模拟业务处理:关闭超过30分钟未支付的订单 int count = 0; try { // 这里替换成你自己的业务代码 // List<Order> timeoutOrders = orderService.listTimeoutOrders(30); // for (Order order : timeoutOrders) { // orderService.closeOrder(order.getId()); // count++; // } count = 10; Thread.sleep(2000); } catch (Exception e) { XxlJobHelper.log("任务执行异常:{}", e.getMessage()); // 必须通过这个方法抛出执行失败标记,调度中心才能记录失败状态 XxlJobHelper.handleFail("任务执行异常:" + e.getMessage()); return; } XxlJobHelper.log("本次任务共处理 {} 笔超时订单", count); } }这里有个细节值得强调:在Handler里千万不要用System.out.println打日志,要用XxlJobHelper.log。前者的日志不会同步到调度中心,你在后台看不到任何输出,出了问题连排查入口都没有。后者会把日志同步到调度中心的在线日志里,点开任务一看就能定位。
另外一个坑是异常处理。任务方法跑出异常,如果只写try-catch吞掉,调度中心会认为任务执行成功了。正确的做法是catch住异常后,调用XxlJobHelper.handleFail()显式标记失败。
4.5 在调度中心注册执行器并配置任务
执行器项目启动成功后,打开调度中心管理界面,按下面步骤操作。
第一步:添加执行器。进入"执行器管理"页面,点击"新增执行器",输入AppName(必须和application.yml里的xxl.job.executor.appname完全一致)和名称,注册方式选"自动注册"。保存后回到列表,过几秒刷新,如果看到机器地址列出现了http://127.0.0.1:9999/这样的地址,说明执行器已经注册成功。
如果列表里一直没出现机器地址,别急着怀疑代码,99%是appname大小写或空格不一致,或者执行器项目压根没启动成功。
第二步:新增任务。进入"任务管理"页面,点击"新增任务",配置项有这么几个需要重点注意:
- 执行器:选择刚创建的
xxl-job-executor-demo。 - 任务描述:写清楚这个任务是干嘛的。
- 调度类型:选"CRON",填入
0 0 2 * * ?(每天凌晨2点执行)之类的表达式。 - 运行模式:选"BEAN",JobHandler填
orderTimeoutCloseHandler,必须和@XxlJob注解里的值一致。 - 路由策略:单机部署选"第一个",集群部署根据场景选(后面详细说)。
- 阻塞处理策略:选"单机串行"(这个后面也详细说)。
保存后任务默认是"停止"状态,需要点一下"启动",才能被调度触发。
第三步:手动触发验证。在任务列表右侧找到"执行一次"按钮,点击后立刻去"调度日志"页面看结果。日志里能看到任务状态是"成功"还是"失败"。
如果点击"执行一次"后,日志显示调度成功,但状态一直停在"运行中"然后超时,多半是调度中心连不上执行器,或者JobHandler名称对不上。
5. 核心机制详解:运行模式、路由策略与调度逻辑
5.1 运行模式:BEAN和GLUE怎么选
xxl-job的任务运行模式分成好几种,实际用得最多的就两种。
BEAN模式:任务逻辑写在你的SpringBoot项目里,用@XxlJob注解标注。优点是不用再写代码就能改任务配置,因为任务逻辑和业务代码在一起,调试方便,IDE里直接打断点。缺点是每次改任务逻辑都要重新发版。日常业务型任务(订单状态变更、对账、报表生成)99%用这种就够了。
GLUE模式:任务逻辑以源码形式托管在调度中心。你可以在页面上直接编辑Java代码,保存后调度中心会动态编译并推送给执行器执行。好处是改逻辑不用发版,适合那种经常调整、试错性质的任务。缺点是要维护两份代码,逻辑复杂一些的项目在线编辑体验很差。我个人的建议是,默认都用BEAN模式,只有那种"今天改明天调"的临时任务才用GLUE模式。
5.2 路由策略:集群部署时的任务派发规则
这块很多人拿不准该选哪个。先把调度中心提供的9种策略过一遍:
| 策略 | 含义 | 适用场景 |
|---|---|---|
| 第一个 | 固定把任务派给注册列表里的第一台机器 | 单机部署,或者指定某台机器跑 |
| 最后一个 | 固定派给最后一台 | 同左,用得少 |
| 轮询 | 按顺序轮流派发 | 各机器负载均衡、任务无状态是最好的默认选择 |
| 随机 | 随机挑一台 | 和轮询类似,但分配不均 |
| 一致性HASH | 按任务参数哈希,同一个任务固定落到同一台机器 | 任务依赖某些本地缓存数据时 |
| 最不经常使用 | 选调用次数最少的机器 | 需要均衡频率时 |
| 最近最久未使用 | 选最久没被调用的机器 | 理论上均衡,实际用得少 |
| 故障转移 | 检查各机器健康状态,派给第一个正常的 | 某台机器挂了自动跳过 |
| 忙碌转移 | 检查各机器是否繁忙,派给最闲的 | 任务耗时差异大时防堆积 |
| 分片广播 | 所有机器同时执行,通过分片参数区分彼此 | 数据量大的批量处理场景,最常见 |
聊一个实际案例。我有一次做了一个批量结算任务,每天夜里2点给全量用户算收益。最开始用的轮询,一个节点处理全部用户,200万用户跑了将近50分钟,眼看着离数据库连接超时时间越来越近。后来改成分片广播,部署了4个执行器节点,每个节点根据分片参数只处理四分之一的数据,20分钟跑完,时间直接砍半。
分片广播的写法大致长这样:
@XxlJob("shardingBatchHandler") public void shardingBatchHandler() { // 获取分片参数 int shardIndex = XxlJobHelper.getShardIndex(); int shardTotal = XxlJobHelper.getShardTotal(); // 只处理属于自己的那批数据 List<Long> userIds = userService.listUserIdsByMod(shardIndex, shardTotal); for (Long userId : userIds) { // 业务处理 } }核心逻辑是:userid % shardTotal == shardIndex的数据才归当前节点处理,这样每个节点处理的数据不重复、不遗漏。
5.3 阻塞处理策略:任务撞车了怎么办
如果上一个任务还没跑完,下一个任务又到了触发时间,就该阻塞策略上场了。三种策略的含义:
- 单机串行:排队处理,上个任务跑完才执行下一个。适合每个任务耗时短、但偶发重合的场景,不容易漏数据。
- 丢弃后续调度:如果上一次还没跑完,这次直接跳过。适合任务耗时长、允许错过一次触发的场景,比如全量报表。
- 覆盖之前调度:终止上一次还没跑完的任务,重新执行这次。使用要很小心,如果任务里有事务操作,强制终止容易造成数据状态不一致。
最稳妥的配置是单机串行。大多数任务逻辑本身是幂等的,顶多排队晚一点执行,但至少不会丢。如果你确认任务耗时高于执行频率,再用"丢弃后续调度"避免任务堆积。
5.4 任务日志与回调机制
每次任务被调度后,执行器会回调给调度中心一个结果,记录在xxl_job_log表里。任务日志页面能看到每个任务的触发时间、执行耗时、执行结果、执行机器。点开"操作"里的"执行日志",能跳到执行器上查看在线日志,日志文件就存在前面配置的logpath目录下。
注意一点:调度日志里的状态是异步回调更新的,任务执行完到状态刷新会有几秒延迟,这是正常现象。
6. 高频踩坑实录:我遇到过的那些问题
6.1 执行器注册不上,调度日志显示"执行失败或地址为空"
这个是最多见的。排查顺序从简单到复杂:
- 先确认执行器项目启动日志里有没有报错,尤其是启动时有个
xxl-job registry success类似的记录。 - 查调度中心的执行器管理页面,看AppName是否和配置完全一致。
- 在调度中心所在的服务器上,
telnet 执行器IP 9999测试端口通不通。通了说明网络OK;不通就查防火墙和云安全组。 - 如果端口通了还是不行,看调度中心的
logs/xxl-job-admin.log文件,搜索执行器IP,看报错信息是超时还是拒绝连接。
6.2 任务执行失败,日志报401或403
基本都是accessToken不一致导致的。调度中心的application.properties里配置了一个token,执行器的application.yml里也有一个,两个对不上就会鉴权失败。
这个坑的隐蔽之处在于:如果你的项目是从老版本升级上来的,调度中心已经配置了新的token,但执行器代码是从Git上拉的新分支,配置没同步过去。改完配置记得执行器要重启,这个不会热加载。
6.3 执行器端口和业务端口冲突
很多初学者的SpringBoot项目主端口配的是8080,然后照抄网上的教程把执行器端口也配成8080或8081,结果启动直接报错。可以按这个规律分配:业务端口8080,执行器端口9999,两个端口职责不同,必须分开。
6.4 任务执行成功但没有任何业务日志
99%是Handler里用了System.out.println而没用XxlJobHelper.log。在线日志展示的是XxlJobHelper.log写入的内容,你打的控制台输出在调度中心是看不见的。
6.5 修改Cron表达式后,任务没按新时间执行
检查调度中心的任务是否在"启动"状态。修改配置后,任务如果处于"停止"状态,你改的表达式不会生效。改完确认一下任务状态是"运行中"。另外,Cron表达式用的时区是调度中心服务器的时区,如果你的服务器时区不是东八区,执行时间会偏移。
6.6 集群环境下任务重复执行
这个要区分两种情况。如果任务日志显示同一个调度ID只执行了一次,但业务上看到了重复数据,那问题多半出在你的业务代码本身不是幂等的;如果日志里明确是多次调度执行,那要检查是不是多个执行器项目用了相同的appname和端口,导致调度中心把A项目的任务派给了B项目。多个环境共用同一个调度中心时,一定要用不同的appname做隔离。
6.7 任务跑完显示成功,但业务逻辑实际没生效
一种常见的原因是业务代码里自己catch了所有异常,把失败当成成功吞掉了。上面也提到了,正确做法是catch异常后在Handler里调用XxlJobHelper.handleFail("异常原因"),把真实的失败信息标记出来。
7. 生产环境的配置建议与扩展思路
7.1 几个值得调整的默认配置
如果你在玩demo,默认配置就够用。但真正上生产前,有几个地方建议改一下:
- accessToken:必须改,这是执行器鉴权的唯一凭证,用足够复杂的随机字符串。
- 调度中心端口:别用默认8080,换一个不那么明显的端口。
- 数据库连接池参数:调度中心默认的连接池配置偏小,如果任务量很大(每秒几百次调度),在
application.properties里适当调大连接池。 - 日志保留天数:默认30天,如果机器磁盘紧张可以改小,毕竟日志文件挺占空间。
7.2 完整的多环境配置方案
项目里有dev、test、prod三套环境时,不要每个环境都用同一套xxl-job配置。规范的玩法是:
- 每个环境部署独立的调度中心,数据源也是独立的数据库。
- 配置信息放Nacos或者Spring Cloud Config里,按环境区分。
- appname带上环境后缀,比如
order-job-dev、order-job-prod,这样即使误配了地址也能通过日志快速定位。
7.3 后续还能怎么扩展
xxl-job本身不提供任务编排功能,如果你的需求是"A任务跑完才能跑B任务"这种依赖关系,原生配置是做不到的,后续可以从下面几个方向做扩展:
- 用分片广播+数据库中间表实现任务编排的状态流转。
- 结合Canal监听Binlog,实现事件驱动的动态任务触发。
- 把调度中心部署成集群模式,配合Nginx做负载均衡,解决调度中心单点问题。
整个流程走下来,你会发现xxl-job的设计确实是为生产环境量身定做的,它把任务调度这个工程问题拆得很细,调度和执行分离,导致扩展和排查都有章可循。我个人最大的体会是:用xxl-job不是图它功能多,而是图它出了问题之后能快速找到答案。日志有地方看,注册状态有地方确认,每次调度都有记录,对值班排查来说省了太多时间。
最后分享一个小技巧:给任务命名的时候,强烈建议用业务模块_动作_场景这种格式,比如order_timeout_close、report_daily_generate,别用task1、task2这种。任务多了以后,搜索和管理会轻松很多,时间久了你就知道这个习惯有多重要。