1. 先说清楚:为什么我不建议你再手动跑定时任务
做后端开发的人,十有八九都经历过这种场景:数据库里有一批业务数据需要每天凌晨定时同步,报表要每半小时刷新一次,缓存要定期清理,或者凌晨要给一批用户推送通知。早期的项目里,这些需求最常见的实现方式就是Spring自带的@Scheduled注解,或者直接在服务器上挂一个Cron脚本,更原始一点的,干脆靠人定闹钟手动跑。
@Scheduled注解确实简单,写个方法加个注解就完事。但随着项目拆分、服务增多,问题就来了:定时任务散落在各个服务里,没有统一的管理界面,改一次执行时间要重新部署,任务挂了你不知道,日志还得去服务器上翻。最头疼的是,如果你做了多实例部署,同一个任务在每台机器上都会执行一遍,数据同步这种任务一旦重复执行,结果就是数据错乱。我见过不止一个项目因为这种重复执行把库存表刷出负数的。
xxl-job解决的就是这一整类问题。它是一个轻量级的分布式任务调度平台,核心思路是调度逻辑和执行逻辑分离:调度中心负责管理所有任务、配置执行时间、触发执行、记录日志;执行器是嵌在业务项目里的一个模块,负责接活儿、干活、上报结果。中间通过网络通信完成任务的派发,天然支持多实例、集群环境,同一个任务可以指定由哪台机器执行,也可以用轮询、分片广播这类策略路由到多个执行器上。
这篇教程我按实操来写,目标是让你在5分钟内把xxl-job跑起来,并且跑通"调度中心配任务,业务项目执行任务"的完整链路。我会用Docker部署调度中心,用Spring Boot写一个执行器示例,任务场景选的是最常见的"数据更新同步"——这也是xxl-job用得最多的场景。适合刚接触分布式任务调度、想在项目里快速落地的同学,也适合已经把@Scheduled写出痛点、想迁移到统一调度平台的团队参考。
2. 环境准备:用Docker部署xxl-job调度中心
很多教程让你先去官网下载源码、自己打包、改配置文件再跑起来,这步其实劝退了很多人。xxl-job提供了现成的Docker镜像,只要你的机器上有Docker,调度中心5分钟就能起好。
2.1 先准备好MySQL和初始表
调度中心本身不存业务数据,但它需要保存任务配置、调度日志、执行器注册信息这些元数据,所以必须有一份数据库。这里有个关键点:xxl-job的调度中心不会自动建表,你必须手动执行官方提供的SQL脚本。
我用的版本是2.3.0,这个版本非常稳定,网上资料也最多。别一上来就追最新版,除非你有特殊需求。对应版本的SQL脚本可以从Gitee上找到,文件名叫tables_xxl_job.sql,在/doc/db/目录下。你先在MySQL里建一个数据库,然后把这个脚本导进去:
mysql -u root -p -e "CREATE DATABASE xxl_job DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" mysql -u root -p xxl_job < tables_xxl_job.sql导入成功后,至少能看到下面这些表:
| 表名 | 用途 |
|---|---|
| xxl_job_info | 任务配置信息 |
| xxl_job_log | 调度日志 |
| xxl_job_registry | 执行器注册信息 |
| xxl_job_group | 执行器分组 |
| xxl_job_user | 登录用户 |
如果导入时报错了,90%是SQL文件编码问题,用utf8mb4重建数据库再试一次。还有一点要注意:MySQL版本建议5.7或8.0,太老的版本字符集支持不完善,太新的版本某些驱动不兼容也会有奇怪的报错。
2.2 拉镜像并启动调度中心
建好库之后,调度中心的启动就简单了。我用的是Docker官方镜像xuxueli/xxl-job-admin,版本标签跟项目版本保持一致,所以这里拉2.3.0:
docker run \ -p 8080:8080 \ -e PARAMS="--spring.datasource.url=jdbc:mysql://127.0.0.1:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai --spring.datasource.username=root --spring.datasource.password=123456" \ -v /data/xxl-job/applogs:/data/applogs \ --name xxl-job-admin \ -d xuxueli/xxl-job-admin:2.3.0这段命令我要拆开讲一下,很多人就是在这里踩坑的。
首先是-e PARAMS。这个镜像里的启动脚本会把PARAMS的内容拼到Java启动命令后面,所以你可以在这里覆盖Spring Boot的配置。数据库连接串里的127.0.0.1指的不是容器内部的地址,而是宿主机的地址。如果你跟我一样是MySQL跑在宿主机上、Docker跑在同一个机器上,这么写没问题。但如果你是Mac或者Windows上用Docker Desktop,容器访问宿主机要用host.docker.internal这个特殊域名,写成jdbc:mysql://host.docker.internal:3306/xxl_job。
然后是-v /data/xxl-job/applogs:/data/applogs。这行把容器里的日志目录挂载到宿主机上,方便你直接查看调度中心自己的运行日志。不加也能启动,但出了问题排查起来麻烦,建议加上。
启动之后,用docker logs -f xxl-job-admin盯一下日志,看到类似这样的输出就说明启动成功了:
Started XxlJobAdminApplication in 3.87 seconds然后浏览器访问http://localhost:8080/xxl-job-admin,默认账号密码是admin/123456,登录进去就能看到调度中心的管理界面。
2.3 登录后要做的三件事
调度中心能登录只是第一步,我建议你先花一分钟做三件事,能省下后面一堆麻烦。
第一件事是改密码。默认账号密码谁都知道,如果你的调度中心端口暴露在外网,就等着别人来帮你执行任务吧。虽然大多数场景下调度中心只在内网跑,但养成习惯没坏处。
第二件事是确认"系统管理-执行器管理"页面是全空的。新装好的调度中心还没有任何执行器,后面你会看到自己注册上来的。
第三件事是回到docker run那句命令,确认你设置的accessToken。默认情况下xxl-job的执行器和调度中心之间是不做鉴权的,只要对方知道地址就能注册。如果你生产环境要走公网,或者公司有安全要求,可以在启动命令里加上--xxl.job.accessToken=你的自定义Token,然后后面Spring Boot集成时配同一个值。不改也行,本地开发完全够用。
3. Spring Boot项目集成执行器
调度中心只是"大脑",真正的活儿得由嵌入在业务服务里的执行器来干。这一步我带你从头建一个Spring Boot项目,一步步把它变成xxl-job的执行器。
3.1 引入xxl-job-core依赖并配置参数
先在你的pom.xml里加入xxl-job的核心依赖:
<dependency> <groupId>com.xuxueli</groupId> <artifactId>xxl-job-core</artifactId> <version>2.3.0</version> </dependency>然后往application.yml里加配置。为了让配置项清晰,我惯用的写法是这样:
xxl: job: admin: addresses: http://localhost:8080/xxl-job-admin accessToken: default_token executor: appname: xxx-data-sync address: ip: port: 9999 logpath: /data/applogs/xxl-job/jobhandler logretentiondays: 30逐个解释一下这些配置的作用,这部分是理解执行器工作原理的关键:
admin.addresses:调度中心的访问地址。如果有多个调度中心做集群,用逗号分隔写多个地址。accessToken:执行器和调度中心通信的凭证,两边必须一致。executor.appname:执行器的应用名,也叫AppName。这个非常重要,后面在调度中心注册执行器时靠的就是这个名字。executor.port:执行器自己暴露的HTTP端口,调度中心通过这个端口把任务JobHandler的请求发过来。注意别跟项目里其他端口冲突。executor.ip:默认留空就行,执行器会自动探测本机IP注册到调度中心。只有当自动探测出错时才需要手动指定。executor.logpath:任务执行日志的存放路径,调度中心界面上查到的日志其实是从这个路径读出来的。executor.logretentiondays:任务日志保留天数,避免日志无限堆积。
3.2 编写执行器配置类
配置类很简单,就是把上面的参数组装成一个XxlJobSpringExecutor的Bean,交给Spring容器管理:
@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.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() { XxlJobSpringExecutor executor = new XxlJobSpringExecutor(); executor.setAdminAddresses(adminAddresses); executor.setAppname(appname); executor.setAddress(address); executor.setIp(ip); executor.setPort(port); executor.setAccessToken(accessToken); executor.setLogPath(logPath); executor.setLogRetentionDays(logRetentionDays); return executor; } }这段代码基本是固定的,核心逻辑就一句话:XxlJobSpringExecutor启动时会自动向调度中心注册当前执行器,然后把项目里所有标注了@XxlJob注解的方法扫描进本地的映射表。调度中心下发任务时,它根据任务配置的JobHandler名称,在本地找到对应的方法并反射调用。
3.3 用@XxlJob注解写一个数据同步任务
既然示例场景是"数据更新同步",我就写一个实际工作中最常见的任务:从外部接口或者临时表拉取变更数据,更新到业务表里。为了演示方便,我用一个简单的打印逻辑代替真实业务,你只需要关注@XxlJob注解的用法:
@Component public class DataSyncJob { private static final Logger logger = LoggerFactory.getLogger(DataSyncJob.class); @XxlJob("syncDataJobHandler") public void syncDataJobHandler() throws Exception { XxlJobHelper.log("数据更新同步任务开始执行..."); // 这里写你真实的同步逻辑 // 比如从a表查出增量数据,写入b表 List<Long> ids = queryChangedIds(); int count = 0; for (Long id : ids) { boolean success = doSync(id); if (success) { count++; } } XxlJobHelper.log("本次同步完成,共同步 {} 条数据", count); // 如果同步结果不满足预期,可以通过 XxlJobHelper 返回执行失败 // if (count != expectCount) { // XxlJobHelper.handleFail("同步数量不一致"); // return; // } XxlJobHelper.handleSuccess("同步成功"); } }这里有几个细节值得说道说道。
第一,@XxlJob注解里的字符串就是JobHandler名称,这个名字要全局唯一,就是你给这个任务起的"方法名"。后面在调度中心创建任务时,要靠这个名字来指定执行哪个JobHandler。
第二,尽量用XxlJobHelper.log()打印日志,而不是直接用logger.info()。原因是XxlJobHelper.log()的日志会跟随当前任务存入调度中心,你在管理界面能看到。而logger.info()只写到本地文件,调度中心看不到。
第三,任务的执行结果用XxlJobHelper.handleSuccess()和XxlJobHelper.handleFail()来标记。如果你不显示调用这些方法,任务默认也算成功。这就是很多"任务显示成功但实际上没干活"的坑的来源,所以关键业务我建议显式捕获异常并调用handleFail()。
写完这些,启动你的Spring Boot项目。注意观察控制台日志,正常的话你会看到执行器启动并注册成功的标识。下面这样就说明执行器已经连上调度中心了:
xxl-job register executor success, appname=xxx-data-sync, address=http://192.168.1.100:9999/4. 在调度中心配任务,跑通全流程
执行器起来了,调度中心也起来了,现在把它们串起来。这一步我在调度中心的界面上操作,你跟着点就行。
4.1 注册执行器
回到调度中心管理界面,打开"执行器管理"页签,点击"新增执行器"按钮。
- AppName填
xxx-data-sync,必须和application.yml里配置的xxl.job.executor.appname完全一致。 - 名称填一个能让人看懂的名字,比如"数据同步服务"。
- 注册方式选"自动注册"。你直接在Spring Boot项目里配置好执行器,它会自动出现在这个列表里。手动注册的方式适用于执行器部署在非标准网络环境、自动注册失败的情况,一般用不上。
保存之后,如果看到执行器列表里出现了一条记录,且"OnLine 机器地址"列显示了你本机的IP和9999端口,恭喜,执行器注册成功。
4.2 创建调度任务
打开"任务管理"页签,默认会连接到当前执行器分组下的任务列表,但列表肯定是空的。点击"新增任务",这里要注意几个关键字段:
- 执行器:选择刚才创建的"数据同步服务"。
- 任务描述:写清楚这个任务是干嘛的,比如"每5分钟同步一次xx表更新数据"。这个描述是给团队看的,一定要写清楚,否则半年后没人记得这个任务是干什么的。
- 调度类型:选"Cron"。
- Cron表达式:这里是重头戏。拿"每5分钟执行一次"来说,表达式是
0 0/5 * * * ?。注意Cron表达式有6位或7位之分,xxl-job用的是7位(秒 分 时 日 月 周 年),很多从Quartz转过来的同学容易在第1位丢掉"秒"。比如你想每天凌晨2点执行,不能只写0 2 * * *,要写0 0 2 * * ?,否则任务会在"凌晨2点的第一秒"才执行吗?不是,那样写的话秒位会被当成第二个字段,整体语义就错了,任务根本不会按预期触发。 - 运行模式:选"Bean",JobHandler填
syncDataJobHandler,必须和代码里@XxlJob("syncDataJobHandler")的名称一致。注意这里不是写方法名,是注解里配的字符串。 - 路由策略:默认"第一个"就行。这个策略决定了任务分发到哪台执行器上执行。你的执行器只有一台,选什么都一样。等以后服务扩容了,你可以选"轮询"做负载均衡,或者选"分片广播"让每台机器各处理一部分数据。
- 阻塞处理策略:默认"单机串行"。这个参数用来处理"上一次任务还没跑完,下一次触发时间又到了"的情况。选"单机串行"就是排队等着,选"丢弃后续调度"就是新的触发直接跳过。对于数据同步这类不能并发的任务,推荐"单机串行"。
- 任务超时时间:设置一个超时值,比如300秒,超过这个时间任务会被标记为超时。如果不设置,任务卡死你都不知道。
保存任务后,你会看到任务列表里多了一条记录,默认是"停止"状态。点一下"启动",任务就开始按照Cron表达式触发。
4.3 手动执行、查看调度结果和日志
任务刚配好,你可能不想等一个Cron周期,没关系,调度中心支持手动触发。在任务列表的操作栏里,点击"执行一次"按钮,任务会被立即下发到执行器上执行。
执行完之后,点击"日志"按钮,进入调度日志页面。这里能看到每次调度的详细记录:
- 调度时间:调度中心下发任务的时间。
- 调度结果:调度中心成功把任务下发出去,会显示"成功"。
- 执行结果:执行器实际执行的结果。可以看到"执行成功"或"执行失败"。
- 执行日志:点进去能逐行看到你用
XxlJobHelper.log()输出的内容。这一页是排查问题的主战场,比业务项目自己的日志还要好用,因为它把调度信息、执行时机、处理结果全部关联在了一条时间线上。
我强烈建议你点一次"执行一次"按钮,然后去日志页看一遍完整流程。看完之后你就理解了xxl-job的整体机制:调度中心并不执行你的任务代码,它只负责"到了时间就叫一嗓子",真正干活的是执行器。
5. 我踩过的坑:常见排查大全
这部分我梳理了自己实际使用中遇到过的、以及帮别人排查过的典型问题,按排查思路整理出来,遇到问题直接对照着查。
5.1 调度中心起不来:数据库连接和时区问题
新手最常见的就是调度中心启动失败。打开docker logs xxl-job-admin,看到的报错五花八门,但根因基本集中在两类:
一类是数据库连接失败。Communications link failure,或者Access denied for user。前者是因为连接串里的数据库地址写错了,记住容器内不能直接用localhost访问宿主机,要区分环境和系统选用127.0.0.1还是host.docker.internal。后者就是账号密码不对,去检查--spring.datasource.username和--spring.datasource.password。
另一类是时区问题。日志里出现The server time zone value ... is unrecognized,这是数据库时区没配对,调度中心启动时访问xxl_job表被拒。解决方式是在JDBC连接串后面加serverTimezone=Asia/Shanghai,像我在2.2节里写的那样。如果你用的是MySQL 5.7,还可能会遇到Unknown initial character set index '255',这是客户端连接时用了不支持的字符集编码,把连接串改为characterEncoding=UTF-8基本都能解决。
5.2 执行器注册不上:网络、AppName、token三大原因
项目能正常启动,但调度中心的执行器列表里看不到机器地址。按下面顺序排查,命中率几乎100%:
- 看日志。执行器启动时如果有
xxl-job register executor fail这样的日志,说明连不上调度中心。确认admin.addresses的地址和端口是否正确。 - 核对AppName。调度中心新增执行器时填的AppName,和项目里
executor.appname必须完全一致,多一个字符、少一个字符都注册不上。注意大小写也敏感。 - 检查网络。如果执行器和调度中心不在同一台机器,确认防火墙有没有放行三个端口:调度中心的8080端口、执行器的9999端口。我在公司帮别人排查时,发现过无数次Windows防火墙默认把Java程序的入站连接拦了。
telnet 执行器IP 9999通不通,一测便知。 - 检查token。如果你的调度中心设置了
accessToken,项目里没配,或者配的值不一样,注册请求会被拒绝。这个报错在日志里不会特别明显,但调度中心日志会有401相关记录。
5.3 任务不触发或重复触发:Cron表达式和路由策略
任务创建之后一直不跑,第一反应去看日志页。如果调度日志里面压根没有记录,说明调度中心就没触发过,问题在Cron配置。这里最容易犯的错我前面提过了,xxl-job的Cron是7位,第1位是秒。0 0 2 * * ?是每天凌晨2点,0 0/5 * * * ?是每5分钟,0 0 * * * ?是每小时整点。每次新建任务,先在界面右上角找到"生成Cron"的组件,它会实时解析下一次执行时间,确认无误再保存。
另一种情况是调度日志有记录,但显示"调度失败"或"执行失败"。调度失败通常是执行器失联,回到5.2检查。执行失败则去点开执行日志,看业务代码里报了什么错。这里有个排查顺序的诀窍:先看调度日志的"执行结果"列,再看"执行日志"里的具体堆栈。前者告诉你任务有没有到执行器,后者告诉你业务代码死在哪一步。
坑还容易出现在"重复触发"。比如你配了0 0 2 * * ?每天凌晨2点跑同步,但执行器做了集群部署,默认路由策略是"第一个",只有一台机器执行,没问题。但如果你用了"分片广播",它会把一个任务同时发给所有执行器跑一遍——很多人的重复执行问题就是这么来的。数据同步、数据清洗这类任务默认用"第一个"或"轮询"策略即可,单据生成、报表统计同理。
5.4 Docker部署的执行器IP自动注册错误
这是Docker化部署以后特有的坑。执行器默认自动探测本机IP,但如果你在容器里跑Spring Boot,探测到的往往是容器内部IP(比如172.17.0.x),而不是宿主机的对外IP。调度中心拿到这个IP后去连执行器,完全连不通。
解决办法有两种。一种是在application.yml里手动指定xxl.job.executor.ip,填上宿主机对外可达的IP。适用于IP固定的场景,一了百了。
另一种是用host网络模式启动容器,和宿主机共享网络栈,自动探测就能拿到宿主机的真实IP:
docker run --network host --name executor-service your-image:tag这种方式简单直接,但缺点是端口管理不像-p那么灵活,适合不想改配置的场景。我的建议是:开发环境随便,生产环境要么手动指定IP,要么用一个稳定的内网域名。
6. 几个值得长期注意的设计经验
把整个链路跑通之后,最后再分享几个我长期使用中沉淀下来的体会。
版本匹配是第一优先级。xxl-job调度中心和执行器通过HTTP协议通信,不同大版本之间协议有些微差异,保险的做法是保持调度中心镜像版本、xxl-job-core依赖版本完全一致。我见过把2.3.0的调度中心配2.4.0执行器的,注册功能正常但任务下发时偶发反序列化异常,排查了半天最后发现是版本问题。所以,"能跑就尽量别升级"这句话在xxl-job这里尤其适用。
第二是日志保留策略要提前想。我的一个线上服务每次数据同步会打印大量明细日志,默认保留30天,几个月下来磁盘就满了。设置logretentiondays时要结合任务频率和日志量来评估,宁可取小别取大,调度历史在数据库里还有一份,不太依赖执行器本地的日志文件。
第三是关于执行器进程的优雅退出。Spring Boot应用关闭时,要确保XxlJobSpringExecutor能先把执行器从调度中心摘除掉,否则调度中心会在一段时间内还认为这台机器在线,把任务发到一个已经挂掉的进程上。正常走Spring的@PreDestroy机制没有问题,但如果你用kill -9强杀进程,就只能等待调度中心的过期清理了。
我个人的习惯是,每次新增定时任务都先手动"执行一次"验证逻辑,确认正常后再把Cron配上去启动。这个小动作看起来多花了一分钟,但它能帮你把"任务上线后发现逻辑有误"的概率降到最低。定时任务的坑往往不在配置上,而在业务逻辑对执行时机的假设上——你觉得数据应该已经准备好了,结果上游还没跑完。这一类问题,只有长期观察才能摸清规律。
市面上比xxl-job更重的调度平台还有不少,但如果你只是想要一个能统一管理、支持集群、日志清晰、部署简单的方案,xxl-job确实是这个体量下的一个很舒服的选择。希望这篇文章能帮你少走点弯路,至少把"调度中心起不来"和"执行器注册不上"这两大拦路虎提前排掉。