2653 字
约 8 分钟
22
S06-X05 · XXL-Job:分布式定时任务的调度模型

@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. 自测

  1. 三个副本的服务用 @Scheduled,如何在不引入 XXL-Job 的情况下避免重复执行?代价是什么?
  2. 分片广播的"分片总数"由什么决定,扩容后会怎样?
  3. COVER_EARLY 为什么可能产生两条同时运行的执行记录?
  4. 执行器注册成功但不触发任务,第一个该查什么?
  5. FAILOVER 用在非幂等任务上会发生什么?
  6. 从 @Scheduled 迁到 @XxlJob,最容易漏删的是什么?
S06-X05 · XXL-Job:分布式定时任务的调度模型
https://www.clxhxhhr.top/posts/5652/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。