xxl-job 接入 Spring 的两种方式:手动装配与自动装配(含 initMethod 双启动踩坑实录)

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 自动装配)。两种方式殊途同归------最终都是向容器注册一个 XxlJobSpringExecutor bean。


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 控制台侧配置

  1. 执行器管理 → 新建:AppName 填 yc-milk-job-executor(与 yml 一致),注册方式选"自动注册";
  2. 任务管理 → 新建:运行模式 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()(父类)按顺序:

  1. 初始化日志目录(logpath);
  2. 启动 JobLogFileCleanThread(调度日志清理,按 logretentiondays);
  3. 启动 TriggerCallbackThread(执行结果回调队列 + 重试线程);
  4. 启动 EmbedServer(Netty,绑定 executor.port=9999)------admin 调度请求的入口;
  5. 启动 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 已实现 SmartInitializingSingletonafterSingletonsInstantiated() 内部会调 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 大量旧模板仍带这个错误写法

|

参考资料

相关推荐
鹅城剑仙1 小时前
Spring Boot 3 全局异常处理与统一响应封装进阶实战
java·spring boot·后端
橘子汽水1681 小时前
Leetcode 208,207实现Trie前缀树,课程表
java·数据结构·算法·leetcode
tryxr1 小时前
Chat2Excel 文件服务模块剩余功能开发
java·服务器·windows·java项目·文件服务
张小姐的猫1 小时前
【AI大模型接入SDK】 —— Ollama本地接入Deepseek
java·linux·开发语言·网络·c++·人工智能
谢亮_vipxieliang1 小时前
ValidX与Maven/Gradle集成配置指南
java·spring boot·spring·maven·hibernate
Nuanyt1 小时前
JUC常见核心知识梳理01 线程 并发 JMM volatile 管程 锁 synchronized
java·开发语言·网络·jvm
我命由我123451 小时前
Android 控件 - ListAdapter
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
CodeStats2 小时前
【Java 类加载器】Java 类加载器完整体系深度拆解(中):URLClassLoader 能力剖析与继承委派辨析
java·jvm·classloader·类加载器
Terra.K2 小时前
JAVA职业探索和学习----中间件开发目标
java·学习·中间件