xxl-job 接入 Spring 的两种方式:手动装配与自动装配(含 initMethod 双启动踩坑实录)
| 项 | 内容 |
|---|---|
| 文档版本 | V1.0 |
| 编写日期 | 2026-09-09 |
| 适用架构 | Spring Boot 2.x / 3.x + xxl-job 调度中心(admin) |
| 技术栈 | xxl-job-core 3.4.2 |
| 关联事件 | 2026-09-09首次部署天宫,EmbedServer 报 BindException: Address already in use |
1. 背景
1.1 xxl-job 三分钟架构速览
xxl-job 是"调度中心 + 执行器"的中心式分布式任务调度框架:
① 注册(30s 心跳)
执行器(业务进程) ─────────────────→ xxl-job admin(调度中心)
↑ │
│ ③ 调度请求(HTTP POST) │
│ 打到执行器内嵌 EmbedServer │
│ (Netty,默认 9999 端口) │
└─────────────────────────────────────┘
④ 执行结果回调(HTTP)
- admin:独立部署的 Spring Boot 工程(带 MySQL),负责任务管理、Cron 触发、日志查询、失败告警;
- 执行器 :嵌入业务进程的组件(就是本文要接入的东西),内嵌 Netty 的
EmbedServer监听executor.port(默认 9999)接收调度请求,执行完回调 admin 上报结果。
执行器接入 Spring 有两种姿势------手动装配(官方姿势) 和 自动装配(社区 starter 姿势)。
1.2 一句话结论
官方只发布了
xxl-job-core,接入方式是手写一个XxlJobConfig声明@Bean(注意:不能写initMethod = "start",否则执行器启动两遍);
官方从未出品 spring-boot-starter(社区有若干自研 starter,走 Spring Boot 自动装配)。两种方式殊途同归------最终都是向容器注册一个XxlJobSpringExecutorbean。
2. 方式一:xxl-job-core 手动装配(官方姿势)
2.1 引依赖
xml
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
<version>3.4.2</version> <!-- 与 admin 版本保持一致 -->
</dependency>
2.2 yml 配置
yaml
xxl:
job:
admin:
addresses: http://10.x.x.161:10002 # 调度中心地址,多个用逗号分隔(集群)
accessToken: default_token # 与 admin 的 xxl.job.accessToken 一致
timeout: 3 # 调度中心网络超时(秒)
executor:
enabled: true # 执行器开关(配合 @ConditionalOnProperty)
appname: yc-milk-job-executor # 执行器名,admin 按此路由任务,多工程必须唯一!
address: '' # 执行器注册地址,留空=自动拼接 ip:port
ip: '' # 留空=自动获取本机 IP;多网卡环境可显式指定
port: 9999 # EmbedServer 端口(admin 回调入口)
logpath: /data/applogs/xxl-job/jobhandler # 任务执行日志目录
logretentiondays: 30 # 日志保留天数
excludedpackage: '' # @XxlJob 方法扫描排除包
2.3 XxlJobConfig(正确写法,yc-milk-job 修复后版本)
java
@Slf4j
@Configuration
@ConditionalOnProperty(prefix = "xxl.job.executor", name = "enabled",
havingValue = "true", matchIfMissing = true)
public class XxlJobConfig {
@Value("${xxl.job.admin.addresses:}")
private String adminAddresses;
// ... 其余 @Value 字段与 yml 一一对应,略 ...
// 不能加 initMethod = "start":xxl-job 的 XxlJobSpringExecutor 实现 SmartInitializingSingleton,
// afterSingletonsInstantiated() 里会注册 @XxlJob 方法并调用 start()------若 @Bean 再声明 initMethod,
// start() 会跑两遍(实测:第一遍 EmbedServer 绑 9999 成功,第二遍 BindException: Address already in use)
@Bean(destroyMethod = "destroy")
public XxlJobSpringExecutor xxlJobExecutor() {
log.info("[XxlJobConfig] 初始化 XXL-Job 执行器: adminAddresses={}, appname={}, port={}",
adminAddresses, appname, port);
XxlJobSpringExecutor executor = new XxlJobSpringExecutor();
executor.setAdminAddresses(adminAddresses);
executor.setAccessToken(accessToken);
executor.setAppname(appname);
executor.setAddress(address);
executor.setIp(ip);
executor.setPort(port);
executor.setLogPath(logPath);
executor.setLogRetentionDays(logRetentionDays);
executor.setExcludedPackage(excludedPackage);
return executor;
}
}
两个写法要点:
| 写法 | 说明 |
|---|---|
@Bean 不带 initMethod |
官方示例的标准写法。启动入口只有一个:SmartInitializingSingleton.afterSingletonsInstantiated()(见第 5 章)。带了 initMethod 就双启动(见第 6 章踩坑实录) |
destroyMethod = "destroy" 可留可去 |
XxlJobSpringExecutor 自己实现了 DisposableBean,Spring 容器关闭时会自动调 destroy();显式声明是幂等的(destroy 内部只是停线程/停服务,重复调用无害) |
2.4 编写 JobHandler
java
@Component
public class DemoJob {
@XxlJob("demoJobHandler") // 名字即 admin 侧任务的 JobHandler 值
public void execute() {
// 取任务参数(admin 任务配置里的"任务参数"字段)
String param = XxlJobHelper.getJobParam();
// 分片广播:多实例分摊(total=实例分片总数, index=当前分片号,从 0 开始)
int shardIndex = XxlJobHelper.getShardIndex();
int shardTotal = XxlJobHelper.getShardTotal();
// 任务执行日志------直接写进 admin 的日志查看器,不是本地 logback
XxlJobHelper.log("demoJobHandler 开始, param={}, shard {}/{}", param, shardIndex, shardTotal);
try {
// ... 业务逻辑 ...
XxlJobHelper.handleSuccess(); // 也可以直接 return,默认成功
} catch (Exception e) {
XxlJobHelper.log(e); // 异常堆栈写入调度日志
XxlJobHelper.handleFail(e.getMessage());
}
}
}
2.5 admin 控制台侧配置
- 执行器管理 → 新建:AppName 填
yc-milk-job-executor(与 yml 一致),注册方式选"自动注册"; - 任务管理 → 新建:运行模式 BEAN,JobHandler 填
@XxlJob注解里的名字,配 Cron、路由策略(轮询/分片广播等)、阻塞处理策略、失败重试次数。
启动业务进程后,admin 执行器管理页 OnLine 机器地址出现 ip:9999 即接入成功。
3. 方式二:spring-boot-starter 自动装配(社区姿势)
3.1 先澄清一个事实:官方没有 starter
xxl-job 官方(xuxueli)只发布 xxl-job-core,从未发布官方 starter------2019 年有贡献者提交过 xxl-job-spring-boot-starter 模块(PR #820),官方关闭未合并 。所以"starter 方式"引入的都是社区/公司自研 starter(Maven 中央仓库与 GitHub 上有多个,常见命名 xxl-job-spring-boot-starter)。
3.2 使用方式
以一个典型社区 starter 为例(坐标、配置前缀以所选 starter 的 README 为准,核心用法一致):
xml
<dependency>
<groupId>com.github.xxx</groupId>
<artifactId>xxl-job-spring-boot-starter</artifactId>
<version>x.y.z</version>
</dependency>
yaml
xxl:
job:
admin:
addresses: http://10.180.82.161:10002
executor:
appname: my-app-executor
port: 9999
logpath: /data/applogs/xxl-job/jobhandler
java
@Component
public class DemoJob {
@XxlJob("demoJobHandler") // 不需要写任何 Config 类
public void execute() {
// 业务逻辑
}
}
差别就一处:不用写 XxlJobConfig ------starter 里的自动配置类(@AutoConfiguration + @ConditionalOnProperty)在 Spring Boot 启动时替你完成了第 2.3 节那个 @Bean 方法的全部工作:创建 XxlJobSpringExecutor、绑定 xxl.job.* 属性、注册进容器。后续启动时机、生命周期管理与方式一完全相同(都由 SmartInitializingSingleton 驱动,见第 5 章)。
3.3 一分钟自研迷你 starter(选读,理解原理)
公司内部多个服务都要接 xxl-job 时,自研 starter 划得来------本质就是把 XxlJobConfig 挪进自动配置类:
java
@AutoConfiguration
@ConditionalOnClass(XxlJobSpringExecutor.class)
@ConditionalOnProperty(prefix = "xxl.job.executor", name = "enabled",
havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(XxlJobProperties.class)
public class XxlJobAutoConfiguration {
@Bean(destroyMethod = "destroy") // 同样不能加 initMethod!
@ConditionalOnMissingBean
public XxlJobSpringExecutor xxlJobExecutor(XxlJobProperties props) {
XxlJobSpringExecutor executor = new XxlJobSpringExecutor();
props.copyTo(executor); // 属性绑定(@ConfigurationProperties 前缀 xxl.job)
return executor;
}
}
再在 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(Boot 2.7 前是 META-INF/spring.factories)里登记该类即可。
4. 两种方式对比与选型
| 维度 | 方式一:core 手动装配 | 方式二:starter 自动装配 |
|---|---|---|
| 依赖来源 | 官方 com.xuxueli:xxl-job-core |
社区/自研 starter(间接依赖 core) |
| 需要写 Config 类 | 是(约 50 行) | 否 |
| 配置前缀 | 自定义(想绑什么前缀自己写) | starter 约定(文档为准) |
| 版本升级 | 直接改 core 版本号,全可控 | 受 starter 维护节奏制约(core 版本、Spring Boot 大版本跟进是否及时) |
| 踩坑面 | 有一个著名的 initMethod 双启动坑(第 6 章) | 正规 starter 不会替你写 initMethod,基本免疫该坑 |
| 适合谁 | 单服务/小规模接入、想少一层依赖、需要精细定制(如动态 appname) | 多服务批量接入、公司有统一中间件团队封装 starter |
5. 核心原理:XxlJobSpringExecutor 的生命周期
两种方式最终都落到同一个类上,理解它的生命周期是理解一切行为(包括坑)的钥匙。
5.1 XxlJobSpringExecutor 的三个 Spring 接口
java
// xxl-job-core 源码(3.x,节选)
public class XxlJobSpringExecutor extends XxlJobExecutor
implements ApplicationContextAware, SmartInitializingSingleton, DisposableBean {
@Override
public void afterSingletonsInstantiated() {
scanJobHandlerMethod(applicationContext); // 扫描所有 @XxlJob 方法,注册 jobHandler
GlueFactory.refreshInstance(1);
super.start(); // ← 启动执行器(唯一合法启动入口)
}
@Override
public void destroy() {
super.destroy(); // 容器关闭时优雅停止
}
}
| 接口 | Spring 的回调时机 | xxl-job 借它做什么 |
|---|---|---|
ApplicationContextAware |
bean 初始化时 | 拿到容器引用 |
SmartInitializingSingleton |
所有单例 bean 都实例化完成后(整个容器生命周期只回调一次) | 扫描注册 @XxlJob + 启动执行器 |
DisposableBean |
容器关闭时 | 优雅停止执行器 |
为什么用 SmartInitializingSingleton 而不是 bean 自己的 initMethod? 因为扫描 @XxlJob 方法要求"所有业务 bean 都就绪"------如果 bean 一创建就启动(initMethod 时机),那些还没实例化的 JobHandler bean 就扫不到。afterSingletonsInstantiated() 恰好保证"全部单例就绪后"才回调,是官方选定的唯一启动时机。
5.2 start() 做了什么
XxlJobExecutor.start()(父类)按顺序:
- 初始化日志目录(logpath);
- 启动
JobLogFileCleanThread(调度日志清理,按 logretentiondays); - 启动
TriggerCallbackThread(执行结果回调队列 + 重试线程); - 启动
EmbedServer(Netty,绑定executor.port=9999)------admin 调度请求的入口; - 启动
ExecutorRegistryThread(向 admin 注册,30s 心跳)。
这 5 步没有任何"已启动"防重标记 ------start() 调几次,线程就起几套、端口就绑几次。这就是双启动坑的物质基础。
5.3 生命周期时序(正确 vs 踩坑)
正确写法(@Bean 裸注解):
Spring 启动
├─ 实例化 xxlJobExecutor bean(只 new + set 属性,不启动)
├─ 实例化其余所有单例 bean(含各 @XxlJob 所在 bean)
└─ SmartInitializingSingleton 回调 afterSingletonsInstantiated()
├─ scanJobHandlerMethod():注册 @XxlJob → "register jobhandler success"
└─ start():线程×N + EmbedServer 绑 9999 ✓ + 向 admin 注册 ✓
(容器关闭)DisposableBean.destroy() → 优雅停止
踩坑写法(@Bean(initMethod = "start")):
Spring 启动
├─ 实例化 xxlJobExecutor bean
├─ ★ initMethod="start" → 第 1 次 start():线程×N + EmbedServer 绑 9999 ✓
├─ (此刻 @XxlJob 还一个都没注册!)
├─ 实例化其余所有单例 bean
└─ SmartInitializingSingleton 回调
├─ scanJobHandlerMethod():注册 @XxlJob
└─ ★ 第 2 次 start():线程再起一套(泄漏)+ EmbedServer 再绑 9999 ✗ BindException!
6. 踩坑实录:initMethod = "start" 导致双启动
事件:2026-09-09 17:57,yc-milk-job 首次部署天宫(Pod:yc-milk-job-847658f86d-fw4sj,xxl-job-core 3.4.2)。
6.1 现象
启动日志惊现 ERROR,但应用功能一切正常:
2026-09-09 17:57:31.554 [xxl-job, EmbedServer] INFO ... xxl-job remoting server start success ... port = 9999
2026-09-09 17:57:36.259 [xxl-job, EmbedServer] ERROR ... xxl-job remoting server error.
java.net.BindException: Address already in use
at sun.nio.ch.Net.bind0(...)
...
at com.xxl.job.core.server.EmbedServer$1.run(EmbedServer.java:92)
前一行 5 秒前刚说 9999 绑定成功,转头又说端口被占------同一个 Pod、同一个 JVM 进程内发生,不存在"别的进程抢端口"(K8s Pod 网络命名空间隔离)。
6.2 定位:日志时序表
| 时间 | 日志 | 解读 |
|---|---|---|
| 17:57:31.421 | [XxlJobConfig] 初始化 XXL-Job 执行器: port=9999 |
bean 创建(@Bean 方法执行) |
| 17:57:31.451~.618 | JobLogFileCleanThread / callbackMessageQueue / retryCallbackThread / EmbedServer start success / ExecutorRegistryThread 各"start"一遍 | 第 1 次 start()------initMethod 触发(此时一个 jobhandler 都没注册!) |
| 17:57:33.953 | MybatisPlus 注册等杂项 | 其余单例继续初始化 |
| 17:57:36.160 | register jobhandler success × 4 |
SmartInitializingSingleton 回调,scanJobHandlerMethod 注册 4 个任务 |
| 17:57:36.256~.257 | 辅助线程再"start"一遍 | 第 2 次 start() 启动 |
| 17:57:36.259 | BindException: Address already in use |
第 2 次 start 的 EmbedServer 绑 9999 失败------被 5 秒前的自己占用 |
整个执行器启动序列完整跑了两遍,是"自己占了自己的端口"。
6.3 根因
网上大量博客(尤其早期版本流传下来的模板)的 XxlJobConfig 写的是:
java
@Bean(initMethod = "start", destroyMethod = "destroy") // ← 错误写法
而 2.x/3.x 的 XxlJobSpringExecutor 已实现 SmartInitializingSingleton,afterSingletonsInstantiated() 内部会调 start()。两个入口叠加 → start() 执行两遍 → 线程双份 + 端口二次绑定冲突。官方 issue #2636(xxl-job 2.3.0)记载了同款问题:两节点注册出四条执行器记录(同 IP 不同端口)。
6.4 修复
diff
- @Bean(initMethod = "start", destroyMethod = "destroy")
+ @Bean(destroyMethod = "destroy")
只保留 SmartInitializingSingleton 这一个启动入口(官方设计),并留注释防止后人"好心"加回去。
6.5 影响评估:为什么"功能没坏但必须修"
- 第 1 次 start 的 EmbedServer 一直持有 9999,注册线程也在跑,admin 下发任务实际能执行------纯功能角度没坏;
- 但:第 2 次 start 又起了一套辅助线程(日志清理、回调队列)→ 资源泄漏;启动期
@XxlJob注册完成前(上例中 31.4s~36.1s 的 5 秒窗口)若有任务下发,会报"job handler not found";启动日志有 ERROR 污染告警。
6.6 一分钟判别法
看到 BindException + xxl-job 相关日志成对出现 (CyclicThread "start" 打了两遍),基本可断定 initMethod 双启动,直接检查 @Bean 注解。
7. 最佳实践清单
| # | 实践 | 原因 |
|---|---|---|
| 1 | @Bean 永远不写 initMethod = "start" |
双启动:线程泄漏 + BindException(本文主题) |
| 2 | core 版本与 admin 版本严格对齐 | 通信协议有兼容性约定 |
| 3 | appname 多工程必须唯一 | admin 按 appname 路由任务,重名会串任务(yc-milk-job 用 yc-milk-job-executor 与其他工程区分) |
| 4 | 本地调试注意网络可达性 | admin → 执行器是反向回调(admin 主动连你本机的 9999),办公网本机通常连不进:本地起一个 admin 同网段联调,或用内网穿透 |
| 5 | 多网卡/K8s 环境 ip 留空自动探测,注册异常再显式指定 | ip: '' 自动取址在多数环境正确 |
| 6 | EmbedServer 端口(9999)只用于 admin 回调,别与业务端口混淆,也不需要在 K8s Service 暴露 | admin 直连 Pod IP |
| 7 | 分片广播任务用 XxlJobHelper.getShardIndex()/getShardTotal() 切分数据 |
扩实例即扩吞吐(yc-milk-job 的 4 个任务均按 MOD(id, shardTotal) 分片) |
| 8 | 粘贴网上的 XxlJobConfig 模板时先删 initMethod | 大量旧模板仍带这个错误写法 |
|
参考资料
- XxlJobSpringExecutor.java 官方源码(master 分支) ------ SmartInitializingSingleton 启动设计的一手证据
- xxl-job 官方文档(中文) ------ 官方接入示例即"裸 @Bean"写法
- GitHub issue #2636:@Bean(initMethod = "start") 导致执行器注册两次 ------ 同款双启动问题的社区佐证
- GitHub PR #820:xxl-job-spring-boot-starter 提案(被官方关闭未合并) ------ "官方无 starter"的出处