如果你所在的团队还停留在"定时任务靠人肉跑"的阶段——每天早上登录服务器手动执行脚本,或者干脆在Spring Boot里用@Scheduled写死,那么这个项目值得你花5分钟看完:基于xxl-job搭建一套统一的分布式任务调度中心,用Docker把调度中心部署起来,然后把Spring Boot服务作为执行器接入,从此定时任务有了统一管理入口、失败重试和完整日志追踪。
这篇文章不是简单地贴几步命令,我会把原理、步骤、配置项含义、以及我踩过的坑全部拆开讲。你照着操作,不只是"跑通",而是真正理解这套调度体系在项目里该怎么用。
1. 从@Scheduled到xxl-job:手动任务到底坑在哪
1.1 单机@Scheduled的三个死穴
相信很多人刚接触定时任务时,第一反应都是Spring Boot自带的@Scheduled。确实,如果是个人项目或者单机小服务,@Scheduled + cron表达式已经够用了,配置简单、不用额外装东西、跑起来也没毛病。但当我开始接手一个总共5个微服务、双环境部署的项目时,情况立刻变了。
第一个死穴是集群执行问题。服务一上多节点,@Scheduled在每个节点上都会执行一遍,同一个任务被重复触发。数据同步就是重复导入,轻则埋点数据多算,重则库存表直接翻倍。你可能会说"加个分布式锁不就解决了吗",但分布式锁只能解决"同时只有一个节点执行"的问题,任务日志分散在各台机器、没有统一的重试和告警机制,这些运维问题依然存在。
第二个死穴是监控与失败重试。@Scheduled抛异常,默认只是打印一条日志,没有持久化的执行记录。任务半夜2点失败,你第二天早上开晨会的时候根本不知道,等上游数据对不上账去查日志,可能已经过去了十几个小时。要自己做失败补偿、任务追跑、执行状态看板,代码量会迅速失控,而且这些代码和业务逻辑纠缠在一起,后面没人敢动。
第三个死穴是任务运维的割裂。业务任务散落在各个服务里,没有统一的管理入口。产品说"这个任务今晚要手动跑一次",你需要拿Postman调接口,或者写一个临时的controller;任务A执行完要通知任务B,你又要写一堆链式调用逻辑。这些本质上都是调度功能,不应该散落在业务代码里自己管理。
1.2 xxl-job的角色拆分:调度中心、执行器、任务日志
这个时候就需要一个独立的调度平台。xxl-job是我用下来最主流的开源方案之一,社区活跃,部署成本可控。它的核心模型是三个角色分开:
- 调度中心(xxl-job-admin):不执行业务代码,只负责按Cron或固定频率生成调度请求,维护任务配置、执行记录、告警。它是一个独立的Spring Boot应用,可以单独打镜像部署。
- 执行器(executor):嵌入在你的Spring Boot应用里,负责接收调度中心发来的HTTP请求并执行任务逻辑,然后把执行结果和日志上报回调度中心。
- 任务(JobHandler):在代码里通过@XxlJob("名字")标注的具体方法,是真正跑业务逻辑的地方。
理解这个模型非常关键。调度中心与执行器之间通过HTTP协议通信,执行器启动时会向调度中心注册自己的IP和端口,调度中心按任务配置的路由策略把请求分发到具体节点。整个过程里业务代码不需要感知谁是调度者,只需要把自己的处理逻辑暴露成JobHandler,并处理分片参数。
就因为这个角色拆分,团队在集群环境下不再有重复执行的问题:一份任务配置只会在调度中心上触发一次,具体落到哪个执行器由路由策略决定,路由策略选对了,就能精确控制执行行为。
2. Docker部署调度中心:数据库初始化与镜像启动的完整走读
2.1 第一步:准备好MySQL并导入官方表结构
xxl-job-admin是一个Spring Boot应用,持久化依赖MySQL。官方镜像并不会帮你自动建表,所以第一件事是准备数据库。
版本选择上,MySQL 5.7和8.0都可以,我用的是8.0,注意连接串里要加上时区参数。首先创建数据库:
CREATE DATABASE `xxl_job` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;然后拿到官方初始化脚本tables_xxl_job.sql。这个脚本在xxl-job仓库的doc/db目录下,也可以直接从镜像里copy出来,更方便:
docker run --rm xuxueli/xxl-job-admin:2.4.0 cat /app/tables_xxl_job.sql > tables_xxl_job.sql这里有个实践经验:镜像自带sql脚本,直接copy比去GitHub翻更不容易拿错版本。拿到脚本后导入:
mysql -h127.0.0.1 -uroot -p xxl_job < tables_xxl_job.sql导入后用SHOW TABLES确认核心表是否齐全,至少能看到xxl_job_info、xxl_job_log、xxl_job_registry、xxl_job_group这几张表。xxl_job_info是任务配置表,xxl_job_log是调度与执行日志表,xxl_job_registry是执行器在线注册表,xxl_job_group是执行器分组表。这几张表后期排查问题都会用到。
2.2 第二步:用docker run快速拉起调度中心
数据库就绪后,启动调度中心容器。最精简的做法是一条docker run命令:
docker run -d \ --name xxl-job-admin \ -p 8080:8080 \ -p 9999:9999 \ -e PARAMS="--spring.datasource.url=jdbc:mysql://192.168.1.100:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&autoReconnect=true --spring.datasource.username=root --spring.datasource.password=你的密码 --xxl.job.accessToken=default_token" \ xuxueli/xxl-job-admin:2.4.0几个参数需要特意说明。PARAMS是JVM启动参数,镜像里做了包装,直接拼成Spring Boot的配置项。URL里serverTimezone=Asia/Shanghai这个很重要,缺了会造成时间差8小时;useSSL=false可以避免MySQL连接时的一堆告警。8080是admin界面的HTTP端口,9999是执行器回调端口,两个都要映射出来。
如果你希望更好管理,建议用docker-compose,把MySQL和admin放在同一个网络里:
version: '3' services: mysql: image: mysql:8.0 container_name: xxl-job-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: xxl_job command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 ports: - "3306:3306" volumes: - mysql-data:/var/lib/mysql - ./tables_xxl_job.sql:/docker-entrypoint-initdb.d/tables_xxl_job.sql:ro xxl-job-admin: image: xuxueli/xxl-job-admin:2.4.0 container_name: xxl-job-admin depends_on: - mysql ports: - "8080:8080" - "9999:9999" 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=root123 --xxl.job.accessToken=default_token' volumes: mysql-data:把tables_xxl_job.sql放到和compose文件同级目录后,docker-compose up -d,MySQL首次启动时会自动初始化数据库并导入脚本,这才是真正的一条命令完成。这里要留意MySQL 8.0的密码认证插件问题,如果你后续要用宿主机上的客户端连接,加--default-authentication-plugin=mysql_native_password更省心。
2.3 第三步:登录后台创建执行器,验证部署成功
容器起来后,访问http://localhost:8080/xxl-job-admin,默认账号admin,密码123456。登录成功后,第一件事不是急着集成Spring Boot,而是先把执行器分组建好。
执行器管理页面点"新增执行器",AppName填xxl-job-executor-sample(要和Spring Boot配置的appname完全一致),名称随便写,注册方式选"自动注册",机器地址先不填。保存后先不用管,等执行器启动后会自动注册上来。
到这里调度中心就部署好了。如果打开任务管理页面能看到"新增任务"按钮、数据列表能正常加载,说明数据库连接和表结构都正常。下一步就是把Spring Boot应用的执行器接进来。
3. Spring Boot执行器集成的三件套:依赖、配置类、任务代码
3.1 Maven依赖与版本强一致规则
集成执行器,其实就是在你自己的Spring Boot服务里引入xxl-job-core,再配一个XxlJobSpringExecutor的Bean。POM里加:
<dependency> <groupId>com.xuxueli</groupId> <artifactId>xxl-job-core</artifactId> <version>2.4.0</version> </dependency>这里有一个必须强调的硬规则:执行器的xxl-job-core版本与调度中心镜像版本必须一致。比如admin用2.4.0,客户端就用2.4.0的依赖。版本不一致会出现序列化兼容问题,轻则任务执行没反应,重则直接报Handler not found。我见过有人admin用2.4.0、客户端引2.3.1,结果任务能注册上、但触发时报方法找不到,花了一个下午排查才意识到是版本错位。
3.2 配置类和application.yml逐字段拆解
接下来是执行器的装配。我在项目里习惯用一个专门的配置类,把参数通过@Value注入,避免把配置写死:
@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.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.setIp(ip); executor.setPort(port); executor.setAccessToken(accessToken); executor.setLogPath(logPath); executor.setLogRetentionDays(logRetentionDays); return executor; } }application.yml对应的配置:
xxl: job: admin: addresses: http://localhost:8080/xxl-job-admin accessToken: default_token executor: appname: xxl-job-executor-sample ip: port: 9999 logpath: /data/applogs/xxl-job/jobhandler logretentiondays: 30逐个拆解一下:
- admin.addresses是调度中心的完整访问地址,注意一定要带上下文路径/xxl-job-admin,如果漏了这个后缀,执行器注册和任务回调全都不通。
- accessToken是调度中心和所有执行器的共享令牌,两边不一致时,admin端不会接收该执行器的注册请求。生产环境务必改成强随机串。
- executor.appname是执行器分组标识,必须和admin后台手动创建的执行器AppName一模一样,否则你在admin里创建任务时,根本找不到该执行器。
- executor.ip一般留空,框架会自动探测本机IP。只有当自动探测到内网IP而调度中心无法访问(典型场景是容器跨宿主机通信)时,才手动指定。
- executor.port是执行器自带的HTTP服务端口,默认9999。调度中心后续要通过这个端口反向触发执行器,所以生产环境防火墙一定要放行这个端口。
- logpath和logretentiondays是业务日志落盘路径及保留天数。日志路径要保证有写权限,容器部署建议挂载到宿主机目录。
3.3 写第一个@XxlJob任务并手动触发
配置完执行器后,写一个JobHandler。以"从第三方接口同步数据"为例:
@Component public class DataSyncJobHandler { private static final Logger logger = LoggerFactory.getLogger(DataSyncJobHandler.class); @XxlJob("dataSyncJobHandler") public void dataSync() { XxlJobHelper.log("数据同步任务启动"); // 模拟业务逻辑:这里可以是拉取接口、增量入库、清理过期数据等 int total = 0; for (int i = 0; i < 100; i++) { total += i; try { Thread.sleep(10); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } XxlJobHelper.log("同步完成,处理记录数: {}", total); } }@XxlJob注解里的字符串就是JobHandler名称,它在同一个调度中心下要全局唯一。任务里记录日志时,用XxlJobHelper.log而不是logger.info,因为前者会把日志主动上报给调度中心,你在admin的调度日志详情页里就能直接看到任务内部输出,而后者只能去服务器上翻日志文件。这是很多新手容易忽略的差异。
写好任务后重启服务,再到admin后台刷新执行器管理页面,能看到刚才配置的xxl-job-executor-sample状态变为"在线"。然后去任务管理新增任务,运行模式选"BEAN",JobHandler填dataSyncJobHandler,Cron表达式随便填一个未来时刻,先保存。接下来点"执行一次",进入调度日志页面,如果看到执行成功并且日志里有"同步完成,处理记录数",说明整个链路已经打通。
4. 任务调度策略解密:Cron、路由、阻塞、重试怎么搭配
4.1 Cron表达式:6位字段还是7位?秒必须写
xxl-job和Quartz一致,使用6位Cron表达式:秒 分 时 日 月 周。很多从Linux crontab过来的人会犯一个经典错误,Linux的cron是"分 时 日 月 周"5位,没有秒,直接把表达式抄过来就等于整体前移了一位,结果任务在完全意料之外的时间触发。
我整理几个常用的表达式:
| 执行频率 | Cron表达式(6位) | 说明 |
|---|---|---|
| 每天凌晨2点 | 0 0 2 * * ? | 注意最后用问号而不是星号 |
| 每5分钟 | 0 */5 * * * ? | 会整点对齐地跑 |
| 每小时30分 | 0 30 * * * ? | 每天跑24次 |
| 工作日9点 | 0 0 9 * * MON-FRI | 周字段用MON-FRI |
| 每月1号0点 | 0 0 0 1 * ? | 月初清理类任务常用 |
有两个字段需要重点说明。日和周不能同时指定值,要么日留问号、周写值,要么日写值、周留问号,两个都写值表达式直接非法。另外秒位必须写,之前有人写0 0 2 * * ?,实际触发时间是每天2点0秒,这是对的;但如果写成0 2 * * ?,就变成了每小时的第0分第2秒,也就是每小时都会跑一次,这正是我强调"秒"的原因。
4.2 路由策略:数据同步场景下的选择逻辑
路由策略解决的是"多个执行器节点,这次触发谁"的问题。xxl-job的策略非常多,但实际项目里高频使用的就那么几个。
- 轮询:任务在多个节点间轮流跑。适合无状态、任意节点执行结果一致的场景。
- 一致性HASH:同一个JobHandler参数会路由到同一个节点。适合每个节点有本地缓存、希望同一任务固定打在同一台机器上的场景。
- 故障转移:触发失败自动切换下一个节点。适合对执行成功率要求较高、且任务本身轻量的场景。
- 分片广播:所有节点同时执行。配合XxlJobHelper.getShardIndex()(当前分片索引,从0开始)和getShardTotal()(总分片数),把数据按ID取模拆分到每台机器上并行处理。这是大数据量数据同步任务最常用的方案。
比如之前有个订单数据清理任务,单机跑需要40分钟,我改成4个执行器节点 + 分片广播,每个节点只处理订单ID取模后属于自己分片的数据,运行时间直接缩短到12分钟左右。代价是任务需要能被拆成多个分片,也就是业务数据必须有可分片的维度,比如按ID区间、按租户、按时间范围。
4.3 阻塞处理与失败重试的搭配建议
阻塞处理策略指前一个任务还没执行完,下一个调度周期又到了,这时候怎么办。三个选项区别很大:
- 单机串行:后一次调度排队,等前面的跑完再执行。这是最稳的选择,绝大多数业务任务都应该用它。
- 丢弃后续调度:后一次触发直接丢弃,防止任务堆积。适合对实时性不敏感、下次跑可以覆盖本次数据的任务,比如全量同步类型的。
- 覆盖之前调度:强制终止正在跑的,执行新的。这个有数据安全风险,要非常谨慎,不是所有任务都能被安全终止的。
失败重试次数这块,需要区分清楚。xxl-job里的"失败重试次数"其实是指调度失败后的重试,注意重试场景下任务逻辑必须幂等。我遇到过把重试次数配成3,结果回调接口偶发超时导致任务实际已经执行成功,但admin判定失败又重试了三次,数据被重复插入。最后发现业务方忘了在插入前做唯一键校验。所以凡是开了重试的任务,第一要求就是幂等。
还有一个很容易忽略的组合建议:给数据同步类任务配上超时时间。比如第三方接口响应慢,任务卡住超过10分钟还没返回,xxl-job会判定执行超时并终止。我推荐任务超时时间不要短于你业务峰值耗时的1.5倍,但要短于调度周期,否则会出现"上次卡着还没超时、下次又要开始"的混乱局面。
5. 集成后的实战排错:执行器注册失败与日志定位链路
5.1 执行器状态一直离线:三分钟定位链路
这个是集成阶段出现频率最高的问题:admin后台执行器一直显示离线,任务创建后没有节点可选。我的排查顺序是固定的。
第一步看执行器启动日志,重点搜"xxl-job register"或者"registry"关键字。如果看到连接超时,十有八九是admin.addresses配错了,或者调度中心的8080端口没通。用curl验证一下:curl http://调度中心IP:8080/xxl-job-admin,能返回页面说明地址本身OK。
第二步看appname是否一致。admin后台执行器管理里新建的AppName和Spring Boot配置文件里的xxl.job.executor.appname,必须一字不差。很多"离线"问题其实是执行器分组的命名对不上,admin找不到对应的注册节点。
第三步看accessToken。两边令牌不一致,执行器的注册请求会被调度中心直接忽略。而且xxl-job的默认配置不是完全关闭鉴权,而是默认default_token,这个值会同时出现在admin和executor的默认配置里,所以两边都不改是可以通的。但如果生产环境要求改admin端token,记得同步把每个服务的accessToken都改掉。
第四步是网络和端口。执行器主动注册是往admin推,但后续admin触发任务需要反向访问执行器的9999端口,两者方向不同。容器场景经常出现"执行器显示在线但任务触发失败",就是只放了8080,忘了放9999。跨进程排查时,用netstat或docker logs确认执行器端口真实监听状态。
5.2 任务"被调度但没执行"的日志排查顺序
任务在任务管理里显示调度成功,但业务代码里没有走出任何日志,这种问题比注册离线更难察觉。按下面的顺序查:
第一,确认运行模式。任务运行模式选的是BEAN还是GLUE?BEAN模式要求JobHandler名字与@XxlJob注解的值完全匹配,注意JobHandler名区分大小写,并且在任务编辑页里的"JobHandler"输入框要填对。GLUE模式则是代码在线维护,一旦选错,执行器上根本没有对应处理器。
第二,确认执行器在线且路由策略有节点可选。如果路由策略选了"第一个",但注册节点列表为空,触发请求也会报错。这时候可以临时把路由策略改成轮询再手动触发一次,看有没有起效。
第三,进入调度日志详情。admin的任务管理里,每条调度记录点开,能看到"调度结果"和"执行结果"。如果调度成功但执行结果为空,问题大概率在执行器侧;如果连调度日志都没有,说明Cron没到点或者任务被设置成"停止"状态。
第四,去执行器的日志目录里翻jobhandler日志。XxlJobHelper.log上报的是结构化日志,而应用自己的logger.info只写在本地。这个日志文件就是任务执行的真实证据。容器环境如果logpath没有挂载出去,容器重建后日志丢失,所以docker-compose里务必要把日志目录映射到宿主机。
5.3 容器环境的时区、端口与日志持久化问题
跑在Docker里的执行器和调度中心,和跑在物理机上有几个差异点,不处理就会出一些很诡异的故障。
时区是最典型的。很多基础镜像默认UTC时区,如果你没有在启动容器时设置TZ环境变量,任务显示的调度时间会比北京时间差8个小时。之前有个客户反馈任务"每天8点跑"实际是"每天16点才跑",查到最后就是镜像时区问题。解决方案很粗暴,docker run时加-e TZ=Asia/Shanghai,或者docker-compose的environment里写TZ。
端口分配要注意执行器端口冲突。同一个宿主机上跑多个Spring Boot服务,如果每个执行器都默认9999,后启动的会Bind失败,服务直接起不来。我习惯给每个服务分配不同端口,比如9999、9998、9997,并在admin后台分别建执行器分组,这样调度中心就能精确定位到每一个应用实例。
日志持久化是最后一个容易被忽略的点。XxlJobHelper的日志默认写在logpath路径,如果你不把日志目录挂载成volume,容器一回收,所有历史执行日志就全没了。管理员在排查历史任务失败原因时就会很被动。经验是docker-compose里加volumes映射,把logpath指到宿主机的/data/logs/xxl-job/ /目录下,再配合定期清理策略,既方便排查又不会把磁盘打满。
其实我在实际使用中还发现一个很小的习惯调整,对整个排查效率帮助很大:任务日志里第一行固定打任务入参,第二行打"开始时间",结束前打"耗时"。这样无论从admin里看,还是去日志文件里翻,你都能在几秒内判断任务到底是什么时候跑的、跑了多久、在哪一步慢的。配任务报警邮箱也是正式环境的刚性需求,否则任务凌晨失败没人知道,到早上才发现数据缺了一块,那整个晚上的下游结算都会受影响。另外,如果你后续要接告警通知,建议优先用企业微信Webhook或者邮件,xxl-job自带的报警扩展点本身就很轻量,不要自己再包一层重型消息中间件。把这几个细节都处理干净,xxl-job这套东西就能从"能跑"变成"好用"。