1. 项目概述与选型思考
做后端开发的,只要碰过定时任务,大概率都经历过这么几个阶段:最开始用Spring自带的@Scheduled,在单机环境下跑着挺爽,一个@EnableScheduling加一个注解就完事。等到业务量上来,部署了多台实例,麻烦就来了——同一个任务在每台机器上都执行一遍,数据重复处理、邮件重复发送、对账重复跑,全是坑。再往后你想把任务拆成分布式调度的模式,用Quartz + QuartzCluster硬撑,又得维护数据库表、处理集群竞争锁、写一堆没人愿意看的配置XML,想想都头大。
我这次做的这个springboot整合xxl-job,就是要解决上面这串问题。XXL-JOB是一个轻量级分布式任务调度平台,核心思路就是"调度中心"和"执行器"分离——调度中心负责任务的调度、管理、监控,执行器这边嵌在Spring Boot业务系统里,负责实际跑任务。两边通过HTTP通信,业务系统只需要引入一个依赖、配置几行参数,然后把要执行的任务写成一个继承IJobHandler的类,就能无缝接入整套分布式任务调度体系。
这篇文章适合谁看?两类人。一类是手头项目已经开始用@Scheduled、但部署多实例后碰到重复执行问题,想找一个成熟方案替换的Java后端开发者。另一类是刚接触分布式任务调度、想快速搞一套能落地的调度平台,但不想从零写调度器的朋友。我会按照我自己实际整合的路径,把环境搭建、代码开发、调度配置、生产环境坑位全部讲一遍,你跟着走基本半小时内能跑通全流程。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:为什么是XXL-JOB而不是其他调度框架
2.1 分布式调度面临的核心痛点
先聊聊分布式调度到底难在哪。单机环境下@Scheduled很好用,Spring容器启动时就把任务注册到了ScheduledAnnotationBeanPostProcessor里,后续由TaskScheduler按cron表达式触发。但一旦拆成多实例部署,这个模式就崩了。
想象一个电商系统,凌晨2点要跑一次"未支付订单自动关闭",部署了3台后端实例。如果用原生@Scheduled,3台实例到点全触发,3条一模一样的SQL去更新同一批订单,看起来结果没错,因为更新是幂等的,但要是换成"给所有未支付用户发短信提醒",那用户就会在凌晨2点同时收到3条一模一样的短信。再严重一点,如果任务要做的是"读取某个目录下的文件并处理",3台实例互相抢文件,处理完的文件删除时机不一致,数据就乱了。
所以分布式调度的核心痛点就三个:任务只执行一次(不能多实例重复执行)、任务可拆分和路由(大量任务可以分片处理)、任务状态可监控(失败、超时、重试都要有反馈)。@Scheduled一个都解决不了,这就是为什么我们需要一个调度平台来管这件事。
2.2 XXL-JOB与Quartz、ElasticJob的对比
既然要引入调度平台,市面上主流的有Quartz、ElasticJob、XXL-JOB。我直接说结论:中小型团队、Spring Boot技术栈,XXL-JOB是最务实的选择。
| 对比维度 | Quartz | ElasticJob | XXL-JOB |
|---|---|---|---|
| 部署复杂度 | 需自建集群+数据库表 | 需搭配ZooKeeper,环境依赖重 | 调度中心就是一个可执行Jar,开箱即用 |
| 学习成本 | 概念多,配置繁琐 | 概念抽象,Java配置复杂 | 可视化界面配置任务,前端操作即可 |
| 公司级功能 | 基本没有,全要自己写 | 有分片、弹性扩容,但功能偏底层 | 自带日志、监控、告警、动态添加/停止任务 |
| 社区活跃度 | 传统但更新缓慢 | 老牌,但中文化文档不友好 | 中文文档完善,社区活跃,迭代频繁 |
| 接入Spring Boot成本 | 需要写Spring Integration配置 | 需要处理ZK连接 | 一个@XxlJob注解搞定 |
我实测过这三者的接入成本:Quartz最少要写一个SchedulerFactoryBean配置、一个JobDetail工厂、一个CronTriggerFactoryBean,再加上数据源和事务配置,项目里瞬间多出一堆样板代码。ElasticJob在旧版本里要连ZooKeeper,虽然3.x版本用ClusterBootstrap简化了一些,但如果你公司没有专门的ZK集群,光维护这个依赖就够呛。
XXL-JOB的优势在于"调度"这件事被做成了纯粹的服务端能力。调度中心独立部署,通过可视化界面管理执行器、任务、cron表达式,业务侧只要保证执行器能连上调度中心即可。调度中心挂了一台,执行器会自动感知并切换到另一台,业务代码完全不用感知这个切换过程。对于多数团队来说,这就够了。
2.3 版本选型:Spring Boot版本太高是第一个坑
我这次整合时踩的第一个坑,就是Spring Boot版本兼容性。我初始项目用的是Spring Boot 3.2.x,JDK 17,直接引入官方最新的xxl-job 2.4.1版本,结果启动时报错ClassNotFoundException: javax.servlet.*。原因很清楚:Spring Boot 3.x的底层Servlet容器从javax.servlet迁移到了jakarta.servlet,而XXL-JOB默认的依赖还是老一代javax命名空间。
这个问题有两种解决路径。第一种是降低Spring Boot版本到2.x系列(比如2.7.x),配合xxl-job 2.4.0使用,兼容性最稳,很多生产项目都是这个组合。第二种是保留Spring Boot 3.x,自己处理依赖替换——把xxl-job相关的javax.servlet-api排除掉,换成jakarta.servlet-api。
我自己试过第二种路径,能用,但会踩到一些奇怪的问题,比如调度中心后台jar包的Jetty版本冲突。这里给一个最实际的建议:如果你不是非要上Spring Boot 3.x不可,直接用Spring Boot 2.7.x + xxl-job 2.4.0,省去大量折腾时间。我最终落地也是这个组合,生产环境跑了一整年没出过兼容性大问题。
下面是标准的Maven依赖配置,注意版本号是实测兼容的组合:
xml复制<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
<version>2.4.0</version>
</dependency>
3. 环境准备:调度中心部署与初始化
3.1 调度中心到底是什么
先直观理解调度中心。它本质上是一个独立的Web应用,职责有四个:管理执行器(注册、监控、自动发现);管理任务(创建、修改、暂停、删除任务,配置cron和路由策略);触发任务(到时间了通知对应的执行器干活);记录结果(收集任务执行日志、调度日志、失败告警)。
调度中心需要搭配一个MySQL数据库来存储这些元数据和日志。所以整个架构最低要求是三件套:一台可以访问的MySQL、一个调度中心实例、若干接入Spring Boot的执行器应用。调度中心本身就一个可执行Jar包,部署方式极其粗暴——jar包丢服务器上,配好数据库连接,java -jar启动,完事。
XXL-JOB官方文档里说得很明白,调度中心支持集群部署。如果公司对可用性要求高,可以起两个调度中心实例,指向同一个MySQL库,通过nginx负载均衡对外提供服务。执行器注册到调度中心是通过HTTP接口做的,多个调度中心实例之间没有复杂的状态同步问题,因为状态都在数据库里,这点设计确实让运维省心。
3.2 初始化数据库表
先从Gitee或GitHub下载xxl-job源码包,解压后找到/doc/db/tables_xxl_job.sql,这个文件就是全部初始化SQL。
整个初始化过程核心就一步:在MySQL里新建一个库,比如xxl_job,然后执行这个SQL脚本。脚本会自动创建包括xxl_job_registry(执行器注册表)、xxl_job_info(任务配置表)、xxl_job_log(调度日志表)、xxl_job_user(后台用户表)等核心表结构,以及一条默认的管理员账号admin/123456。
说说数据库选型。xxl-job官方建议MySQL 5.7及以上,我自己实际用MySQL 8.0也完全没有问题。需要注意两点:一是数据库编码统一用utf8mb4,避免执行日志里出现emoji或非常规字符时写入报错;二是xxl_job_log这张表的数据量会随着调度频率增长得非常快,一个每分钟跑一次的任务,一天就是1440条日志记录,所以上线前必须配置好日志自动清理任务。
3.3 修改配置并启动调度中心
拿到源码后找到调度中心模块xxl-job-admin,编辑application.properties,核心配置就这几个:
properties复制server.port=8080
# 数据源
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# 告警邮箱(可选,但建议配一个)
spring.mail.host=smtp.qq.com
spring.mail.username=xxx@qq.com
spring.mail.password=授权码
补充一个经验:如果你是想快速体验,不建议自己从源码编译打包调度中心,直接去GitHub Releases页面下载编译好的xxl-job-admin-2.4.0.jar,改好配置就能跑。这一步能帮你节省至少十分钟的Maven打包时间。
启动成功后访问http://localhost:8080/xxl-job-admin,用admin/123456登录后台。看到"执行器管理"、"任务管理"、"调度日志"这些菜单,调度中心就算起来了。
这里有一个小坑值得提前说:调度中心本身是一个Spring Boot应用,如果你本机8080端口被占用,需要改server.port,同时后续执行器配置的admin.addresses也要跟着改。否则执行器会默认朝8080注册,两个端口对不上,执行器一直显示离线,排查起来很恼火。
4. 执行器接入:Spring Boot里的三步配置法
4.1 执行器的本质是什么
执行器是嵌在业务应用里的一个组件,职责是接收调度中心下发的指令,找到对应的任务Handler并执行,然后把执行结果和日志回传给调度中心。注意,执行器不是一个独立的进程,而是Spring Boot应用中的一个模块,与应用同生命周期。
理解了这个模型,你就会明白xxl-job的任务分发机制:调度中心并不直接在业务应用里执行代码,它只是告诉你"该干活了",具体怎么干、干什么,完全由执行器根据任务ID和Handler名称,在本地找到对应的Bean方法去执行。这也是我能用@XxlJob注解把一个普通的Spring Bean方法直接暴露成"定时任务"的根本原因。
4.2 核心配置类
引入xxl-job-core依赖后,下一步是创建一个XxlJobConfig配置类。这部分的配置项就四个:调度中心地址、执行器名称、执行器端口、日志路径。我贴一份我常用的配置类:
java复制@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 executorAddress;
@Value("${xxl.job.executor.ip}")
private String executorIp;
@Value("${xxl.job.executor.port}")
private int executorPort;
@Value("${xxl.job.executor.logpath}")
private String logPath;
@Value("${xxl.job.executor.logretentiondays}")
private int logRetentionDays;
@Bean
public XxlJobSpringExecutor xxlJobExecutor() {
XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
xxlJobSpringExecutor.setAppname(appname);
xxlJobSpringExecutor.setAddress(executorAddress);
xxlJobSpringExecutor.setIp(executorIp);
xxlJobSpringExecutor.setPort(executorPort);
xxlJobSpringExecutor.setAccessToken(accessToken);
xxlJobSpringExecutor.setLogPath(logPath);
xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);
return xxlJobSpringExecutor;
}
}
这里重点解释几个容易出问题的配置项。
appname是执行器的唯一标识,调度中心后台添加执行器时会让你填一个"AppName",两边必须完全一致。我见过最多的问题就是这边配置文件里写的appName-xxl-job-executor-sample,调度中心那边却简写成了xxl-job-executor,结果永远注册不上。
accessToken是调度中心和执行器之间的通信凭证,默认可以不配,但我强烈建议配一个。生产环境里不配token意味着任何知道调度中心地址的人都能注册一个执行器,安全性隐患很大。调度中心后台在/xxl-job-admin系统配置页面设置同样的token值才能对接上,两边也要保持一致。
executorPort是执行器自身开启的HTTP端口,调度中心通过这个端口反向调用执行器。注意这个端口不能在业务应用代码里被占用了。比如你的Spring Boot主端口是8080,执行器端口就不能也写8080。我一般习惯用9999这个端口,并在@SpringBootApplication的main方法里显式关闭Spring Boot自带的JMX端口,避免随机端口分配干扰执行器注册:
java复制SpringApplication app = new SpringApplication(XxlJobApplication.class);
app.setEnvironmentPrefix("spring");
app.run(args);
4.3 application.properties中的落地配置
配置文件如下:
properties复制# 调度中心地址
xxl.job.admin.addresses=http://127.0.0.1:8080/xxl-job-admin
# 执行器AppName,与调度中心后台执行器配置保持一致
xxl.job.executor.appname=order-service-executor
# 访问令牌,与调度中心后台一致
xxl.job.accessToken=default_token
# 执行器通讯端口
xxl.job.executor.port=9999
# 执行器日志保存路径
xxl.job.executor.logpath=/data/applogs/xxl-job/jobhandler/
# 日志保留天数
xxl.job.executor.logretentiondays=30
补充一个和配置无关、但和网络环境强相关的点:如果你的执行器应用部署在Docker容器里,而且用的是bridge网络模式,执行器向调度中心注册的IP默认会是容器的内部IP,比如172.17.0.2。调度中心拿到这个IP后发起HTTP通讯时是访问不通的。解决方式是设置xxl.job.executor.ip为宿主机IP,或者用host网络模式启动容器。很多人在容器环境里部署报"执行器通讯失败",九成是这个原因。
4.4 在调度中心后台注册执行器
配置完成、Spring Boot应用启动成功后,回到调度中心后台,进入"执行器管理"页面,点击"新增执行器"。
AppName填刚才配置里的order-service-executor,名称随意填比如"订单服务执行器",注册方式选"自动注册"。保存后稍等几秒钟,刷新页面,就能看到该执行器由离线变为在线,同时下方会显示执行器的IP和端口。到这一步,执行器和调度中心之间的"握手"就完成了。
这里要提醒一句:调度中心对执行器的在线状态感知是有延时机制的,默认大概30秒一次心跳检测。所以刚启动执行器时如果后台还显示离线,不用急着怀疑配置错了,等个30秒到1分钟再刷新看看。
5. 第一个分布式任务:从定义到跑通
5.1 用@XxlJob注解编写任务Handler
执行器注册好之后,开始写任务本体。XXL-JOB对任务的定义很灵活,一个任务可以是一个带@XxlJob注解的方法。看下面这个最简单的示例:
java复制@Component
public class OrderTimeoutHandler {
private static final Logger logger = LoggerFactory.getLogger(OrderTimeoutHandler.class);
@XxlJob("orderTimeoutClose")
public void orderTimeoutClose() {
// 模拟处理超时未支付订单
logger.info("开始处理超时未支付订单...");
// 业务逻辑...
logger.info("处理完成");
}
}
@XxlJob注解里的orderTimeoutClose就是任务Handler的名称,这个名字是整个链路里最关键的标识。调度中心创建任务时要填"JobHandler"参数,填的就是这里注解里的值。两边必须一模一样,差一个字母都跑不起来。
这里有个很重要的经验:@XxlJob方法不能有参数,因为执行器在下发任务时并不传参。如果你想给任务传参数,得通过XxlJobHelper.getJobParam()方法获取。比如任务要支持传入"批量处理条数"这类动态参数,可以这样写:
java复制@XxlJob("batchOrderHandler")
public void batchOrderHandler() {
String jobParam = XxlJobHelper.getJobParam();
int batchSize = 100;
if (StringUtils.hasText(jobParam)) {
batchSize = Integer.parseInt(jobParam);
}
// 按batchSize批量处理
}
在调度中心配置任务时,"任务参数"那一栏填什么,XxlJobHelper.getJobParam()拿到的就是什么。这样同一个任务,根据参数不同就能跑不同的业务逻辑,不用为了几个不同参数值各写一个Handler。这个技巧在实际项目中非常常用。
5.2 任务执行结果回传
不要用return或者直接logger.info来标记任务成功,XXL-JOB有自己的一套状态回传机制。标准做法是使用XxlJobHelper工具类:
java复制@XxlJob("orderTimeoutClose")
public void orderTimeoutClose() {
try {
// 业务处理逻辑
int count = processTimeoutOrders();
// 成功时,设置成功信息并返回
XxlJobHelper.log("成功处理 {} 条超时订单", count);
XxlJobHelper.handleSuccess("处理完成,共处理" + count + "条");
} catch (Exception e) {
XxlJobHelper.log("处理失败:{}", e.getMessage());
XxlJobHelper.handleFail("处理异常:" + e.getMessage());
}
}
调度中心的后台"调度日志"里,可以看到任务执行的成功/失败状态,以及通过XxlJobHelper.log写入的运行日志。如果你在代码里只是用logger.info打印日志,这些日志不会进调度中心的后台面板,排查问题时会非常痛苦,只能去服务器上看应用日志文件。
5.3 配置调度任务并触发执行
任务代码写好后,到调度中心"任务管理"页面,新增一个任务。关键配置项如下:
- 执行器:选择刚才注册的
order-service-executor - JobHandler:填
orderTimeoutClose(与注解名一致) - 调度类型:选Cron,配置表达式
- 运行模式:选Bean
- 路由策略:选第一个"第一个",或"轮询"
我用得最多的cron表达式是这几个:
text复制# 每天凌晨2点
0 0 2 * * ?
# 每5分钟一次
0 0/5 * * * ?
# 每30秒一次(测试用)
0/30 * * * * ?
保存后,可以在任务管理页面点击"执行一次"按钮,立即触发任务。再去"调度日志"页面查看本次调度的日志记录,如果显示"成功",说明全链路已经通了——调度中心触发了cron,通知了执行器,执行器找到了orderTimeoutClose这个Handler并执行,回传了成功状态。
6. 进阶实操:分片广播与动态任务处理
6.1 为什么需要分片任务
先讲一个场景。你的订单表有500万条数据要清理,单台执行器凌晨2点慢慢执行,可能要跑40分钟。如果下个月数据涨到1000万条,跑一次要一个半小时,凌晨2点开始,到3点半才结束,直接影响其他夜间批处理任务的资源。这种场景下,单点执行已经不够看了,需要做分片。
分片广播是XXL-JOB最实用的高级功能,思路很简单:调度中心同时通知所有注册的执行器实例,每个实例拿到"当前是第几台、总共有几台"的信息,然后各自只处理自己负责的那一部分数据。比如有3台执行器,每台分片编号分别是0、1、2,任务执行时对订单表按订单ID % 3来分片,每台只处理自己能整除的那一份。3台并行跑,处理时间理论上缩短到原来的三分之一。
6.2 分片参数的获取与使用
分片逻辑在代码里写起来非常简单:
java复制@XxlJob("orderShardingClean")
public void orderShardingClean() {
// 获取分片参数
int shardIndex = XxlJobHelper.getShardIndex();
int shardTotal = XxlJobHelper.getShardTotal();
XxlJobHelper.log("分片信息: index={}, total={}", shardIndex, shardTotal);
// 按分片条件查询数据,只处理当前分片负责的数据
List<Long> orderIds = orderMapper.findExpiredByShard(shardIndex, shardTotal);
for (Long orderId : orderIds) {
orderMapper.updateStatus(orderId, "CANCELED");
}
}
对应的SQL可以写成:
sql复制SELECT id FROM orders
WHERE status = 'UNPAID'
AND id % #{total} = #{index}
LIMIT 500
分片有一个要注意的点:如果数据量本身不大,强行分片反而会增加很多无意义的查询和空跑开销。比如只有100条数据要处理,一台机器1秒就搞定了,没必要分到3台。我实际经验是,单次处理时长超过3分钟、或者数据量超过10万条,才值得上分片广播。
6.3 调度中心配置分片路由策略
在调度中心创建任务时,路由策略下拉框中找到"分片广播",选中它。这样任务被触发后,所有注册的执行器都会同时收到调度指令。注意,分片广播和"轮询"、"第一个"这类单点路由策略是互斥的,它们在任务配置里只能选一种。
我见过不少人把分片广播理解为"多台机器轮流执行",这是一个很大的误解。轮询是指这次任务由A机器执行,下次由B机器执行,同一时刻只有一台在执行;分片广播则是同一时刻所有机器都在执行,各自处理一部分。如果你想要的是并行加速,选分片广播;如果你只是想负载均衡,选轮询就行。
6.4 动态任务:不重启就新增定时任务
除了预先在后台配置好cron表达式,XXL-JOB还支持通过接口动态创建任务。这个功能对那种"运营人员在后台配置营销活动,活动开始时间不定,需要临时生成一个定时开关任务"的场景特别有用。
核心API在XxlJobAdminApi里,通过AdminBiz接口调用。我用一个简单的封装示例说明:
java复制@Autowired
private XxlJobAdminApi xxlJobAdminApi;
public void createDynamicJob() {
XxlJobInfo jobInfo = new XxlJobInfo();
jobInfo.setJobGroup(1); // 执行器ID,可以通过查询获取
jobInfo.setJobDesc("动态创建的任务");
jobInfo.setAuthor("admin");
jobInfo.setScheduleType("CRON");
jobInfo.setScheduleConf("0 0 3 * * ?");
jobInfo.setExecutorHandler("dynamicJobHandler");
jobInfo.setExecutorRouteStrategy("FIRST");
jobInfo.setExecutorBlockStrategy("SERIAL_EXECUTION");
jobInfo.setGlueType("BEAN");
ReturnT<String> result = xxlJobAdminApi.addJob(jobInfo);
}
注意jobGroup不是执行器的AppName字符串,而是执行器在调度中心表里的数字ID。得先通过XxlJobAdminApi.queryJobGroup()查到对应的ID再使用。
动态任务的优点很明显,新增任务不用改代码、不用重启应用。但运维上要小心,后台可视化任务管理页面能看到所有动态创建的任务,权限把控靠调度中心的账号体系,所以动态任务的创建接口在生产环境最好只向内网开放,不要暴露到公网。
7. 踩坑实录:我整合过程中遇到的5个经典问题
7.1 执行器一直显示离线
这个问题在社区问答里出现频率最高。排查顺序我建议按下面来:
- 第一步,确认调度中心和应用之间的网络连通性。在应用所在服务器上执行
curl http://{调度中心IP}:8080/xxl-job-admin,通不通就很清楚了。 - 第二步,确认执行器的AppName与后台配置一致,注意大小写和空格。
- 第三步,看应用启动日志,有没有类似
xxl-job register executor success的日志。 - 第四步,确认
accessToken两边一致。token不匹配,注册请求会被直接拒绝,日志里会看到The access token is invalid。
一个冷知识:如果你多次改过执行器的端口或IP,调度中心后台可能有旧的心跳记录缓存,等一会儿,或者到xxl_job_registry表里清掉旧记录再刷新。这种玄学问题我遇到过两次,清完之后立即恢复。
7.2 任务执行报错502/连接超时
常见于执行器部署在内网、调度中心部署在另一台机器的情况下。调度中心要回调执行器的9999端口,这个端口需要在安全组、防火墙策略里放通。排查时直接用调度中心所在的机器执行telnet {执行器IP} 9999,能通再继续查其他原因。
7.3 Spring Boot 3.x + JDK 17兼容性问题
前文已经提到过,这里给一个更细的说明。如果你的团队已经全面用Spring Boot 3.x + JDK 17,不想为了xxl-job降级,可以用一种比较干净的方案:引入xxl-job源码,把xxl-job-core模块里所有依赖javax.servlet的类,手工改成jakarta.servlet后重新打包。这个工作量说大不大,说小也不小,核心改动集中在XxlJobExecutor对静态文件服务的处理上。如果项目里不是必须升级Boot版本,我真的不建议在生产环境冒这个险。稳定压倒一切,这个道理在做技术选型时永远适用。
7.4 任务偶发不执行
这个问题的原因非常多,最常见的一种是:执行器线程池被占满。XXL-JOB的XxlJobExecutor内部默认维护了一个线程池处理任务,如果某个任务跑太慢,比如一个批处理任务执行了5分钟还没结束,而cron表达式每1分钟触发一次,线程池很快就被占满,新来的任务只能排队,甚至被阻塞策略丢弃。
后台任务配置里的"阻塞处理策略"选项,默认是"丢弃后续调度"。意思就是任务已经在执行了,新的触发不会重新起一个线程,而是直接被丢弃。有些业务场景希望任务串行排队执行,那就把阻塞策略改成"单机串行"。这个选择要结合任务的实际行为来定,不是一刀切的。
7.5 调度日志显示失败但业务代码里没报错
这个问题让人很挠头。我遇到过的情况是:任务方法内部自己try-catch吞掉了异常,没有调用XxlJobHelper.handleFail(),所以调度中心这边还能看到任务一直在跑,其实业务逻辑已经挂了。所以写任务Handler时的铁律是:凡是有业务异常发生,必须在catch块里主动调用XxlJobHelper.handleFail();不想让异常中断后续处理时,也要在catch里把失败原因用XxlJobHelper.log()记录清楚,让调度日志成为可靠的排错入口。
8. 生产环境的进阶配置与经验总结
8.1 调度中心集群部署
如果公司对调度中心可用性有要求,最省事的方案就是双实例部署加一层负载均衡。部署两个xxl-job-admin实例,共享同一个MySQL库,nginx把请求分发到两个实例上。执行器配置的admin.addresses填nginx的地址就行。实测双实例模式下,一台实例宕机,另一台在几秒内就能接管后续调度任务,对执行器无感,非常稳。
这种部署模式下,数据库连接一定要配好连接池上限。两个调度中心实例同时查任务表、写日志,虽然读写量不大,但如果日志清理配置不当,xxl_job_log表膨胀,SQL性能会明显下降。我给自己项目的日志清理策略是:默认保留30天,每天凌晨4点自动清理过期日志。XxlJob自带的清理任务在调度中心的后台配置里就能开,不需要额外写脚本。
8.2 执行器线程池参数调优
默认的XxlJobExecutor线程池参数对于绝大多数场景已经够用,但有一个参数值得关注:maxPoolSize。如果你的任务大部分是分片广播类型,3台执行器同时收到任务时,每台会分配一个线程,这个还好。但如果一次性触发了30个任务,每台执行器就需要30个线程,这种情况下默认配置可能会有限制。我是这样改的:
java复制@Bean
public XxlJobSpringExecutor xxlJobExecutor() {
XxlJobSpringExecutor executor = new XxlJobSpringExecutor();
// ...其他配置
executor.setMaxPoolSize(50);
executor.setCorePoolSize(10);
// ...
return executor;
}
需要注意线程不是越多越好。线程一多,CPU上下文切换开销直线上升,任务执行效率反而可能下降。我建议corePoolSize保持在10左右,maxPoolSize控制在50以内,除非有非常明确的并发需求。
8.3 告警通知配置
调度中心的告警功能非常实用。后台"系统管理"->"用户管理"里可以给每个用户配置邮箱,然后任务配置里勾选"调度失败告警",任务失败时调度中心会发邮件通知。更灵活的做法是通过XxlJobHelper在代码里手动发送通知,比如任务失败后调用业务侧已有的通知服务(钉钉、企业微信webhook),这样告警就能直达团队群聊。
我实际做的告警方案是:任务失败时,catch块里调用一个AlertService.sendToDingTalk("任务xxx失败", 异常信息),配上钉钉机器人,半夜出问题大家手机能收到提醒。这个逻辑是纯业务层面的,跟xxl-job本身的告警不冲突,双保险效果最好。
8.4 与Spring Boot生态的整合经验
项目里除了xxl-job,一般还会整合mybatis-plus、redis、nacos等组件。在执行器Handler里注入这些服务的实例,跟注入普通Spring Bean完全一样,@Autowired按需注入即可。唯一要注意的是,任务方法本身不能@Transactional直接包住长事务里的复杂业务逻辑——批处理任务里,事务开得过大会导致数据库连接长时间被占用,经常出现"连接池耗尽"的故障。我处理这类问题的原则是:拆批提交,每处理完一小批就提交一次事务,配合分片广播,整体效率会有非常明显的提升。
8.5 写在最后的个人经验
整套xxl-job整合下来,我最大的感受是:这个框架把分布式调度里的脏活累活都藏起来了,你只需要理解三个核心概念——调度中心、执行器、JobHandler,然后把业务方法暴露成Handler,剩下的路由、重试、日志、监控都是"配置即可用"的成熟能力。对于团队来说,它还有一个容易被忽略的优势:运营和开发可以同时在可视化后台看到任务状态,沟通排查问题的成本大幅降低。
如果你现在是多实例部署、还在硬扛@Scheduled的重复执行问题,不用犹豫,花一下午按这篇文章的路径走一遍,很多人跑通第一个分布式任务时都会有一种"怎么这么简单"的感觉。如果你已经在用xxl-job,希望文章里关于分片广播、动态创建任务、线程池调优和故障排查的这些经验,能给你后续优化提供一些思路。这个框架入门的门槛很低,但真正用好,需要你对任务模型、阻塞策略、路由策略这些细节有透彻的理解——这些才是生产环境稳定运行的关键。
