做定时任务从单机cron迁移到统一调度平台这个事,我前前后后折腾了两周,最终落地方案是接入部署xxl-job——一套基于Spring Boot的分布式定时任务调度系统。这个平台把调度中心和执行器拆成两端,支持可视化任务管理、动态修改Cron、失败重试、集群部署、分片广播等一系列能力,基本覆盖了我们在生产环境的全部需求。无论你是刚接手后台任务模块的新手,还是已经在用其他调度框架、正在做选型评估的开发者,这篇文章应该都能给你一份可以直接照抄的参考。
接入过程中踩过的坑不少,有些问题网上资料讲得并不清楚,网上搜出来的答案也真真假假,最后全靠自己翻源码、抓日志才定位到根因。这部分经验我会全部写在文末的排查章节里,建议你不管是否遇到问题,都先把那一章翻一遍,很多生产事故本来是可以提前避开的。
1. 为什么最终选型 xxl-job
1.1 分布式任务场景下,cron 是真的顶不住
先说我们之前的状况。早期业务量不大,团队用的是Linux crontab加Spring @Scheduled,任务少的时候没什么存在感,等业务节点多起来之后,问题集中爆发。
第一是配置发散。定时任务散落在各台机器上,想调整一个执行时间,运维和开发要逐个登录服务器去改crontab。某次漏改了一台机器,结果线上同一个任务在A机器跑了、B机器没跑,数据缺了一大块,排查了很久才发现是节点配置不一致导致的。第二是执行结果不可见。任务有没有跑成功,只能靠业务数据反推,没有统一日志、没有告警,失败出现几小时后才能被人肉巡检发现。第三是单点问题,一台机器挂了,上面挂着的任务全部停摆,而同一时间另一台机器可能还在空转。第四是@Scheduled默认单线程执行,一个任务跑得慢了,后面的任务全部排队,越积越多。
这些问题不是靠多写几个try-catch能解决的,到了某个规模之后,定时任务必须从"每台机器自己跑"升级成"统一平台调度、多节点协同执行"。
1.2 xxl-job 的核心架构和优势
xxl-job把整个调度体系拆成了调度中心和执行器两端。调度中心负责维护任务定义、触发时间、路由策略、执行日志,执行器负责真正跑业务逻辑,并把执行结果回传。两端之间通过HTTP + JSON通信,调度中心内部依赖成熟的调度框架来完成分布式触发,任务信息和调度记录统一落在关系数据库里,天然适合多节点部署。
相比直接裸用某调度框架,xxl-job最大优势是开箱即用:自带管理后台、任务管理界面、日志查询、报警邮件、动态修改Cron、失败重试、分片广播等等,基本不需要自己造轮子。相比某些重量级调度平台,xxl-job部署成本低,一个Spring Boot应用加一张数据库表就能跑起来,技术栈也贴合我们团队的Java体系。后来我又对比过几个方案,有的功能更强大但依赖组件多、维护成本高,有的社区不够活跃遇到问题没地方搜,综合考虑后还是定了xxl-job。
1.3 版本选择说明
当前生产环境用的是2.4.x版本,跑了半年多一直很稳。新版本3.x在UI和部分配置上有些变化,但核心接入流程基本一致。这篇文章里的操作和配置以2.4.x为准,用到3.x的时候留意一下配置文件项和默认端口的变化即可,不要拿着老教程硬套新版本。
2. 调度中心部署与初始化
2.1 环境清单和数据库准备
部署调度中心前先把环境确认清楚:JDK 8以上、Maven 3.6以上、MySQL 5.7及以上,CentOS和Windows都可以跑,生产环境推荐Linux。数据库方面,需要单独建一个库,建议库名就叫xxl_job,避免和其他业务表混在一起。
建库后执行源码doc/db目录下的tables_xxl_job.sql初始化脚本。脚本会创建任务信息表、调度日志表、执行器注册表、用户表等核心表。注意不要漏掉这一步,很多同学第一次启动admin失败,八成就是数据库表没有初始化完。初始化成功后可以用客户端确认一下表数量,正常情况会有xxl_job_info、xxl_job_log、xxl_job_registry、xxl_job_group、xxl_job_user等表。
2.2 调度中心关键配置说明
调度中心是一个独立的Spring Boot应用,核心配置都在application.properties文件里。贴一份关键配置,注释标一下含义:
server.port=8080 spring.datasource.url=jdbc:mysql://localhost:3306/xxl_job?useSSL=false&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai spring.datasource.username=root spring.datasource.password=123456 xxl.job.accessToken=default_token xxl.job.i18n=zh_CN xxl.job.triggerpool.fast.max=200 xxl.job.triggerpool.slow.max=100accessToken是调度中心和执行器之间的通信凭证,两边必须配成一样。如果为空,则通信不做权限校验;如果有值,执行器配置不一致时会直接鉴权失败。生产环境务必设置强token,别用默认值。i18n配置成zh_CN可以直接用中文后台界面。
triggerpool.fast.max和triggerpool.slow.max是调度中心内部两个触发线程池的大小,一个处理快任务、一个处理慢任务。这个设计值得说一下:如果只有一套线程池,某个耗时的慢任务把线程占满,后面的快任务全部排队,整个调度中心就卡住了。分池之后,慢任务再慢也不会拖垮快任务的触发。
2.3 启动调度中心和初始化检查
配置改完之后直接启动,可以用mvn spring-boot:run,也可以打包后执行java -jar xxl-job-admin-2.4.0.jar。启动日志里看到"Started XxlJobAdminApplication"就说明起来了。浏览器访问http://IP:8080/xxl-job-admin,默认账号admin,默认密码admin123,首次登录后第一时间改密,这个就不多强调了。
登录后先别急着建任务,可以检查一下"执行器管理"页面,此时应该是空的,后面接入执行器节点后会自动出现在这里。顺手确认调度中心系统配置里的日志清理天数、触发线程池等参数是否符合预期,默认值在多数场景下可以先用着。
3. 执行器接入完整流程
3.1 引入依赖与基础配置
调度中心搭好只是起点,真正的工作是把业务服务接入进来。这里以一个订单服务为例,演示完整的执行器接入过程。
先在pom.xml里引入xxl-job-core依赖:
<dependency> <groupId>com.xuxueli</groupId> <artifactId>xxl-job-core</artifactId> <version>2.4.0</version> </dependency>然后在application.properties里增加执行器相关配置:
xxl.job.admin.addresses=http://localhost:8080/xxl-job-admin xxl.job.accessToken=default_token xxl.job.executor.appname=order-executor xxl.job.executor.address= xxl.job.executor.ip= xxl.job.executor.port=9999 xxl.job.executor.logpath=/data/logs/xxl-job/jobhandler xxl.job.executor.logretentiondays=30逐项解释一下。admin.addresses是调度中心地址,集群环境可以填多个用逗号分隔。appname是执行器名称,必须和调度中心"执行器管理"里配置的AppName保持一致,这个是最容易踩坑的点。address留空代表使用自动注册的地址,ip留空代表自动探测本机IP,如果你的服务器有多块网卡,自动探测可能拿到一个错误的内网IP,这时候就需要显式指定ip。port是执行器暴露的HTTP端口,默认9999,注意不要和业务端口冲突,也要确保调度中心能通过网络访问到这个端口。
logpath是执行器本地日志的存放目录,logretentiondays是本地日志保留天数,生产上建议都设置一下,不然后期磁盘会被日志堆满。
3.2 执行器配置类编写
光配了properties还不够,需要在Spring容器里注册一个XxlJobSpringExecutor的Bean:
@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; } }这段代码看起来长,实际就是读取配置并逐项赋给执行器对象。init和destroy方法在Spring管理Bean时会被自动调用,执行器启动后会自动向调度中心注册,关闭时自动注销。
3.3 注册执行器并完成第一次对接
登录调度中心后台,进入"执行器管理"页面,点新增执行器,AppName填order-executor,名称可以填"订单执行器"。保存后先启动你的业务服务,观察启动日志,正常情况下会出现执行器注册成功的提示。回到"执行器管理"页面,就能看到order-executor下面多出了一个节点,显示IP:PORT,状态是在线。
这里有一个经验:如果生产环境有多套环境,比如测试和正式共用一套调度中心,建议执行器AppName区分环境,比如order-executor-test、order-executor-prod,避免任务调度串环境。
3.4 开发第一个任务并验证全链路
执行器接入成功后,写一个最简单的任务Handler:
@Component public class SimpleTask { @XxlJob("demoJobHandler") public void demoJobHandler() { XxlJobHelper.log("demo task start"); System.out.println("执行订单统计任务"); } }类必须交给Spring管理,加@Component即可。@XxlJob注解里的名称就是任务标识,调度中心配置任务时要填这个名称,拼写错了会报"JobHandler not found"。
然后到调度中心"任务管理"页面新增任务:
- 执行器:order-executor
- JobHandler:demoJobHandler
- 运行模式:Bean
- Cron:0 0 2 * * ?
- 路由策略:第一个
- 任务描述:订单统计测试
保存后先不要等Cron触发,直接在操作列点"执行一次",再进入"调度日志"页面,点击这次调度的日志详情,能看到调度中心和执行器的完整通信记录。执行成功的话日志里会出现我们通过XxlJobHelper.log写入的"demo task start"。
到这里,调度中心到执行器的链路已经完全打通,第一节最核心的接入流程就跑通了。
4. 任务治理与高级调度玩法
4.1 路由策略选择与适用场景
调度中心新增任务时有一个路由策略下拉框,这个选项的作用是:当同一AppName下挂了多个执行器节点时,一次调度应该把任务发给哪个节点。选项非常多,我按实际使用场景整理成一张表:
| 路由策略 | 行为 | 典型场景 |
|---|---|---|
| 第一个 | 固定发给注册列表的第一个节点 | 只需要单节点执行且不关心负载 |
| 最后一个 | 固定发给注册列表的最后一个节点 | 与第一个类似,偶尔用于特殊切换 |
| 轮询 | 按顺序轮流分发 | 多节点负载均衡,无状态任务 |
| 随机 | 随机选一个节点 | 负载不敏感的场景 |
| 一致性HASH | 按任务ID哈希取模固定节点 | 希望同一任务落在同一节点 |
| 故障转移 | 调用失败自动换下一个节点 | 单点任务但要求高可用 |
| 忙碌转移 | 节点忙碌则切换下一个节点 | 长耗时任务避免排队 |
| 分片广播 | 所有节点同时收到任务并携带分片参数 | 大数据量任务水平拆分 |
实际使用中最常用的是轮询和分片广播。轮询适合那种每个节点都能独立处理的轻量任务;分片广播适合数据量大的批处理任务,把一个任务拆分到多台机器并行跑。故障转移适合必须在单节点跑、但又不希望节点故障后任务直接失败的任务,注意它只能保证换节点重新执行,业务上要处理好幂等。
4.2 分片广播实战:百万级订单统计
这里单独讲分片广播,因为这个功能工程价值最大。我们有一个订单统计任务,每天凌晨要把前一天的全量订单汇总一次,单机跑要一个多小时,经常和业务高峰撞车。用分片广播之后,任务被切成多片,每台执行器处理一份,整体时间从一小时降到了十几分钟。
任务代码核心逻辑如下:
@XxlJob("orderShardingJob") public void orderShardingJob() { int shardIndex = XxlJobHelper.getShardIndex(); int shardTotal = XxlJobHelper.getShardTotal(); XxlJobHelper.log("当前分片:{}/{}, 节点:{}", shardIndex, shardTotal, XxlJobHelper.getJobParam()); // 实际应用中从数据库或消息队列取全量订单ID List<Long> orderIds = queryAllOrderIds(); for (Long orderId : orderIds) { // 按订单ID取模决定当前节点处理哪些单 if (orderId % shardTotal == shardIndex) { processOrder(orderId); } } }分片广播触发时,每个节点拿到的shardTotal都是一样的,shardIndex从0到N-1各不同。最常用的分配策略就是按业务主键取模,把数据集均分到各节点。要注意取模可能会造成数据倾斜,比如订单ID不够随机、某个余数段的数据量特别大,这时可以换成分段范围或一致性哈希来做分配。
另外提一个细节:分片广播是多节点同时执行同一个任务Handler,必须保证每个节点处理的是互不重叠的数据,否则会重复计算。我们是在业务表上增加了"处理状态+唯一索引"做了兜底,宁可多跑也不能跑出脏数据。
4.3 失败重试、阻塞处理与超时设置
调度中心的任务配置里有三个容易让人迷惑的参数:失败重试次数、阻塞处理策略、超时时间。
失败重试次数比较好理解,任务执行异常后调度中心会再次触发调用。这里有一个必须写在前面的大坑:凡是要开重试的任务,业务逻辑必须做到幂等。比如一个任务生成对账单,重试一次就生成两份,线上必定出事故。我们内部规定,任何带重试的任务,业务表必须有唯一键,或者任务代码里先查重再落库。
阻塞处理策略解决的是这样一个问题:任务还没跑完,下一次调度时间又到了,怎么办。有三个选项:
- 单机串行:排队等上一个跑完再跑下一个,调度不会丢,但可能积压。
- 丢弃后续调度:如果上一次还在跑,本次调度直接丢弃,适合对时效要求高的任务。
- 覆盖之前调度:终止正在执行的旧任务,启动新任务,适合"只要最新一次结果"的场景,但要保证旧任务可以被安全打断。
超时时间默认为0,也就是不限制。生产上建议给那些可能有死锁、可能卡死的任务设置一个合理的超时时间,比如300秒,超时后调度中心会把任务标记为失败,能及时发现异常而不至于任务永远悬挂。
4.4 任务参数传递与动态化
很多任务需要入参,比如"统计2025-01-01到2025-01-07的数据"。这些参数完全可以在调度中心页面上的"任务参数"输入框里配置,然后在任务代码里获取:
@XxlJob("dateStatJob") public void dateStatJob() { String jobParam = XxlJobHelper.getJobParam(); XxlJobHelper.log("接收到的任务参数: {}", jobParam); // jobParam 形如 {"startDate":"2025-01-01","endDate":"2025-01-07"} // 解析JSON后走业务逻辑 }这样改统计日期只需要去后台改参数,不用改代码、不用重新发版。我们后来把大量硬编码在代码里的业务日期都迁移成了这种方式,运维成本明显下降。
另外运行模式除了Bean之外还有GLUE模式,支持在调度中心直接维护一段代码,执行器动态加载执行。GLUE模式的好处是免发版,但生产环境我更推荐Bean模式,代码走版本管理、走CI/CD,出问题能回滚。GLUE模式适合临时排查问题或者跑一次性补数任务,不建议长期核心任务使用。
5. 生产环境部署与运维细节
5.1 调度中心集群搭建
调度中心单节点够用,但生产上为了高可用,最好部署两个节点。集群关键点是多个调度中心节点共享同一个MySQL库,任务信息、调度日志都从库里读写,节点之间无状态,前面再挂一层Nginx做负载均衡,整体架构就出来了。
Nginx关键配置如下:
upstream xxl_job_admin { server 10.0.0.11:8080 max_fails=3 fail_timeout=10s; server 10.0.0.12:8080 max_fails=3 fail_timeout=10s; } server { listen 80; server_name scheduler.example.com; location / { proxy_pass http://xxl_job_admin; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里有一个反向直觉的建议:调度中心节点不是越多越好。调度触发依赖任务调度表锁,节点太多会放大锁竞争,反而拖低触发性能。一般两个节点足够,极端高并发场景先考虑分片和调参,不要盲目堆调度中心实例。
另外多节点部署时,所有节点所在机器务必保持时间同步,最好是配置NTP服务。时钟不一致会导致触发时间偏差,产生任务提前或延后执行的问题,这类问题查起来很迷惑,但根因往往就是服务器时间偏了。
5.2 执行器集群自动注册与优雅下线
执行器接入后默认会心跳注册到调度中心,同一AppName下的所有节点都会出现在执行器管理列表中。扩容时不需要改任何配置,新节点启动后自动注册,调度中心下一次调度就能感知到。缩容时直接停掉实例,执行器关闭时会通知调度中心下线节点。
这里要提醒一下:如果某个节点被强制kill杀掉,没有走优雅停服流程,调度中心不会立刻感知,要等心跳超时才会把节点标记为离线。窗口期内调度中心还是有可能把任务发到这个已经宕机的节点上,任务自然失败。所以生产上尽量用规范的关机方式,不要直接kill -9。同时任务是否成功要靠失败重试和告警兜底,不能把宝全押在优雅停机上。
还要注意经典的多网卡问题。执行器自动探测IP时,如果机器上有docker、虚拟网卡等,探测到的IP可能是内网无法访问的地址,调度中心拿这个地址调不过来。遇到任务老是"调度成功但执行失败"时,先去调度日志里看执行器地址,如果地址明显不对,在配置里显式指定xxl.job.executor.ip。
5.3 监控告警与日志治理
xxl-job调度中心内置了邮件告警,配置好SMTP后在任务管理里填上报警邮箱,任务失败就会收到邮件提醒。不过邮件提醒在部分团队里沦为形式,没人及时看。我们后来做了更实用的告警通道:定时扫描调度日志表,把失败任务推送到内部IM群。
这里给出一个扫描失败日志的SQL参考:
SELECT job_id, job_group, trigger_code, handle_code, trigger_time FROM xxl_job_log WHERE trigger_time >= DATE_SUB(NOW(), INTERVAL 10 MINUTE) AND (trigger_code != 200 OR handle_code != 200) ORDER BY id DESC LIMIT 50;trigger_code是调度中心的触发结果,handle_code是执行器的执行结果,只要有一个不是200,都说明任务异常。用这个SQL轮询失败记录,配合IM机器人推送,比邮件及时得多。
日志治理是另一个容易忽视的点。xxl_job_log表增长非常快,尤其是任务多、日志多的场景,几个月就能到几百万行,后台查询会变卡。执行器本地的logretentiondays只能清理本地日志文件,数据库里的调度日志表要单独清理。我们实现了一个清理任务,定期把30天前的调度日志搬到归档表或直接删除,并给xxl_job_log表的trigger_time字段建了索引,查询性能基本可控。索引这块不要忽略,大表上没索引,查一次日志全表扫描,调度中心页面会明显卡顿。
6. 接入部署常见问题排查实录
6.1 调度中心登录后执行器管理里看不到节点
这是接入期最高频的问题。第一先确认执行器服务有没有正常启动,看启动日志里有没有注册成功的记录。第二检查AppName是否和调度中心执行器管理里新增的AppName完全一致,大小写、横杠都不能差。第三检查网络,在调度中心所在服务器上执行telnet 执行器IP 9999,看端口通不通,不通就去查防火墙和云安全组。注意8080往9999方向的访问和9999往8080方向的注册访问是两条链路,任何一个不通都会导致注册异常。
权限问题也有人遇到过,如果调度中心配置了accessToken,执行器端没配,注册请求会被拒绝。这个在调度中心服务端日志里能看到鉴权失败提示。
6.2 任务显示"调度成功"但"执行失败",日志里报JobHandler not found
首先确认@XxlJob注解里的名字和调度中心任务配置里的JobHandler是否完全一致,这类问题基本都是手滑打错字母。其次确认写任务的类是否被Spring管理,最常见的问题是把任务类写成了普通类,或者类路径没有被Spring扫描到,执行器端根本没有注册这个JobHandler。检查一下启动日志里有没有"xxl-job jobhandler [xxx] loaded success"之类的加载记录,没加载到就是Spring扫描问题,给类加上@Component并确认它在扫描包路径下。
6.3 调度记录显示成功但业务数据没有变化
这种问题最费时间,因为链路表面是通的,实际执行没有落到预期位置。第一排查调度日志里的执行器机器地址,看回调的IP是不是执行器所在机器;如果不是,十有八九是多网卡识别问题,按前面说的方法显式配置ip。第二排查执行器日志,确认业务代码到底有没有执行到目标逻辑,有可能日志打印了但业务方法里条件判断把数据过滤掉了。第三看有没有配置了多个相同AppName的实例,调度中心可能把任务发给了另一个预期之外的实例。
6.4 任务重复执行
重复执行往往是好几层因素叠加造成的,排查顺序建议这样:
- 检查路由策略,确认是不是所有节点都会执行。比如用了分片广播但没在代码里做数据分配,每个节点处理同一份数据,必然重复,这种问题在代码review阶段就要拦下来。
- 检查调度中心是否多节点部署且时间不同步,时间偏差可能让调度触发两次;用NTP统一各服务器时间。
- 检查业务ID本身没有唯一约束,重试场景下重复执行就直接产生脏数据。
最稳妥的方案始终是业务层做幂等,不要指望调度平台时刻精准。高并发场景可以引入分布式锁,或者用数据库唯一键、状态机来控制。
6.5 慢任务积压导致后续任务延迟
有段时间我们发现某个任务经常延迟十几分钟才执行,去看执行器日志,发现上一个任务还在跑,后面的任务一直在排队。原因是某个批量处理任务单次执行时间太长,超过了调度周期。解决思路有几个方向:第一是缩小单次处理的数据量,比如改成分批拉取、分批提交;第二是把任务改成分片广播,让多个节点并行处理;第三如果是IO密集型任务,检查执行器线程池配置是否合理,不合理的就适当调大,但前提是下游服务扛得住并发。核心原则是让任务执行时间远小于调度周期,不要让任务"追尾"。
6.6 执行器本地日志磁盘被打满
执行器在logpath目录下会持续写入任务日志,如果业务代码里打印了大量耗时、参数、中间结果,日志增长速度会非常快。这类问题排查时先执行du -sh命令看一下日志目录大小,再按日期文件大小倒序排查哪个任务日志写得多。长期措施是把logretentiondays调小,并配合logback自身的滚动策略限制单文件大小和总大小。另外提醒一下XxlJobHelper.log的日志和业务logback日志是分开的,不能只清理一套,两边都要治理。
最后分享一点个人体会:接入xxl-job本身不难,难的是接入之后的治理。任务数量一多,如果没有清晰的命名规范、参数规范、幂等规范,调度平台反而会变成一个事故放大器。我们后来把任务命名、负责人、告警群、数据去向都统一在文档里登记,所有任务上线前要做小流量试运行,跑完核对数据无误再正式启用。这个习惯帮我们避开了很多次线上问题,比任何技术细节都值得重视。