Spring Boot项目里定时任务做得多了,总会遇到一些让人头疼的瞬间。本地开发用@Scheduled注解跑得挺欢,一上线部署到多台实例就原形毕露——同一个任务在每个节点各执行一次,数据重复处理、库存重复扣减,运维半夜打电话问你为什么批量任务跑了好几遍。这时候就需要一个统一的调度平台来接管所有定时任务,xxl-job 就是目前 Java 生态里用得最广的分布式任务调度中间件之一。这篇文章我会从零开始,用 5 分钟左右带你把 xxl-job 跑起来,重点覆盖 Docker 部署调度中心、Spring Boot 接入执行器、任务配置与常见坑点排查,适合刚接触分布式任务调度、被重复执行问题折磨过的 Java 后端开发者。
1. 为什么不用 @Scheduled,非要上一套调度中心
先把最核心的问题聊透。很多刚接触微服务的同学会有疑问:Spring Boot 自带的@Scheduled明明开箱即用,为什么还要单独部署一套 xxl-job?这个问题的答案直接决定了你的项目该不该引入调度中心。
1.1 @Scheduled 的四个"原罪"
我在实际项目中踩过的坑可以总结为四点。第一,无法统一管理。几十个定时任务分散在各个微服务里,想看某个任务上次执行时间、执行结果、失败日志,得翻遍所有服务的日志文件,排查问题效率极低。第二,不能动态调整。@Scheduled的 cron 表达式在代码里写死,每次修改执行频率都要重新打包、重新发布,这在生产环境是不可接受的。第三,多实例重复执行。一旦服务部署了多个副本,同一个任务会被执行多次,虽然可以用分布式锁兜底,但锁的代码写起来麻烦,还容易踩坑。第四,没有失败告警。任务跑了多久、是否超时、是否失败,全部没有感知,全靠人工巡检,出了问题往往已经是几小时后的事了。
1.2 xxl-job 的核心设计理念
xxl-job 解决上述问题的思路很清晰:把任务调度和业务执行彻底拆开。调度中心(xxl-job-admin)负责任务的注册、调度、日志记录和告警,执行器(Executor)嵌入你的业务服务,接收调度指令并执行任务代码。两者通过 HTTP 接口通信,天然支持分布式部署,任务在多个执行器之间通过路由策略分发,不会重复执行。
这个设计和人的工作方式很像。调度中心像老板,只负责安排活、检查活、记录考勤;执行器像员工,只负责接到指令后把手里的活干完。老板不用关心员工具体怎么写代码,员工也不用操心活从哪来,各司其职,整体效率最高。我用下来最大的感受是,引入 xxl-job 后,排查定时任务问题的耗时至少缩短了 70%,所有执行记录、日志、堆栈都能在调度中心的可视化界面里查到,再也不用满服务器翻日志了。
2. Docker 快速部署 xxl-job 调度中心
标题里写了保姆级教程,这一节我会把每一步操作都写清楚,包括我当初部署时踩过的坑。前置条件是你的机器已经安装好了 Docker 和 Docker Compose,Windows 用 Docker Desktop,Linux 直接用 Docker Engine 即可。
2.1 初始化数据库
xxl-job 调度中心需要 MySQL 存储任务配置、调度日志等元数据。官方源码里自带建表脚本,在tables_xxl_job.sql文件中。你可以从 GitHub 仓库下载,路径是xxl-job/doc/db/tables_xxl_job.sql,也可以直接用我下面的方式从已发布的 Docker 镜像里复制出来。
# 先启动一个临时容器把脚本复制出来 docker run --rm -v /tmp/xxl-job-sql:/tmp alpine:3.18 sh -c "apk add --no-cache wget >/dev/null 2>&1 && wget -O /tmp/tables_xxl_job.sql https://raw.githubusercontent.com/xuxueli/xxl-job/master/doc/db/tables_xxl_job.sql && ls /tmp"如果你的网络环境访问 GitHub 不稳定,更省事的做法是直接用一个带 MySQL 的 Docker Compose 编排,让 MySQL 容器首次启动时自动执行挂载的初始化脚本。无论哪种方式,最终目的都是得到一张名为xxl_job_qrtz_*的表结构(一共 8 张左右)。
我个人推荐直接拉取mysql:8.0镜像,新建数据库xxl_job,然后把脚本执行一遍。
注意:xxl-job 3.x 版本对 MySQL 8.0 的兼容性很好,但 MySQL 8.0 默认的认证插件是 caching_sha2_password,如果你的 xxl-job 版本较老(2.x),需要在连接串里显式指定 useSSL=false 和 allowPublicKeyRetrieval=true。
2.2 使用 Docker Compose 编排调度中心
数据库就绪后,开始部署调度中心。我习惯用 docker-compose 把 MySQL 和 xxl-job-admin 一起编排,这样以后迁移环境只需要一个文件搞定。下面是完整的docker-compose.yml:
version: '3.8' services: mysql: image: mysql:8.0 container_name: xxl-job-mysql environment: MYSQL_ROOT_PASSWORD: root123456 MYSQL_DATABASE: xxl_job TZ: Asia/Shanghai ports: - "3306:3306" volumes: - ./mysql-data:/var/lib/mysql - ./tables_xxl_job.sql:/docker-entrypoint-initdb.d/tables_xxl_job.sql command: --default-authentication-plugin=mysql_native_password healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot123456"] interval: 10s timeout: 5s retries: 5 xxl-job-admin: image: xuxueli/xxl-job-admin:2.4.1 container_name: xxl-job-admin environment: PARAMS: "--spring.datasource.url=jdbc:mysql://mysql:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai --spring.datasource.username=root --spring.datasource.password=root123456" ports: - "8080:8080" depends_on: mysql: condition: service_healthy volumes: - ./logs:/data/applogs这里面有几个关键的配置点值得展开说说。
- 镜像版本选择:我用的是
2.4.1,这是目前比较稳定的版本,对应的执行器依赖也是2.4.1,客户端和服务端版本尽量保持一致,避免出现协议不兼容的问题。 - PARAMS 环境变量:xxl-job-admin 的 Docker 镜像支持通过
PARAMS环境变量传入 Spring Boot 启动参数,数据库连接串里的serverTimezone必须要设置,否则会报时区错误。 - depends_on + healthcheck:如果不做健康检查,调度中心可能在 MySQL 尚未就绪时就启动,导致数据库连接失败。这样编排能保证 MySQL 先启动并初始化完成,调度中心再启动,一次成功,不用反复重启。
配置完成后,在 docker-compose.yml 所在目录执行docker compose up -d,等几十秒后访问http://localhost:8080/xxl-job-admin,默认账号admin,密码123456,能看到登录页就说明调度中心部署成功。
2.3 部署完成后需要立刻做的三件事
调度中心起来后,别急着写代码,先把下面三件事做了,后面能少踩很多坑。
第一,修改默认密码。admin/123456 是公开的默认口令,生产环境必须改掉,否则任何人登录你的调度中心都能操作任务。在用户管理里直接改密码即可。
第二,确认执行器端口规划。xxl-job 执行器默认使用9999端口和调度中心通信,如果服务器上有防火墙,记得把这个端口放通。如果你的服务实例很多,端口规划要提前想清楚,不能都用一个端口。
第三,看一下调度中心日志目录。Docker 方式部署的调度中心日志在容器里的/data/applogs,生产环境建议挂载到宿主机持久化,方便排查调度中心自身的问题。
3. Spring Boot 项目集成执行器
调度中心部署好了,接下来就是让我们的业务服务变成执行器。这一节以一个普通的 Spring Boot 2.7 项目为例,把集成步骤拆开了讲。
3.1 引入 Maven 依赖
在pom.xml里添加 xxl-job 的依赖:
<dependency> <groupId>com.xuxueli</groupId> <artifactId>xxl-job-core</artifactId> <version>2.4.1</version> </dependency>注意版本一定要和调度中心保持一致,我见过有人调度中心是 2.3.1,执行器用了 2.4.1,结果任务调度时频繁报协议解析错误。这个依赖只引入了客户端库和注册逻辑,不会和你的业务代码冲突,放心用。
3.2 配置执行器属性
在application.yml中新增如下配置:
xxl: job: admin: addresses: http://localhost:8080/xxl-job-admin accessToken: default_token executor: appname: order-service-executor address: ip: port: 9999 logpath: ./logs/xxl-job/jobhandler logretentiondays: 30逐项解释一下。
- admin.addresses:调度中心的完整地址,如果有多个调度中心(高可用部署),用英文逗号分隔。
- accessToken:调度中心和执行器之间的通信令牌,两边必须一致。如果调度中心配置了令牌,这里不填或填错,任务会调度失败,报 500 错误。
- executor.appname:执行器在调度中心里的名字,同一个服务的多个实例要共用同一个 appname,这样调度中心才能按路由策略分发任务。
- executor.port:执行器 HTTP 服务的端口,用于接收调度中心的任务请求。
- executor.logpath:任务执行日志的保存路径,调度中心展示的执行日志会从这里的文件里读取。
3.3 创建 XxlJobConfig 配置类
接下来需要把 XxlJobSpringExecutor 注入 Spring 容器,它负责扫描标注了@XxlJob的方法,并启动内嵌的 HTTP 服务。
@Configuration public class XxlJobConfig { @Value("${xxl.job.admin.addresses}") private String adminAddresses; @Value("${xxl.job.accessToken}") private String accessToken; @Value("${xxl.job.executor.appname}") private String appname; @Value("${xxl.job.executor.port}") private int port; @Value("${xxl.job.executor.logpath}") private String logPath; @Value("${xxl.job.executor.logretentiondays}") private int logRetentionDays; @Bean public XxlJobSpringExecutor xxlJobExecutor() { XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor(); xxlJobSpringExecutor.setAdminAddresses(adminAddresses); xxlJobSpringExecutor.setAccessToken(accessToken); xxlJobSpringExecutor.setAppname(appname); xxlJobSpringExecutor.setPort(port); xxlJobSpringExecutor.setLogPath(logPath); xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays); return xxlJobSpringExecutor; } }这段代码基本是官方文档的标准写法,唯一要提醒的是setPort的值不能和其他服务冲突,建议做成配置项而不是写死。
4. 任务开发与调度策略实战
执行器接入成功后,开始写真正的任务代码。xxl-job 支持两种任务开发方式:Bean 模式和 GLUE 模式,我重点讲平时用得最多的 Bean 模式。
4.1 一个标准的任务方法长什么样
@Component public class OrderTaskHandler { private static final Logger logger = LoggerFactory.getLogger(OrderTaskHandler.class); @XxlJob("cancelTimeoutOrderJob") public void cancelTimeoutOrderJob() { XxlJobHelper.log("开始处理超时未支付订单任务..."); int count = orderService.cancelTimeoutOrders(); XxlJobHelper.log("本次取消超时订单数量: {}", count); XxlJobHelper.handleSuccess("取消订单成功, 数量=" + count); } }几个关键点:
- 方法上标
@XxlJob("任务名称"),任务名称全局唯一,调度中心配置任务时通过这个名称匹配执行器里的方法。 - 方法必须放在 Spring 管理的 Bean 里,否则执行器扫描不到。
- 通过在方法里调用
XxlJobHelper.log打印日志,这些日志会从执行器传到调度中心,在调度中心的"调度日志"里就能看到,这是排查问题的关键手段。 - 任务执行完要调用
XxlJobHelper.handleSuccess或handleFail标记执行结果,否则调度中心会认为任务还在运行中,超时后触发告警。
4.2 在调度中心配置任务
任务代码写好后,登录调度中心,在"任务管理"里新增任务。填写要点如下:
- 执行器:选择刚才配置的
order-service-executor - 任务名称:填一个可读性好的名称,用于后台展示
- 调度类型:选 Cron,然后填表达式。我常用的是
0 0 2 * * ?(每天凌晨两点),注意 xxl-job 的 Cron 表达式不支持 6 位/年字段,和 Quartz 保持一致即可 - 运行模式:选 Bean,JobHandler 填
cancelTimeoutOrderJob - 路由策略:这里很关键,多实例部署时选"轮询"或"一致性 HASH",选"第一个"会导致所有任务都打到一个实例上
配置完成后,在操作列点"执行一次",立刻就能看到调度日志。正常情况下会显示"调度成功"和"执行成功"两条记录。
4.3 路由策略和阻塞处理策略怎么选
这两个参数是新手的重灾区,我根据自己的经验给出建议。
路由策略,单实例直接选"第一个"即可;多实例选"轮询",适合大部分幂等任务;如果任务依赖本地缓存或需要连续处理分片数据,选"一致性 HASH",同一个 key 的任务会固定打到同一个实例。
阻塞处理策略,任务执行时间较长且并发时会造成数据混乱,选"单机串行";任务必须实时执行且不能排队,选"丢弃后续调度";任务对时间不敏感、失败也不影响主流程,选"覆盖之前调度"。我默认推荐"单机串行",这是安全性最高的方式。
5. 常见问题与排查技巧实录
这部分我记录了自己实际部署和运维中遇到的几个高频问题,每个都附上了排查思路和解决方案。
5.1 调度成功但执行失败,日志却说找不到 JobHandler
这个问题出现频率极高。调度中心的"调度日志"显示调度成功,但点进去发现执行器报错xxl-job jobhandler not found。原因基本就三个:执行器 AppName 填错了、@XxlJob注解的值和调度中心配置的 JobHandler 不一致、执行器的 Spring 容器没扫到你的任务类。
排查技巧是打开执行器的启动日志,看是否有xxl-job register jobhandler success, name:cancelTimeoutOrderJob这行输出。没有的话,检查 ComponentScan 是否覆盖到了任务类所在的包。
5.2 执行器连不上调度中心
表现是调度中心显示执行器离线,或者执行时一直报连接超时。先确认端口,9999端口是否被防火墙拦截;再确认网络,如果用 Docker 部署调度中心,执行器在宿主机时,admin.addresses不能写localhost,要写调度中心容器的映射端口对应的宿主机 IP;最后确认 accessToken 是否一致。
5.3 任务重复执行的问题
有些场景下任务明明只配置了一个,却在一分钟内执行了多次。这种情况先看调度中心的任务配置,是不是启动了两个调度中心实例连了同一个数据库;再看执行器,是不是同一个 appname 注册了多个实例,而路由策略选了"第一个"以外的策略,导致多个实例同时拿到任务;最后看代码里有没有手动调用XxlJobHelper的 trigger 方法。
5.4 Cron 表达式明明是对的,就是不触发
建议先在调度中心用"执行一次"测试,确认任务没问题后再排查 Cron 表达式。xxl-job 里的 Cron 表达式精度只到秒,不支持年字段,0 0 2 * * ?和0 0 2 1/1 * ? *是合法的,但把年字段写上了(7 位)会直接报错。另外,时区问题也会导致不触发,检查执行器的serverTimezone是否设置了 Asia/Shanghai。
最后再分享一个小技巧。执行器的日志路径最好挂载到独立的存储上,并配合 logback 做日志切割。我遇到过只打印了XxlJobHelper.log的前半段、后半段无故丢失的情况,排查了很久发现是磁盘空间满了,日志写不进去。定时任务这种功能,平时没人注意,一出问题就是大半夜的线上事故,提前把监控和日志做好,能给自己省很多事。
目前这套 xxl-job 方案我已经用在了订单超时关闭、对账文件生成、数据同步等多个业务场景里,整体稳定性很可靠。你如果在集成的过程中遇到其他问题,欢迎按照文中的排查思路一步步定位,大概率能在自己的日志里找到答案。