1. 从调度需求说起:为什么我盯上了XXL-JOB
先交代一下背景。我这边负责一套数据中台的底表维护工作,每天凌晨要跑一大批离线统计任务,其中最头疼的就是“每日报表自动汇总”——业务方每天早上一上班就要看到前一天的核心经营数据,包括订单量、成交金额、新增用户、渠道转化等等。这些数据散落在十几张业务表里,还要做去重、口径统一、异常剔除,最后汇总成一张宽表,再同步到数仓和报表平台。
最开始这个活儿是拿服务器 crontab 脚本硬跑的,写了一大堆 Shell 加 SQL 的拼接,每天凌晨两点准时执行。表面上能用,但问题非常明显:
- 业务方偶尔会临时要求补数据,脚本一改就要重新上线,风险高;
- 任务跑挂了没有告警,第二天早上业务方发现报表是空的,我们才知道出了问题;
- 多个任务之间如果存在依赖关系,crontab 根本编排不了,只能靠“在脚本里 sleep 半小时”这种土办法;
- 没有执行日志管理,每次排查都要翻系统日志,痛苦得一匹。
后来我就琢磨着引一个调度框架进来。市面上的方案我大概对比过——Azkaban、Airflow、DolphinScheduler、XXL-JOB。选型的时候考虑到我们团队后端以 Java 为主、不想引入太重的大数据组件、需要运维简单可视化操作,最终锁定了 XXL-JOB。我花了一个周末的时间,拿一个 demo 把“每日报表自动汇总”这个场景完整跑通了。
这篇文章就把我的整个实践过程拆开来讲:先从 XXL-JOB 的核心原理说起,再说怎么搭一个最小可运行的 demo,然后讲报表任务在里面的完整落地流程,最后是这段时间踩过的坑和我个人的一些建议。篇幅不短,我尽量把每一个细节和为什么这么做都说清楚。
2. 先花五分钟理解 XXL-JOB 的核心设计
在动手写代码之前,我强烈建议你先搞清楚 XXL-JOB 的工作原理。不然后面配置任务、排查问题的时候你会一头雾水。
2.1 XXL-JOB 到底是什么
XXL-JOB 是一个分布式的任务调度平台。它解决的核心问题是:把“什么时候执行什么任务”这件事,从业务代码里抽出来,统一管理。
打个比方你就明白了。你以前写定时任务,就是在代码里设个闹钟,闹钟响了就干活。问题是,如果系统部署了好几台机器,闹钟会响好几次,同一个任务可能被重复执行。而且你改闹钟时间的时候,必须重新编译、重新发布代码,非常麻烦。而 XXL-JOB 相当于一个中心化的“指挥中心”,它拿着所有任务的时间表,到点了就打电话给对应的执行器,说“该你干活了”。谁来干、什么时候干、干完了没,中心都有记录。
这套架构里有三个角色:
- 调度中心(Admin Server):负责管理任务、触发任务、记录日志、执行告警。它是一个独立的 Web 应用,有可视化界面。
- 执行器(Executor):业务项目集成 XXL-JOB 依赖后,就变成了执行器。它负责接收调度中心的指令,真正去跑你写的任务代码。
- 任务(Job):你在执行器里编写的一个个方法,被调度中心按策略触发。
调度中心和执行器之间通过 HTTP 接口通信,执行器启动后会主动注册到调度中心。所以你会发现,XXL-JOB 对业务代码的侵入程度很低——你只需要在项目里引入一个依赖,加上几行配置,然后把一个方法标记成一个任务即可。
2.2 调度中心和执行器的通信原理
很多刚接触 XXL-JOB 的同学会有疑问:为什么我启动项目后,调度中心列表里还是看不到执行器?
关键在于“注册”这个动作。执行器启动时,会根据配置的 admin 地址,调用调度中心的注册接口,把自己的 ip:port 和 AppName 上报上去。调度中心收到之后,才把执行器在界面上标记为“在线”。
所以如果你的执行器一直“离线”,排查方向基本就两条:
- 执行器所在机器到调度中心的网络不通;
- 执行器应用名(AppName)跟调度中心配置的不一致。
通信这块,XXL-JOB 用的是自研的 HTTP 请求封装,调度中心到执行器是“调度”,执行器到调度中心是“注册”和“回调”。任务跑完后,执行器会把执行结果回调给调度中心,这样你在调度中心的日志页面就能看到成功还是失败。
2.3 路由策略、阻塞处理、失败重试
这三个概念是配置任务时一定会碰到的,我简单展开说一下。
路由策略决定了:当一个任务有多个执行器节点时,调度中心把任务分给谁。
- 轮询:轮流分发,负载均衡;
- 第一个:永远发给第一个注册的节点;
- 故障转移:先检查节点健康状态,只发给存活节点;
- 分片广播:把所有节点都发一遍,适合每个节点处理一部分数据(比如按订单号取模)。
我们的报表汇总任务用的是“轮询”,因为执行器是单节点部署,轮询和第一个效果一样。
阻塞处理策略决定了:当任务还没跑完,下一次触发时间又到了,怎么办。
- 单机串行:同一个任务在同一个执行器节点上排队执行,等上一个跑完再跑下一个;
- 丢弃后续调度:新触发的直接放弃;
- 覆盖之前调度:新触发的开始跑,把旧的停掉。
报表任务涉及长时间的数据计算,绝对不能覆盖之前调度,所以用的是“单机串行”。
失败重试就比较直白了,指任务失败后自动重新调度几次。这里提醒一句:报表汇总这种任务,重试前最好确认一下数据幂等性。比如如果你已经插了一半的数据,重试又从头插一遍,大概率会出重复数据。我后面会专门讲这个问题。
3. 搭建最小可运行的 XXL-JOB Demo
说再多原理都不如跑一个 demo 来得实在。我这一节就把搭建过程完整写出来,照着做你也能跑起来。
3.1 环境准备
我本机用的环境是这样的:
- JDK 1.8
- Maven 3.6
- MySQL 5.7
- 一个干净的空项目,Spring Boot 版本 2.x
XXL-JOB 有两个版本分支:老版本 2.3.x 和后续版本。新版本把调度中心的代码拆成了 xxl-job-admin 和 xxl-job-core 两个模块,我直接用的 GitHub 上的 master 分支。
数据库准备这一步很关键。你的 MySQL 里需要建一个数据库,然后执行调度中心源码里的tables_xxl_job.sql脚本。这个脚本会创建大概十来张表,包括任务信息表、执行器表、日志表、调度日志表、用户表等。
提示:我用的是 2.4.0 版本的脚本。不同版本的 XXL-JOB 表结构有差异,不要随便拿网上贴的旧版脚本混用,否则调度中心启动后会报表结构不匹配的错误。
3.2 启动调度中心
调度中心本身是一个 Spring Boot 应用。你需要修改它的配置文件application.properties,把数据库地址改成你本地 MySQL 的连接串,然后改一下调度中心的内置端口。
我本地的配置是这样的:
server.port=8080 spring.datasource.url=jdbc:mysql://127.0.0.1:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8 spring.datasource.username=root spring.datasource.password=你的密码改完直接运行XxlJobAdminApplication这个启动类。启动成功后访问http://localhost:8080/xxl-job,默认账号密码是admin/123456,登录进去就能看到调度中心的控制台。
这个控制台就是你日常管理任务的地方:新增任务、修改执行时间、查看执行日志、手动触发一次任务、查看调度报表,都在这里操作。
3.3 写一个最简单的执行器程序
调度中心起来了只是个空壳,真正干活的是执行器。我们新建一个 Spring Boot 项目,引入 XXL-JOB 的 core 依赖:
<dependency> <groupId>com.xuxueli</groupId> <artifactId>xxl-job-core</artifactId> <version>2.4.0</version> </dependency>然后在配置文件里加上执行器相关的配置:
xxl.job.admin.addresses=http://127.0.0.1:8080/xxl-job xxl.job.accessToken=default_token xxl.job.executor.appname=report-executor xxl.job.executor.port=9999接下来,写一个配置类,把 XxlJobSpringExecutor 这个 Bean 构建出来:
@Configuration public class XxlJobConfig { @Bean public XxlJobSpringExecutor xxlJobExecutor() { XxlJobSpringExecutor executor = new XxlJobSpringExecutor(); executor.setAdminAddresses("http://127.0.0.1:8080/xxl-job"); executor.setAppName("report-executor"); executor.setIp(""); executor.setPort(9999); executor.setAccessToken("default_token"); executor.setLogPath("/data/logs/xxl-job"); return executor; } }这里面有几点要注意:
setIp留空,表示让执行器自动探测本机 IP;setPort一定不能跟本机其他端口冲突;setLogPath是执行器保存任务执行日志的磁盘路径,一定要确保目录存在且有写权限,不然任务会报日志相关的错误。
写好后启动这个 Spring Boot 项目,注意观察启动日志。如果看到类似 “xxl-job executor init success” 之类的输出,就说明执行器注册成功了。
这时候回到调度中心控制台,在“执行器管理”页面应该能看到刚刚启动的report-executor,状态是“在线”。
3.4 在调度中心注册第一个任务
执行器在线了,接下来就可以创建任务了。
在调度中心控制台左侧菜单点“任务管理”,选择执行器report-executor,新增一个任务。任务配置里最关键的有几个:
- 任务名称:随便起一个,建议描述性强的,比如“每日经营报表自动汇总”;
- Cron:填写触发表达式;
- 路由策略:选轮询;
- 运行模式:选 Bean 模式(Glue 模式后面再说);
- JobHandler:填写你在执行器里定义的 handler 名称;
- 阻塞处理策略:选单机串行;
- 失败重试次数:根据场景设置。
然后回到代码,在业务方法上加上@XxlJob注解即可:
@Component public class ReportDailyJob { @XxlJob("dailyReportJobHandler") public void dailyReportJobHandler() throws Exception { System.out.println("开始执行每日报表汇总任务"); // 这里放真正的报表汇总逻辑 } }保存任务后,回到任务管理页面,点击“执行一次”按钮,稍等几秒去日志页面看执行结果。如果日志里打印出了 “开始执行每日报表汇总任务”,说明整个链路已经通了。
到这里,一个最小的 XXL-JOB demo 就跑通了。你动手试一试,会发现从零到能手动跑通一个任务,其实也就一个小时左右。
4. 把每日报表汇总任务真正落到 XXL-JOB 上
demo 通了以后,就要开始干正事了。我前面说过,原本的报表任务是靠 cron 脚本加 SQL 拼接完成的,现在要把它改造成一个 XXL-JOB 的任务。这个改造过程,我拆成四步走。
4.1 数据准备与口径整理
在做任何代码改造之前,先把数据口径固定下来。这一步往往比写代码还重要。
我们的日报汇总逻辑大概是这样的:
- 从业务订单表取昨日订单数据;
- 从用户表取昨日新增用户数;
- 从各渠道明细表取各渠道的转化数据;
- 排除测试订单、退款订单、内部刷单数据,这些在表里都有标记字段;
- 最终按“日期+渠道”维度聚合成一条记录,写入报表宽表。
因为原始表的数据量很大,我建议不要直接在定时任务里写那种多表大 Join 的 SQL,最好是先写几个独立的中间表,再合并汇总。这样性能更好,也方便问题排查。比如昨晚某渠道数据异常,你可以直接查中间表定位是哪一步出了问题,而不是在一大坨 SQL 里挣扎。
4.2 在代码里实现任务的幂等
幂等这个问题,是我在这个项目里花时间最多的地方。
假设你这段报表汇总任务在凌晨两点触发,跑了一半突然数据库连接断了,任务失败。调度中心按你配置的失败重试次数,半小时后又触发了一次。这时候,上次插入的一半数据还在表里,重新跑一遍就会产生重复数据。
我用的方案是“业务日期作为唯一键”:
- 表结构里加一个唯一索引
(business_date, channel_id); - 插入数据时用
INSERT INTO ... ON DUPLICATE KEY UPDATE,重复就执行更新而不是再次插入; - 或者先执行一条 DELETE 删除业务日期为昨天的数据,再执行 INSERT。
这样无论任务失败重跑多少次,最终表里同一业务日期、同一渠道只有一条数据。这个设计做完之后,我再也不怕失败重试了。
4.3 配置完整的调度参数
在调度中心配置任务的完整参数时,我是这么填的:
| 参数项 | 配置值 | 说明 |
|---|---|---|
| Cron | 0 10 2 * * ? | 每天凌晨 2 点 10 分执行 |
| 运行模式 | Bean | 使用 @XxlJob 注解类 |
| JobHandler | dailyReportJobHandler | 与代码中的 handler 名称一致 |
| 路由策略 | 轮询 | 单执行器节点下效果等同“第一个” |
| 阻塞处理策略 | 单机串行 | 防止任务堆积、交叉执行 |
| 失败重试次数 | 3 | 失败后间隔重试 |
| 超时时间 | 600 | 超过 600 秒未完成视为超时 |
特别说一下 Cron 表达式。XXL-JOB 使用的是 Quartz 的 Cron 表达式,跟 Linux 的 crontab 略有差异。比如一个常见的坑:Quartz 的 Cron 有“秒”这一位,而 Linux crontab 没有。我第一次配置时报了 Cron 表达式错误,就是因为把 Linux 的写法直接搬了过来。
正确的每日凌晨两点十分执行,应该是0 10 2 * * ?,中间那个问号表示“不指定具体的那一天”。
4.4 任务业务代码的完整结构
下面这段代码大致展现了我这个报表任务的处理流程,很多细节我省略了,但整体骨架是可以参考的:
@Component public class ReportDailyJob { @Resource private ReportService reportService; @XxlJob("dailyReportJobHandler") public void dailyReportJobHandler() throws Exception { // 1. 获取业务日期:默认跑昨天的数据 String businessDate = LocalDate.now().minusDays(1).toString(); // 2. 统计各渠道数据,写入中间表 reportService.cleanAndReloadChannelData(businessDate); // 3. 汇总中间表,写入宽表 reportService.mergeToReportTable(businessDate); // 4. 触发下游同步 reportService.notifyDownStream(businessDate); } }我在每个步骤之间都加了日志输出,这样在调度中心的日志页面就能看到任务进行到哪一步了。否则你在 SQL 里跑了一个小时,中途挂了,连失败在哪一步都不知道,排查成本极高。
这里我再强调一点:任务代码里不要加Thread.sleep这种“硬等”逻辑。以前 cron 脚本里 sleep 是常见操作,但放到 XXL-JOB 里,任务执行时间会被记录、被监控,sleep 会导致日志看着是“运行中”,实际上啥也没干。有依赖等需求的话,应该拆成多个子任务,用 XXL-JOB 的“任务依赖”功能去编排。
5. 实操过程中我踩过的几个坑
讲一个项目如果只讲思路不讲坑,那是纸上谈兵。我把自己在实际操作中遇到的问题整理成了一份速查表,每一个都标明现象、原因和解决方案,希望对大家有帮助。
5.1 执行器一直显示离线
现象:调度中心执行器管理页面,执行器状态永远显示为“离线”,手动触发任务会报“执行器为空或不存在”。
原因排查我按优先级做了三件事:
- 先看执行器项目的启动日志,确认有没有报注册失败相关的异常;
- 再确认执行器配置的
admin.addresses是否写成了http://localhost:8080,如果调度中心和执行器不在同一台机器,localhost 是访问不通的; - 最后确认执行器配置的
port是否被防火墙拦截。
我的情况是第三种:测试环境的服务器防火墙没有放行 9999 端口,执行器能注册到调度中心,但调度中心执行调度指令时根本无法连接。放行端口后就正常了。
5.2 任务连续执行多次
现象:凌晨报表跑完之后,业务方反馈数据被覆盖了多次,出现了脏数据。
原因:我没有考虑到调度中心本身的高可用部署。当时测试环境调度中心起了两个实例,任务默认开启了故障转移策略,导致两个调度中心同时把任务下发给了同一个执行器节点,代码里又没有完善的幂等机制。
解决办法分两层:
- 配置层面,路由策略从故障转移改成“轮询”;
- 代码层面,去掉重复执行的影响,方案就是我前面说的“以业务日期为维度的增量覆盖”。
从这次之后我养成了一个习惯:所有定时任务的代码,一律先考虑重复执行时是否安全,再考虑功能是否实现。
5.3 调度中心日志界面打不开
现象:任务能执行,但调度中心的日志页面一直转圈,看不到日志详情。
原因:调度中心读取日志时,会去执行器部署的那台机器上拉取日志文件。如果执行器的logPath配置的目录不存在,或者执行器端口访问受限,日志加载就会失败。
我在配置logPath时犯了一个低级错误:写了一个不存在的绝对路径。后来老老实实改成/data/logs/xxl-job并提前创建好目录,一切正常。
5.4 重试导致的重复数据
这个是报表类任务的常见问题。我虽然前面设计了唯一索引做幂等,但实际比对发现,中间表在重试时会被重复清理和写入,导致关键时间点上的数据短暂不一致。
后来我把策略改了:中间表也采用“全量重刷+覆盖写”的方式,中间表和最终宽表都带唯一键。每次任务开始,先删除业务日期的旧数据,再插入新数据。这个方案配合 XXL-JOB 的“单机串行”阻塞策略,目前没有出过问题。
5.5 调度时间与业务时间不一致
现象:任务执行时间总是在整点后几秒钟才触发,比配置的 Cron 晚了几秒到十几秒。
原因:调度中心有一个调度线程池,任务多了以后,线程池排队会导致触发时间有轻微延迟。
这种情况通常不影响业务,因为我们任务执行的是“昨天”的数据,晚几秒钟没有任何影响。但如果你的任务对执行时间要求非常精确(比如整点抽奖、整点发券),建议把 Cron 往前调 10 秒到 15 秒,比如0 10 0 * * ?改成0 45 23 * * ?之类,提前一点点触发,实际效果就差不了多少了。
6. 从 demo 到生产:任务治理和运维经验
demo 跑通只是第一步。真正用起来之后,你会遇到各种“看起来没毛病,实际上难维护”的细节。我分享几个让我受益比较大的运维习惯。
6.1 任务命名规范和组划分
任务一多,名字五花八门,调度中心里根本分不清谁是谁。我的规则是这样的:
- 任务名称格式统一为“业务模块_动作_时间维度”,例如“每日报表_经营汇总_日报”;
- 执行器 AppName 按业务域划分,报表相关的都叫
report-executor,数仓同步的都叫dw-sync-executor; - 一个职责单一的执行器里,任务数量控制在 10 个以内,太多就考虑拆分成多个执行器。
这样做的好处是,只要看任务列表就知道是哪个业务、干什么、跑多频繁,不需要点进去看详情。
6.2 日志保留与清理
XXL-JOB 默认会保留每个任务的日志,长时间运行后数据库和磁盘都会膨胀。
调度中心的日志表xxl_job_log建议配置定期清理策略。我是通过一个额外的定时任务,每天晚上 3 点删除 30 天之前的调度日志和日志文件。
清理逻辑很简单,核心就两条 SQL:
DELETE FROM xxl_job_log WHERE trigger_time < DATE_SUB(NOW(), INTERVAL 30 DAY);文件清理就直接删掉日志目录下 30 天以前的子目录,可以写个简单的 Shell 脚本放到 crontab 里。
注意:不要把日志清理和业务任务混在同一个执行器里。我当时想省事,把清理任务写在同一个项目里,结果清理任务失败导致日志堆积,差点把磁盘打爆。
6.3 监控与告警
XXL-JOB 自带失败告警功能,配置好邮件接收人后,任务失败会自动发邮件。但提醒一下:这个告警只会在任务失败时发,如果任务压根没被触发(比如调度中心挂了),你收不到任何消息。
我的做法是,在业务侧加了一个“心跳校验”:每天凌晨报表任务跑完后,往一张监控表里写一条完成记录;第二天上午 9 点,用一个独立的检查任务去扫描这张表,发现昨天没有完成记录,就发企微通知。这样即使调度中心整个挂了,我也能从业务数据层面感知到异常。
6.4 任务下线与灰度上线
改任务是早晚的事,但千万别直接在生产环境改配置。我的流程是:
- 先在测试环境新增任务,注册到测试执行器,跑通验证;
- 然后把生产执行器的任务暂时置为“停止”;
- 更新代码、部署,再重启任务。
这里面最容易忽略的是“Glue 模式”任务。XXL-JOB 支持在控制台直接编辑代码生成任务,虽然方便,但代码散布在数据库里,脱离了 Git 管理,后续排查问题非常痛苦。我现在的策略是全部任务都用 Bean 模式,代码跟随项目走版本管理。除非遇到特别紧急的临时数据处理,否则不用 Glue。
7. 后续可以怎么扩展这个 demo
如果你觉得上面的 demo 已经跑通了,想更进一步,我有几个方向建议你试试:
7.1 结合分片广播做数据分批
我们的订单明细表数据量很大,单机扫描全表跑汇总比较慢。可以考虑用分片广播策略,比如配置两个执行器节点,每个节点各处理一半的数据(比如按照订单号取模),最后再合并结果。
XXL-JOB 在触发分片广播任务时,会把当前分片序号和总分片数传给你。代码里可以通过XxlJobHelper.getShardIndex()和XxlJobHelper.getShardTotal()拿这两个参数,据此拆分数据范围。
7.2 引入告警平台对接
XXL-JOB 的默认告警只有邮件,对国内团队来说不太友好。我见过有团队通过修改 admin 源码的方式接入钉钉/企业微信机器人,但这样维护成本高。更简洁的做法是,在任务代码里自己捕获异常,调用统一告警平台的 HTTP 接口通知。
7.3 动态调整执行时间
业务方偶尔会有“今天报表提前出一个”的需求。你不用改 Cron,直接在调度中心页面上点“执行一次”就能手动触发。如果你需要更加灵活的时间策略,可以把 Cron 配置放到配置中心,用 XXL-JOB 提供的修改接口去动态更新。
我在实际使用中发现,最舒服的工作状态是:任务跑完自动把报表数据推送到企业微信群,业务方每天早上打开手机就能看到结果,完全不需要来问我“今天报表出来没”。这个体验上的提升,比任何技术上的优化都更能体现调度的价值。
我个人最深的体会是:调度框架解决的是“什么时候跑、跑完通知谁、失败怎么办”的问题,而真正决定报表质量好坏的,还是你业务逻辑里对数据口径、幂等处理、异常边界这些细节的把握。不要因为上了 XXL-JOB 就觉得万事大吉,它只是把“定时”这件事管好了,剩下的每一步都要靠你自己在代码里守住。