@Scheduled 在多副本部署下有一个无法回避的问题:三个 Pod,同一个 cron 会各跑一次。加 Redis 锁能挡住重复执行,但挡不住"这个任务到底跑没跑、跑了几秒、失败了几次"——这些信息全在各自的日志里。
XXL-Job 把这件事拆成两个进程:一个调度中心决定"什么时候、让谁跑",一个执行器只负责"跑"。任务记录、重试、告警、日志统一落在调度中心,副本数变化不影响调度语义。
1. 架构三角
| 角色 | 部署形态 | 职责 | 挂了的后果 |
|---|---|---|---|
调度中心 xxl-job-admin |
独立 Spring Boot 应用 + MySQL | 维护任务、算 cron、下发触发、收集日志与结果 | 所有任务停摆,执行器空转 |
| 执行器 | 业务应用内嵌(xxl-job-core) |
注册自己、接收触发、执行 @XxlJob 方法 |
该应用的任务全部"无可用执行器" |
| 注册中心 | xxl_job_registry 表 |
执行器每 30 秒心跳,调度中心按 appname 查活 | 调度中心找不到实例 |
按官方 2.4.x 文档核对,心跳间隔 30 秒,失效阈值 90 秒;这两个值决定了"停了实例之后多久不再派活"。
2. 与 @Scheduled 的本质区别
| 维度 | @Scheduled |
XXL-Job |
|---|---|---|
| 调度位置 | 每个实例本地算时间 | 中心统一算,指派到某一个实例 |
| 重复执行 | 必然重复,需要外部锁 | 默认不重复;分片广播才是有意为之 |
| 失败重试 | 无 | 可配重试次数,失败换实例 |
| 执行记录 | 只有应用日志 | xxl_job_log 表 + 在线日志查看 |
| 手工补跑 | 改代码或上机器 | 界面点"执行一次" |
| 动态改 cron | 要重启 | 界面改完立即生效 |
| 阻塞处理 | 无概念 | 串行 / 丢弃 / 覆盖三种策略 |
结论:单机、无副本、无审计需求的任务继续用 @Scheduled 更省事;只要出现"多副本"或"要能追查执行结果",就该换。
3. 接入:依赖、配置、执行器 Bean
// <dependency>
// <groupId>com.xuxueli</groupId>
// <artifactId>xxl-job-core</artifactId>
// <version>2.4.1</version>
// </dependency>
xxl:
job:
admin:
addresses: http://10.0.0.8:8080/xxl-job-admin
accessToken: ${XXL_JOB_TOKEN:default_token}
executor:
appname: clarity-executor
address: ""
ip: ""
port: 9999
logpath: /data/applogs/xxl-job/jobhandler
logretentiondays: 30
| 键 | 作用 | 写错的表现 |
|---|---|---|
admin.addresses |
调度中心地址,多个逗号分隔 | 执行器注册失败,xxl_job_registry 无记录 |
executor.appname |
执行器分组名,必须与界面新建执行器时一致 | 界面显示"无可用执行器" |
executor.ip |
留空自动取网卡 IP | 多网卡机器取错网段,中心连不上 |
executor.port |
内嵌 Netty 端口 | 与业务端口冲突则启动失败 |
executor.logpath |
任务日志落盘目录 | 容器内没挂卷,重建后日志丢失 |
accessToken |
中心与执行器之间的握手凭证 | 不一致只报注册失败,不提示原因 |
@Configuration
public class XxlJobConfig {
@Bean
public XxlJobSpringExecutor xxlJobExecutor(
@Value("${xxl.job.admin.addresses}") String addresses,
@Value("${xxl.job.accessToken}") String token,
@Value("${xxl.job.executor.appname}") String appname,
@Value("${xxl.job.executor.port}") int port,
@Value("${xxl.job.executor.logpath}") String logpath) {
XxlJobSpringExecutor executor = new XxlJobSpringExecutor();
executor.setAdminAddresses(addresses);
executor.setAccessToken(token);
executor.setAppname(appname);
executor.setPort(port);
executor.setLogPath(logpath);
executor.setLogRetentionDays(30);
return executor;
}
}
4. @XxlJob 的写法
@Component
public class ArticleSyncJob {
@XxlJob("articleSyncJob")
public void sync() {
String param = XxlJobHelper.getJobParam(); // 界面填的任务参数
long jobId = XxlJobHelper.getJobId();
try {
List<Long> ids = parseIds(param);
for (Long id : ids) {
articleService.syncToIndex(id);
XxlJobHelper.log("synced id={}", id); // 写入该次执行的日志文件
}
XxlJobHelper.handleSuccess("共 " + ids.size() + " 条");
} catch (Exception e) {
XxlJobHelper.log(e);
XxlJobHelper.handleFail("同步失败: " + e.getMessage());
}
}
}
三条纪律:方法必须无参无返回;业务异常自己捕获并 handleFail,否则框架只能记一条"执行异常"而没有上下文;XxlJobHelper.log 的日志写在该次执行的日志文件里,不要用 log.info 混进应用日志。
同一个类里多个 @XxlJob 的 value 必须全局唯一(按 appname 维度)。重名不会报错,只会有一个生效——取决于 Bean 扫描顺序。
5. 任务参数与分片广播
| 输入方式 | 取法 | 适用 |
|---|---|---|
| 界面"任务参数" | XxlJobHelper.getJobParam() |
日期区间、业务类型 |
| 分片序号 | XxlJobHelper.getShardIndex() |
0 到 total-1 |
| 分片总数 | XxlJobHelper.getShardTotal() |
等于在线执行器数量 |
| 调度时间 | XxlJobHelper.getJobLogFileName() 等 |
审计 |
2.3.0 之前用 ShardingUtil.getShardingVo().getIndex(),2.4.x 已统一到 XxlJobHelper,老代码升级时要改这一处。
@XxlJob("articleReindexShardingJob")
public void reindex() {
int index = XxlJobHelper.getShardIndex();
int total = XxlJobHelper.getShardTotal();
// 按主键取模分片,避免各实例扫全表
List<Long> ids = articleMapper.selectIdsByMod(index, total);
XxlJobHelper.log("shard {}/{} 处理 {} 条", index, total, ids.size());
ids.forEach(articleService::reindex);
}
分片总数是运行时在线实例数,不是配置值。扩容两个实例,下一次调度就变成 4 片。所以分片逻辑必须写成"按 total 取模",不能写死除数;同时要容忍 total 在两次调度之间变化——正在跑的那次不变,下一次才变。
6. 九种以上路由策略
按官方 2.4.x 的枚举,实际是 10 项:常说的"九种"漏了"最久未使用"。
| 策略 | 枚举 | 行为 | 适用 |
|---|---|---|---|
| 第一个 | FIRST |
固定选列表首个 | 单实例、调试 |
| 最后一个 | LAST |
固定选末个 | 灰度验证新实例 |
| 轮询 | ROUND |
依次轮转 | 负载均匀的等耗时任务 |
| 随机 | RANDOM |
随机选 | 无状态、耗时离散 |
| 一致性哈希 | CONSISTENT_HASH |
按任务 ID 哈希固定到某实例 | 需要亲和性(本地缓存) |
| 最不经常使用 | LEAST_FREQUENTLY_USED |
选累计调用最少的 | 长尾任务 |
| 最久未使用 | LEAST_RECENTLY_USED |
选最久没被调用的 | 同上,更平滑 |
| 故障转移 | FAILOVER |
失败后换实例重试 | 偶发失败、幂等任务 |
| 忙碌转移 | BUSYOVER |
跳过正在忙的实例 | 长任务、避免排队 |
| 分片广播 | SHARDING_BROADCAST |
广播到全部实例并给分片号 | 批量数据处理 |
选错策略的典型症状:用 ROUND 跑一个要 40 分钟的任务,第二天发现实例 A 排了三个、实例 B 闲着的;用 FAILOVER 跑非幂等任务,重试导致数据写两遍。
7. 阻塞处理策略
| 策略 | 枚举 | 行为 | 风险 |
|---|---|---|---|
| 单机串行 | SERIAL_EXECUTION |
同一任务在单实例内排队 | 堆积,cron 密集时越排越长 |
| 丢弃后续调度 | DISCARD_LATER |
忙时直接放弃本次 | 丢执行记录,需接受"少跑一次" |
| 覆盖之前调度 | COVER_EARLY |
中断旧任务,跑新的 | 旧任务可能停在一半 |
COVER_EARLY 的"中断"是 Thread.interrupt(),不是杀死线程。任务里有阻塞 IO 或没检查中断标志的循环,照样跑完,然后两条日志同时存在。写长任务必须自己响应中断。
8. 失败重试与告警
| 项 | 在哪配 | 说明 |
|---|---|---|
| 失败重试次数 | 任务编辑页 | 0 表示不重试;大于 0 时按"故障转移"逻辑换实例 |
| 任务超时时间 | 任务编辑页,单位秒 | 0 表示不限;超时记失败并中断线程 |
| 告警邮件 | 执行器配置里配邮箱 | 失败 / 超时 / 无可用执行器都会发 |
| 生命周期 | 调度中心侧 | 失败次数达到阈值可自动停用任务 |
重试只有配合幂等才有意义。幂等的实现方式:按业务唯一键 upsert、先写状态表再处理、用 jobId + shardIndex 做去重键。
9. 注册机制与常见故障
执行器启动时 POST 到调度中心注册,之后每 30 秒心跳一次,写进 xxl_job_registry:
SELECT registry_group, registry_key, registry_value, update_time
FROM xxl_job_registry
WHERE registry_key = 'clarity-executor';
update_time 停止前进就说明心跳断了。90 秒后调度中心把它判死,任务报"无可用执行器"。
| 现象 | 原因 | 处理 |
|---|---|---|
| 界面显示"无可用执行器" | appname 与界面不一致 / accessToken 不一致 / 网络不通 | 先查 xxl_job_registry 有无记录,再对齐三项配置 |
| 执行器注册了但任务不触发 | 调度中心 cron 时间与真实时间漂移 | 两边都开 NTP,timedatectl 核对 |
| 任务总在固定时刻被记失败 | 超时时间配得太小 | 调大,或改成"丢弃后续" |
| 日志目录越来越大 | logretentiondays 没配或配成 -1 |
设为 30,容器挂卷 |
| 多网卡机器注册了错误 IP | executor.ip 留空时取了错误网卡 |
显式写内网 IP |
| 同一任务被执行两次 | 用 @Scheduled 和 @XxlJob 同时挂了同一个方法 |
删掉本地的 @Scheduled |
最后一条很常见:迁移期为了"保险"保留原 @Scheduled,结果中心调一次、本地又跑一次。切换应当是一次性替换,而不是并存。
10. 边界
| 场景 | 更合适的方案 | 理由 |
|---|---|---|
| 单机、秒级、无审计 | @Scheduled |
引入调度中心不划算 |
| 毫秒级延迟要求 | 时间轮 / 延迟队列 | 调度中心最小粒度是秒 |
| 任务之间有依赖(DAG) | 调度平台(DolphinScheduler / Airflow) | XXL-Job 是一维 cron |
| 一次性补数据 | 手工脚本 + 审批 | 不该进任务表 |
| 流式持续消费 | MQ 消费者 | 定时任务不适合"一直跑" |
11. 自测
- 三个副本的服务用
@Scheduled,如何在不引入 XXL-Job 的情况下避免重复执行?代价是什么? - 分片广播的"分片总数"由什么决定,扩容后会怎样?
COVER_EARLY为什么可能产生两条同时运行的执行记录?- 执行器注册成功但不触发任务,第一个该查什么?
FAILOVER用在非幂等任务上会发生什么?- 从
@Scheduled迁到@XxlJob,最容易漏删的是什么?