Skip to content

定时任务调度 ​

定时任务调度深度聚合:覆盖 Spring @Scheduled、Quartz、XXL-Job 三大主流方案的原理、配置、集群与生产实践,含 cron 表达式详解、MQ 延迟消息边界与面试考点。与《消息队列》配合阅读。


一、任务调度概述与场景 ​

1.1 什么是任务调度 ​

任务调度指按照预定的时间规则或事件规则,自动触发一段业务逻辑执行。它是企业应用里最普遍的后台能力,从"每天凌晨跑对账"到"订单 30 分钟未支付自动关闭",背后都是任务调度。

从触发语义上,任务调度可以分成两大类:

类型触发语义代表实现
定时任务按 cron 表达式或固定周期"到点就执行",通常周期性重复Spring @Scheduled、Quartz、XXL-Job
延迟任务某个业务事件发生后,等待 N 秒或到某个时间点再执行一次RocketMQ 延迟消息、RabbitMQ 延迟插件、DelayQueue、时间轮

1.2 定时任务与延迟任务的区别 ​

对比维度定时任务延迟任务
触发源时间规则(cron / 周期)业务事件(下单、支付回调、状态变更)
执行次数通常周期性、多次通常一次(相对事件延迟一次)
触发时刻是否可预期可提前算出(下一次触发时间)事件发生时刻不确定,只能知道"事件后多久"
状态管理任务本身独立,靠调度器维护常与业务单据状态绑定(待支付→已取消)
典型例子每日报表、定时备份、缓存刷新订单超时取消、短信延迟提醒、审批超时自动通过

一个直观的例子:每天 0 点统计昨日流水是定时任务;用户下单后 30 分钟未支付则自动关单是延迟任务——它只在"下单"这个事件发生后才开始计时。

1.3 典型业务场景一览 ​

场景触发方式说明
报表与对账每日/每月定点低频、大数据量、错过要补跑
数据清理与归档每日凌晨删除过期日志、冷数据迁移
缓存预热/刷新周期高峰前把热点数据刷进缓存
状态机扫描高频轮询扫描超时未支付的订单、超时未确认收货
消息补偿周期扫描本地消息表,重推发送失败的消息
数据同步周期主从/异构系统间定期拉取增量
健康检查与心跳高频探活、上报指标

1.4 cron 表达式详解(重点) ​

cron 一词源自 Unix 的守护进程,本意是"用一行字符串描述什么时候执行"。Java 生态使用 Quartz 风格的 6 段式(可扩展为 7 段带年),比 Linux 的 5 段式多了一个"秒"字段。

位置字段取值范围支持的特殊字符
第 1 位秒0-59, - * /
第 2 位分0-59, - * /
第 3 位时0-23, - * /
第 4 位日(月中第几天)1-31, - * / ? L W
第 5 位月1-12 或 JAN-DEC, - * /
第 6 位周(星期几)见下方"数字语义的坑", - * / ? L #
第 7 位(可选)年1970-2099, - * /(Quartz 支持,Spring 不支持)

特殊字符含义表:

字符含义说明与示例
*任意值该字段的每个单位都命中
?不指定只能用在"日"和"周"字段;两者互斥,指定了其中一个,另一个必须用 ?
-区间9-18 表示 9 到 18 点
,枚举MON,WED,FRI 表示周一、三、五
/步长0/5 表示从 0 开始每 5 个单位一次;* 等价于 0/1 的简写
L最后日字段 L=当月最后一天;周字段 6L=当月最后一个周五;L-3=倒数第 3 天
W最近工作日只能用在日字段,15W=距离 15 号最近的工作日(15 号为周六则提前到周五)
LW最后工作日当月最后一个工作日
#第几个星期几只能用在周字段,6#3=当月第 3 个周五

周字段数字语义的坑(面试高频):

实现数字与星期的对应说明
Linux crontab0-6,0 表示周日(部分实现 0 和 7 都表示周日)5 段式,无秒字段
Quartz / XXL-Job1-7,1 表示周日,7 表示周六与 Java Calendar 常量一致(SUNDAY=1)
Spring @Scheduled遵循常见 cron 约定:0-6 表示周日到周六,7 也表示周日官方明确与 Quartz 的数字语义不同

Spring 5.3 起官方文档明确指出:Spring 的 CronExpression 严格遵循 cron 惯例(0-6 表示 SUN-SAT,7 也代表 SUN),而 Quartz 偏离该惯例使用 1-7(1=SUN)。因此同一表达式 0 0 9 * * 1 在 Quartz 里是"每周日 9 点",在 Spring 里却是"每周一 9 点"。生产建议:周字段一律写英文缩写 SUN-SAT,彻底绕开歧义。

1.5 cron 常用写法速查表 ​

需求Quartz / Spring 表达式(6 段)
每 5 分钟执行一次0 0/5 * * * ?
每小时的 30 分执行0 30 * * * ?
每 2 小时执行0 0 */2 * * ?
每天 02:00 执行0 0 2 * * ?
工作日 09:00 执行0 0 9 ? * MON-FRI
每天 9 点和 18 点各一次0 0 9,18 * * ?
每月 1 号 0 点执行0 0 0 1 * ?
每月最后一天 23:30 执行0 30 23 L * ?
每周一 10:00 执行0 0 10 ? * MON
每季度首日执行0 0 0 1 1,4,7,10 ?

注意:Spring @Scheduled 不接受带"年"的第 7 段,写成 7 段会直接抛 IllegalStateException(启动即失败)。

1.6 Java 任务调度技术演进与分布式的核心矛盾 ​

演进路线:

Timer / ScheduledExecutorService(JUC)
   -> Spring @Scheduled(注解化,单体友好)
      -> Quartz(框架级:持久化、集群)
         -> XXL-Job / Elastic-Job / PowerJob(分布式任务调度平台)
Timer / ScheduledExecutorService(JUC)
   -> Spring @Scheduled(注解化,单体友好)
      -> Quartz(框架级:持久化、集群)
         -> XXL-Job / Elastic-Job / PowerJob(分布式任务调度平台)

分布式的核心矛盾:单体时代一个定时任务只跑一次,代码里 @Scheduled 即可。一旦应用多实例部署,每个实例都会执行同一段定时逻辑,出现重复对账、重复发券、重复推送。要解决"一个任务在集群中只执行一次、且可观测可运维",只有两条路:

  1. 加分布式锁(Redis SETNX / ZooKeeper / 数据库锁),轻量但锁的续期、释放、故障转移都要自己处理;
  2. 换专用调度框架(Quartz 集群 / XXL-Job),由框架保证触发互斥,并提供重试、告警、路由、分片等能力。

二、Spring @Scheduled 与 @EnableScheduling ​

2.1 快速上手 ​

java
@Configuration
@EnableScheduling // 开启定时任务能力
public class ScheduleConfig {
}

@Component
public class ReportTask {

    // 每天凌晨 2 点执行报表生成
    @Scheduled(cron = "0 0 2 * * ?")
    public void generateDailyReport() {
        System.out.println("生成日报表: " + LocalDateTime.now());
    }
}
@Configuration
@EnableScheduling // 开启定时任务能力
public class ScheduleConfig {
}

@Component
public class ReportTask {

    // 每天凌晨 2 点执行报表生成
    @Scheduled(cron = "0 0 2 * * ?")
    public void generateDailyReport() {
        System.out.println("生成日报表: " + LocalDateTime.now());
    }
}

三步即可运行:启动类或配置类加 @EnableScheduling、任务类交给 Spring 管理(@Component)、方法上标 @Scheduled。方法要求 public void 且无参数。

2.2 三种触发方式对比 ​

属性语义触发时间计算适用场景
fixedRate固定速率上一次开始时间 + 间隔每隔固定时间执行一次的轮询
fixedDelay固定延迟上一次结束时间 + 间隔任务耗时不固定、怕重叠的执行
cron日历表达式由 cron 规则推算指定具体时刻/复杂规则
java
@Scheduled(fixedRate = 5000)          // 每 5 秒一次(按开始时间算)
@Scheduled(fixedDelay = 5000)         // 上次执行结束后 5 秒再执行
@Scheduled(initialDelay = 10000, fixedDelay = 5000) // 启动 10 秒后首跑
@Scheduled(cron = "0 0/5 * * * ?")    // 每 5 分钟的 0 秒执行
@Scheduled(fixedRate = 5000)          // 每 5 秒一次(按开始时间算)
@Scheduled(fixedDelay = 5000)         // 上次执行结束后 5 秒再执行
@Scheduled(initialDelay = 10000, fixedDelay = 5000) // 启动 10 秒后首跑
@Scheduled(cron = "0 0/5 * * * ?")    // 每 5 分钟的 0 秒执行

关键区别:fixedRate 如果上一次执行耗时超过间隔,由于默认单线程调度器不会并发执行同一个任务,下一次触发会"排队等待",实际表现是执行完立即接着跑;fixedDelay 则天然保证两次执行之间至少隔一个间隔,常用于防止任务重叠。

2.3 异步执行与线程池配置 ​

Spring 默认只创建一个调度线程,所有 @Scheduled 任务共用它。如果某个任务内部是阻塞调用(远程 RPC、大批量 SQL),会占住调度线程,导致其它任务"到点不触发"。

yaml
spring:
  task:
    scheduling:
      pool:
        size: 5            # 调度线程池大小(负责"到点触发")
        thread-name-prefix: scheduling-
    execution:
      pool:
        core-size: 8       # @Async 业务执行线程池
        max-size: 16
        queue-capacity: 1000
spring:
  task:
    scheduling:
      pool:
        size: 5            # 调度线程池大小(负责"到点触发")
        thread-name-prefix: scheduling-
    execution:
      pool:
        core-size: 8       # @Async 业务执行线程池
        max-size: 16
        queue-capacity: 1000

更彻底的方案是把任务的"触发"和"执行"分离——触发线程只负责提交,真正干活放到 @Async 线程池:

java
@Component
public class NotifyTask {

    @Async("notifyExecutor")
    @Scheduled(cron = "0 0/1 * * * ?")
    public void sendBatchNotify() {
        // 每分钟触发一次,实际执行进入异步线程池,不阻塞调度线程
    }
}
@Component
public class NotifyTask {

    @Async("notifyExecutor")
    @Scheduled(cron = "0 0/1 * * * ?")
    public void sendBatchNotify() {
        // 每分钟触发一次,实际执行进入异步线程池,不阻塞调度线程
    }
}

注意:@Async + @Scheduled 组合下每次触发都新建一次异步任务,不再有"同一任务排队互斥"的语义,是否允许并发执行要自己评估。

2.4 动态修改 cron(SchedulingConfigurer) ​

注解里的 cron 是编译期写死的,想改要发版。用 SchedulingConfigurer 可以把 cron 挪到数据库/配置中心,运行时生效:

java
@Component
public class DynamicCronTask implements SchedulingConfigurer {

    @Autowired
    private TaskCronMapper taskCronMapper;

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.addTriggerTask(
            () -> doWork(),   // 任务体
            triggerContext -> {
                String cron = taskCronMapper.selectCronByTaskName("reportTask");
                if (cron == null || cron.isEmpty()) {
                    return null; // 返回 null 表示暂停调度
                }
                return new CronTrigger(cron).nextExecutionTime(triggerContext);
            }
        );
    }

    private void doWork() { /* 业务逻辑 */ }
}
@Component
public class DynamicCronTask implements SchedulingConfigurer {

    @Autowired
    private TaskCronMapper taskCronMapper;

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.addTriggerTask(
            () -> doWork(),   // 任务体
            triggerContext -> {
                String cron = taskCronMapper.selectCronByTaskName("reportTask");
                if (cron == null || cron.isEmpty()) {
                    return null; // 返回 null 表示暂停调度
                }
                return new CronTrigger(cron).nextExecutionTime(triggerContext);
            }
        );
    }

    private void doWork() { /* 业务逻辑 */ }
}

局限:这只是"表达式动态化",不解决多实例重复执行问题,集群场景仍需分布式锁或调度平台。

2.5 @Scheduled 底层原理简析 ​

整个链路如下:

@EnableScheduling
  -> 注册 ScheduledAnnotationBeanPostProcessor
     -> 扫描容器中 @Scheduled 方法,封装成 ScheduledTask
        -> 注册进 ScheduledTaskRegistrar
           -> 交给 TaskScheduler(默认单线程 ScheduledExecutorService)
              -> cron 用 CronTrigger 推算 nextExecutionTime,到点执行
@EnableScheduling
  -> 注册 ScheduledAnnotationBeanPostProcessor
     -> 扫描容器中 @Scheduled 方法,封装成 ScheduledTask
        -> 注册进 ScheduledTaskRegistrar
           -> 交给 TaskScheduler(默认单线程 ScheduledExecutorService)
              -> cron 用 CronTrigger 推算 nextExecutionTime,到点执行

Spring Boot 场景下默认使用自动配置的 ThreadPoolTaskScheduler,线程数即上文 spring.task.scheduling.pool.size(默认 1)。想精细控制可自行声明 TaskScheduler Bean 覆盖默认。

2.6 多实例部署下的重复执行问题 ​

问题:订单服务部署 2 台,@Scheduled(cron = "0 0 2 * * ?") 的"超时关单扫描"两台都会跑,出现重复处理。

解决方案做法缺点
环境开关只留一个调度节点通过 @Scheduled + 配置条件让任务只在主实例启用主实例宕机需人工切换,无自动故障转移
分布式锁任务开头抢 Redis/DB/ZK 锁,抢到才执行锁实现要自己兜底续期与异常释放
ShedLock注解化分布式锁,配合 @Scheduled只防重复、不防丢失,详见 5.3
引入 XXL-Job调度中心统一触发,执行器只被动执行引入平台与运维成本

结论:实例数少且可接受"主节点手动切换"用环境开关最省事;正经的分布式环境建议直接上调度平台,把"只执行一次"交给框架保证。

三、Quartz 详解 ​

3.1 Quartz 定位与核心概念 ​

Quartz 是 Java 生态最老牌的框架级任务调度库(OpenSymphony 出品,被 Spring 集成)。与 @Scheduled 最大的不同:任务定义(JobDetail)、触发规则(Trigger)、调度器(Scheduler)三者分离,且支持把任务持久化到数据库。

概念说明
Job要执行的业务逻辑接口,只有一个 execute(JobExecutionContext) 方法
JobDetailJob 的静态描述:绑定哪个 Job 类、JobKey、分组、携带的参数
Trigger触发规则:什么时候触发、触发多少次(SimpleTrigger/CronTrigger 等)
Scheduler调度器:把 JobDetail 和 Trigger 组装起来统一调度
JobDataMapJob 执行时可获取的参数容器
JobStore任务与触发器的存储介质(内存 / JDBC)

3.2 Job 与 JobDetail 的关系 ​

Quartz 每次触发时会通过反射 jobClass.newInstance() 创建新的 Job 实例,执行完即丢弃——因此 Job 默认是"无状态"的,不能在 Job 字段里保存跨次执行的中间数据(线程安全因此天然满足)。需要保留状态时用 JobDataMap 或 @PersistJobDataAfterExecution。

JobDetail 才是"任务本体",包含 JobKey(全局唯一标识)、Job 类、JobDataMap:

java
JobDetail jobDetail = JobBuilder.newJob(MyJob.class)
        .withIdentity("dailyReport", "reportGroup")
        .usingJobData("type", "DAY")   // 静态参数写入 JobDataMap
        .build();

Trigger trigger = TriggerBuilder.newTrigger()
        .withIdentity("dailyReportTrigger", "reportGroup")
        .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?"))
        .build();

scheduler.scheduleJob(jobDetail, trigger);
JobDetail jobDetail = JobBuilder.newJob(MyJob.class)
        .withIdentity("dailyReport", "reportGroup")
        .usingJobData("type", "DAY")   // 静态参数写入 JobDataMap
        .build();

Trigger trigger = TriggerBuilder.newTrigger()
        .withIdentity("dailyReportTrigger", "reportGroup")
        .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?"))
        .build();

scheduler.scheduleJob(jobDetail, trigger);

一个 JobDetail 可以挂多个 Trigger(同一套参数、不同时间规则触发多次),也可以多个 JobDetail 指向同一个 Job 类(不同参数)。

3.3 JobDataMap 与有状态 Job ​

JobDataMap 本质是 Map<String, Object>,在 Job 中通过 context.getMergedJobDataMap() 读取。注意两点:存入的值必须可序列化(JDBC JobStore 要落库);Trigger 上的 JobDataMap 与 JobDetail 上的会合并,Trigger 的同名键覆盖 JobDetail 的。

java
public class MyJob implements Job {
    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        JobDataMap data = context.getMergedJobDataMap();
        String type = data.getString("type");
        // 执行业务
    }
}
public class MyJob implements Job {
    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        JobDataMap data = context.getMergedJobDataMap();
        String type = data.getString("type");
        // 执行业务
    }
}

两个重要注解:

注解作用
@DisallowConcurrentExecution同一个 JobDetail 的上一次执行未结束时,不允许并发触发下一次(防止同一任务重叠)
@PersistJobDataAfterExecution执行结束后把修改过的 JobDataMap 持久化,供下次执行使用(实现跨次状态)

注意:@DisallowConcurrentExecution 只约束"同一个 JobDetail",不同 JobDetail 之间不互斥;且 Quartz 集群下依赖调度器是集群共享状态才生效。

3.4 Trigger 体系 ​

Trigger 类型触发规则适用场景
SimpleTrigger固定间隔 + 固定次数(可设 startTime/endTime)一次性任务、固定次数的定时执行
CronTriggercron 表达式,日历规则最强复杂的"每天/每周/每月"定点任务
CalendarIntervalTrigger按"自然日/周/月/年"为间隔如每隔 2 个月执行(月间隔无法用 SimpleTrigger 精确表达)
DailyTimeIntervalTrigger每天内按时间段+间隔如每天 9:00-18:00 每 30 分钟一次

Trigger 也支持分组和名称(TriggerKey),便于统一管理。调度时通过 scheduler.pauseTrigger/resumeTrigger/unscheduleJob 控制。

3.5 Misfire(错过触发)策略 ​

Misfire 指"本该触发却没触发"的情况,典型原因:线程池无空闲线程、调度器停机中、集群节点宕机后恢复。系统会为错过的触发计算补跑策略,阈值由 org.quartz.jobStore.misfireThreshold 控制(默认 60 秒)。

以 CronTrigger 为例的常用策略:

策略行为适用场景
withMisfireHandlingInstructionIgnoreMisfires立即补跑所有错过的触发,再按正常节奏继续报表类,错过必须补
withMisfireHandlingInstructionFireAndProceed立即补跑最近一次错过,后续按正常节奏希望"尽快补一次"
withMisfireHandlingInstructionDoNothing丢弃所有错过的触发,等下一个正常触发点心跳、缓存刷新类,错过就错过
java
Trigger trigger = TriggerBuilder.newTrigger()
        .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?")
                .withMisfireHandlingInstructionDoNothing())
        .build();
Trigger trigger = TriggerBuilder.newTrigger()
        .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?")
                .withMisfireHandlingInstructionDoNothing())
        .build();

3.6 Spring Boot 集成 Quartz ​

Spring Boot 提供了官方 starter,配置层面对外暴露 SchedulerFactoryBean:

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
java
public class DailyReportJob extends QuartzJobBean {
    @Override
    protected void executeInternal(JobExecutionContext context) {
        // 继承 QuartzJobBean 可直接注入 Spring Bean 执行
    }
}
public class DailyReportJob extends QuartzJobBean {
    @Override
    protected void executeInternal(JobExecutionContext context) {
        // 继承 QuartzJobBean 可直接注入 Spring Bean 执行
    }
}
yaml
spring:
  quartz:
    job-store-type: jdbc          # 任务持久化到数据库
    jdbc:
      initialize-schema: never    # 已手动建表则 never
    properties:
      org.quartz.scheduler.instanceName: MyScheduler
      org.quartz.scheduler.instanceId: AUTO   # 集群时自动生成实例 ID
      org.quartz.threadPool.threadCount: 10   # 调度线程数
      org.quartz.jobStore.isClustered: true   # 开启集群
      org.quartz.jobStore.clusterCheckinInterval: 15000
spring:
  quartz:
    job-store-type: jdbc          # 任务持久化到数据库
    jdbc:
      initialize-schema: never    # 已手动建表则 never
    properties:
      org.quartz.scheduler.instanceName: MyScheduler
      org.quartz.scheduler.instanceId: AUTO   # 集群时自动生成实例 ID
      org.quartz.threadPool.threadCount: 10   # 调度线程数
      org.quartz.jobStore.isClustered: true   # 开启集群
      org.quartz.jobStore.clusterCheckinInterval: 15000

再通过 SchedulerFactoryBean.getScheduler() 或 @PostConstruct 里注册 JobDetail/Trigger 即可。Spring Boot 会自动从容器中查找 JobDetail 与 Trigger Bean 并注册进 Scheduler。

3.7 JobStore:内存与 JDBC 持久化 ​

对比维度RAMJobStoreJDBCJobStore
存储位置内存数据库
重启后任务全部丢失自动恢复继续调度
集群支持不支持支持(配合 isClustered)
性能高每次调度有 DB 读写,吞吐受限
适用单体、任务可丢需要持久化与集群的正式环境

JDBC JobStore 需要初始化约 12 张 qrtz_ 前缀的表,关键几张:

表作用
qrtz_job_detailsJobDetail 定义(Job 类、JobDataMap)
qrtz_triggers触发器主表
qrtz_cron_triggers / qrtz_simple_triggers各类型触发器的扩展信息
qrtz_fired_triggers当前正在执行的触发
qrtz_scheduler_state集群节点状态(含检查时间戳)
qrtz_locks集群互斥用的行锁表

建表脚本由 Quartz 发行包提供(docs/dbTables/tables_mysql_innodb.sql 等)。

3.8 Quartz 集群模式原理 ​

Quartz 集群是"无中心"的对等架构:所有节点连同一个数据库,每个节点都是完整调度器,谁抢到任务谁执行。

                  +---------------------+
 节点 A ---------> |   共享数据库(MySQL)  | <--------- 节点 B
 (Scheduler)      | qrtz_locks 行锁     |      (Scheduler)
                  | qrtz_triggers       |
                  | qrtz_scheduler_state| <--------- 节点 C
                  +---------------------+
                  +---------------------+
 节点 A ---------> |   共享数据库(MySQL)  | <--------- 节点 B
 (Scheduler)      | qrtz_locks 行锁     |      (Scheduler)
                  | qrtz_triggers       |
                  | qrtz_scheduler_state| <--------- 节点 C
                  +---------------------+

执行流程:

  1. 每个节点周期性向 qrtz_scheduler_state 上报心跳(默认约 15 秒 check-in);
  2. 触发前,节点对 qrtz_locks 中对应行执行 SELECT ... FOR UPDATE 拿锁;
  3. 拿锁成功后查询到期的 Trigger,更新其下一次触发时间,再释放锁;
  4. 宕机节点未完成的任务按 misfire 策略由其它节点补跑。

集群模式的局限(也是被 XXL-Job 等平台替代的原因):所有触发都要抢数据库锁,调度吞吐受 DB 限制;节点时钟不同步会引发 misfire 风暴;没有管理界面、没有失败重试/路由/告警等运维能力。

四、XXL-Job 详解(重点) ​

4.1 定位与总体架构 ​

XXL-Job 是国内使用最广的分布式任务调度平台(许雪里开源)。核心设计思想是"调度中心"与"执行器"分离:调度中心只管按 cron 触发,执行器只负责在业务应用里执行 JobHandler,两者通过 RPC 通信,天然支持调度中心集群 + 执行器集群。

        +--------------------- 调度中心集群 (xxl-job-admin) ---------------------+
        |   内嵌 Quartz 负责 cron 触发,共享 MySQL,DB 锁保证同时只有一台在调度  |
        +------------------------------------+--------------------------------+
                                             | 触发请求 (RPC/HTTP)
                                             v
        +--------------------- 执行器集群 (业务应用内嵌) -----------------------+
        |   执行器启动后向调度中心注册(AppName+IP:Port),周期性心跳上报          |
        |   收到触发后从线程池取线程调用对应 @XxlJob Handler                    |
        +---------------------------------------------------------------------+
        +--------------------- 调度中心集群 (xxl-job-admin) ---------------------+
        |   内嵌 Quartz 负责 cron 触发,共享 MySQL,DB 锁保证同时只有一台在调度  |
        +------------------------------------+--------------------------------+
                                             | 触发请求 (RPC/HTTP)
                                             v
        +--------------------- 执行器集群 (业务应用内嵌) -----------------------+
        |   执行器启动后向调度中心注册(AppName+IP:Port),周期性心跳上报          |
        |   收到触发后从线程池取线程调用对应 @XxlJob Handler                    |
        +---------------------------------------------------------------------+

调度与执行解耦带来的直接收益:任务代码改动不用重启调度中心;执行器扩容缩容调度中心自动感知;任务失败、日志、告警全部沉淀在调度中心统一查看。

4.2 核心概念 ​

概念说明
调度中心(admin)独立部署的 Web 服务,管理任务、触发调度、收集日志、发告警
执行器(executor)嵌入业务应用的组件,注册到调度中心,真正跑任务
任务(JobInfo)一条调度配置:执行器 + JobHandler + cron + 路由策略 + 阻塞策略等
JobHandler执行器里用 @XxlJob("名称") 标注的任务方法,调度中心按名称路由
调度日志每次触发的记录,含调度结果、执行结果、执行日志
Glue 模式任务代码托管在调度中心数据库,远程下发执行

执行器有两种注册方式:自动注册(启动后上报,调度中心动态维护在线列表)和手动录入(机器无法反向连接时的兜底)。

4.3 调度中心搭建 ​

shell
# 1. 下载源码并导入数据库(doc/db/tables_xxl_job.sql)
mysql -uroot -p xxl_job < tables_xxl_job.sql

# 2. 修改调度中心配置(xxl-job-admin 的 application.properties)
server.port=8080
spring.datasource.url=jdbc:mysql://localhost:3306/xxl_job
spring.datasource.username=root
spring.datasource.password=root

# 3. 打包部署(默认管理员账号 admin / 123456)
mvn package -DskipTests
java -jar xxl-job-admin/target/xxl-job-admin-2.4.0.jar
# 1. 下载源码并导入数据库(doc/db/tables_xxl_job.sql)
mysql -uroot -p xxl_job < tables_xxl_job.sql

# 2. 修改调度中心配置(xxl-job-admin 的 application.properties)
server.port=8080
spring.datasource.url=jdbc:mysql://localhost:3306/xxl_job
spring.datasource.username=root
spring.datasource.password=root

# 3. 打包部署(默认管理员账号 admin / 123456)
mvn package -DskipTests
java -jar xxl-job-admin/target/xxl-job-admin-2.4.0.jar

访问 http://localhost:8080/xxl-job-admin 即进入控制台,可新增执行器、新建任务、查看调度日志。

4.4 执行器接入与 Spring Boot 集成 ​

xml
<dependency>
    <groupId>com.xuxueli</groupId>
    <artifactId>xxl-job-core</artifactId>
    <version>2.4.0</version>
</dependency>
<dependency>
    <groupId>com.xuxueli</groupId>
    <artifactId>xxl-job-core</artifactId>
    <version>2.4.0</version>
</dependency>
yaml
xxl:
  job:
    admin:
      addresses: http://localhost:8080/xxl-job-admin   # 调度中心地址(集群用逗号分隔)
    accessToken: default_token                        # 与调度中心保持一致,否则注册/回调被拒
    executor:
      appname: order-executor                          # 执行器名称,调度中心按它分组
      ip: ''                                           # 为空则自动探测,容器/NAT 环境需手动指定
      port: 9999                                       # 执行器 RPC 端口
      logpath: /data/applogs/xxl-job/jobhandler/       # 执行日志目录
      logretentiondays: 30                             # 日志保留天数
xxl:
  job:
    admin:
      addresses: http://localhost:8080/xxl-job-admin   # 调度中心地址(集群用逗号分隔)
    accessToken: default_token                        # 与调度中心保持一致,否则注册/回调被拒
    executor:
      appname: order-executor                          # 执行器名称,调度中心按它分组
      ip: ''                                           # 为空则自动探测,容器/NAT 环境需手动指定
      port: 9999                                       # 执行器 RPC 端口
      logpath: /data/applogs/xxl-job/jobhandler/       # 执行日志目录
      logretentiondays: 30                             # 日志保留天数
java
@Configuration
public class XxlJobConfig {

    @Bean
    public XxlJobSpringExecutor xxlJobExecutor() {
        XxlJobSpringExecutor executor = new XxlJobSpringExecutor();
        executor.setAdminAddresses("http://localhost:8080/xxl-job-admin");
        executor.setAppname("order-executor");
        executor.setPort(9999);
        executor.setLogPath("/data/applogs/xxl-job/jobhandler/");
        return executor;
    }
}
@Configuration
public class XxlJobConfig {

    @Bean
    public XxlJobSpringExecutor xxlJobExecutor() {
        XxlJobSpringExecutor executor = new XxlJobSpringExecutor();
        executor.setAdminAddresses("http://localhost:8080/xxl-job-admin");
        executor.setAppname("order-executor");
        executor.setPort(9999);
        executor.setLogPath("/data/applogs/xxl-job/jobhandler/");
        return executor;
    }
}

任务方法示例:

java
@Component
public class OrderJobHandler {

    @XxlJob("orderCloseHandler")
    public void closeTimeoutOrder() {
        XxlJobHelper.log("开始扫描超时订单");
        String param = XxlJobHelper.getJobParam();   // 获取调度中心下发的任务参数
        // ... 执行业务
        XxlJobHelper.handleSuccess("本次关闭订单 1024 单");  // 主动上报成功及结果
    }
}
@Component
public class OrderJobHandler {

    @XxlJob("orderCloseHandler")
    public void closeTimeoutOrder() {
        XxlJobHelper.log("开始扫描超时订单");
        String param = XxlJobHelper.getJobParam();   // 获取调度中心下发的任务参数
        // ... 执行业务
        XxlJobHelper.handleSuccess("本次关闭订单 1024 单");  // 主动上报成功及结果
    }
}

XxlJobHelper 的日志会随执行日志回传调度中心,支持在 Web 控制台滚动查看,这是排查问题的主要手段。

4.5 路由策略 ​

调度中心触发时,需要从该执行器的多个在线实例中选一个(或全部)来执行,路由策略决定了怎么选:

路由策略行为适用场景
第一个 / 最后一个固定选注册列表第一个/最后一个实例基本不用
轮询依次轮流分发默认最常用,负载均衡
随机随机选一个与轮询类似
一致性 HASH按 JobId 哈希选固定实例同一任务总是打在同一台,便于本地缓存
最不经常使用(LFU)选调用次数最少的实例追求各实例次数均衡
最近最久未使用(LRU)选最久没被调用的实例同上,按时间维度
故障转移依次尝试,调用失败的实例自动剔除换下一个对"必须成功执行一次"的任务友好
忙碌转移检测到实例忙碌(正在执行同任务)则换一台避免任务重叠
分片广播把任务广播给该执行器全部实例大数据量任务按分片并行处理(见 4.6)

路由策略 + 阻塞处理策略是 XXL-Job 控制"任务分发与并发"的两个旋钮,高可用场景常用"故障转移/忙碌转移",大数据场景用"分片广播"。

4.6 分片广播与分片任务设计 ​

分片广播触发时,每个在线实例都会收到任务,通过 XxlJobHelper.getShardIndex()(本机分片号,从 0 开始)和 getShardTotal()(总分片数)协作切分数据:

java
@XxlJob("bigDataScanHandler")
public void bigDataScanHandler() {
    int shardIndex = XxlJobHelper.getShardIndex();
    int shardTotal = XxlJobHelper.getShardTotal();

    XxlJobHelper.log("分片处理: index=" + shardIndex + ", total=" + shardTotal);

    // 每条数据只归一个分片处理:user_id % total == index 的本机处理
    List<Long> userIds = userService.scanByShard(shardIndex, shardTotal, 1000);
    for (Long userId : userIds) {
        // 处理该用户数据
    }
}
@XxlJob("bigDataScanHandler")
public void bigDataScanHandler() {
    int shardIndex = XxlJobHelper.getShardIndex();
    int shardTotal = XxlJobHelper.getShardTotal();

    XxlJobHelper.log("分片处理: index=" + shardIndex + ", total=" + shardTotal);

    // 每条数据只归一个分片处理:user_id % total == index 的本机处理
    List<Long> userIds = userService.scanByShard(shardIndex, shardTotal, 1000);
    for (Long userId : userIds) {
        // 处理该用户数据
    }
}

配合 SQL:WHERE user_id % #{shardTotal} = #{shardIndex},或用 user_id 的哈希取模保证分布均匀。

典型场景与设计要点:

场景说明
千万级用户消息推送按 user_id 分片,每台只推自己那 1/N
全表数据补偿/修复分片并行扫描,速度提升 N 倍
大批量数据迁移各分片处理互不重叠的数据区间

要点:分片任务天然并行,但每片内部仍可能因失败重试而重复处理某条数据,行级幂等(唯一键/状态机)依然要设计;实例上下线会导致 total 变化,任务里应"用本次触发的 total/index 计算",不要缓存死。

4.7 阻塞处理策略与失败重试 ​

阻塞处理策略:上一次调度还没执行完,下一次触发又来了,怎么办?

策略行为适用
单机串行排队,上一次执行完再执行下一次默认推荐,任务不能并发
丢弃后续调度直接丢弃新触发,日志记录"丢弃"高频任务,跑一次即可
覆盖之前调度终止上一次执行,用新触发覆盖只关心最新一次结果的场景

失败重试:任务配置里可以设置"失败重试次数",调度中心在任务执行失败或执行超时后按次数重新触发。注意:

  • 重试是"整任务重新触发",不是断点续跑,所以任务必须幂等;
  • 超时时间要按任务实际耗时评估,设太短会导致"还在正常跑就被判超时重试"。

4.8 调度类型与动态配置 ​

调度中心为任务支持的调度类型:

调度类型说明
cronQuartz 风格表达式,最常用
固定速度按固定间隔触发(等价 fixedRate)
固定延迟上次执行结束后隔固定时长再触发(等价 fixedDelay)

动态配置能力:在控制台可随时新增/编辑/暂停/恢复任务、修改 cron 与参数、手动"执行一次",全部实时生效、无需重启任何服务——这是相比注解式调度(@Scheduled/Quartz)最直接的运维优势。

4.9 Glue 模式 ​

Glue 模式下任务代码不再打包在应用里,而是以文本形式托管在调度中心数据库,执行器每次执行前动态拉取、编译(Java)或直接执行(Shell/Python 等脚本),实现"改任务代码不用发版"。

Glue 类型说明
GLUE(Java)代码存 DB,执行器拉取后动态编译加载,适合快速热更的 Java 逻辑
GLUE(Shell) / GLUE(Python) 等直接执行脚本,适合运维类任务,天然跨语言

适用:临时脚本、需要频繁调整的运营任务。不适合:核心业务逻辑(代码分散在 DB 中,难 review、难做版本管理)。

4.10 任务日志与告警 ​

  • 日志:执行器把日志写入本地 logpath,调度中心通过 RPC 拉取在线滚动查看;业务日志用 XxlJobHelper.log(...) 输出,与调度日志聚合展示。日志默认保留天数由 logretentiondays 控制。
  • 告警:任务支持配置失败告警,内置邮件告警(在调度中心配置邮箱账号);钉钉/企微通知可基于调度中心扩展。建议把"执行失败 + 重试后仍失败"作为告警触发点,避免失败重试期间的重复报警。
  • 报表:控制台提供调度趋势、调度/执行失败量统计,用于日常巡检。

4.11 调度中心集群部署与数据库表 ​

调度中心支持多实例部署:多个 admin 实例共享同一个 xxl_job 库,内嵌 Quartz 以集群模式运行(数据库锁互斥,同一时刻只有一台 admin 实际执行调度),前端用 Nginx 做负载均衡即可。执行器侧配置多个 admin 地址会自动做容灾切换。

xxl_job 库核心表:

表名作用
xxl_job_info任务配置(cron、路由、阻塞、JobHandler 等)
xxl_job_log每次调度的日志(调度结果/执行结果/触发器日志地址)
xxl_job_logglueGlue 模式的任务代码
xxl_job_registry执行器在线注册信息(心跳维护)
xxl_job_group执行器分组(AppName 维度)
xxl_job_lock集群调度互斥锁
xxl_job_user登录用户

4.12 XXL-Job 的可靠性边界 ​

需要客观认识它的边界:调度中心通过轮询数据库触发,触发粒度是秒级,不适合毫秒级高频调度(那是 MQ/自研时间轮的领域);任务触发与执行结果之间是"尽力而为 + 重试"语义,不能替代业务级幂等;调度中心是独立组件,本身需要部署与运维(虽然很轻)。

五、其他方案对比(Elastic-Job/PowerJob/ShedLock) ​

5.1 Elastic-Job ​

ElasticJob 是当当开源、后捐给 Apache 的分布式调度方案,现隶属 Apache ShardingSphere 生态。与 XXL-Job 的"中心调度"不同,它采用 ZooKeeper 协调的无中心架构:各应用实例作为对等节点,通过 ZK 选举分片、感知上下线。

特性说明
注册中心ZooKeeper(节点注册、分片协调、失效转移)
分片作业按分片总数分配,弹性伸缩自动重分片
错过作业重触发支持(Misfire 处理)
运维提供简易控制台,能力弱于 XXL-Job

适合:已在重度使用 ZooKeeper 的团队、需要强一致分片协调的场景。代价:需要额外维护 ZK 集群。

5.2 PowerJob ​

PowerJob(原 OhMyScheduler)是后起之秀,定位"分布式调度 + 分布式计算":

特性说明
架构调度服务器 + Worker,类似 XXL-Job 的调度中心/执行器
任务类型单机/广播/MapReduce(可拆分子任务并行计算)
工作流支持 DAG 编排任务依赖
秒级调度支持秒级与固定延迟任务,灵活性更好
控制台自带较完善的 Web 控制台

适合:需要任务编排(DAG)、MapReduce 式批计算、对技术新颖度有偏好的团队。

5.3 ShedLock ​

ShedLock 不是调度框架,而是给 Spring @Scheduled 加分布式锁的轻量库:多个实例都想执行同一个 @Scheduled 方法时,只有一个能抢到锁执行。

java
@Scheduled(cron = "0 0 2 * * ?")
@SchedulerLock(name = "dailyReportTask", lockAtMostFor = "15m", lockAtLeastFor = "1m")
public void dailyReport() { ... }
@Scheduled(cron = "0 0 2 * * ?")
@SchedulerLock(name = "dailyReportTask", lockAtMostFor = "15m", lockAtLeastFor = "1m")
public void dailyReport() { ... }
要点说明
锁存储JDBC、MongoDB、Redis、ZooKeeper 等
lockAtLeastFor锁至少持有时间,防止时钟不同步导致并发抢锁
lockAtMostFor锁最多持有时间,防止节点宕机锁不释放
局限只防"重复执行",不防"任务丢失"(持锁节点宕机期间任务不执行)

适用:已经从 @Scheduled 起步、实例数不多、不想引入调度平台的中小型系统。

5.4 MQ 的延迟消息能力盘点(与《消息队列》呼应) ​

中间件延迟消息能力关键机制
RocketMQ4.x 仅 18 个固定延迟等级;5.x 支持任意时间(时间轮)消息先进入 SCHEDULE_TOPIC_XXXX,到期由 ScheduleMessageService 扫描转发回原 Topic
RabbitMQDLX+TTL 模拟 / 官方延迟插件(任意延迟)死信队列转发;延迟交换机(已在《消息队列》2.4 详述)
Kafka无原生延迟消息需自研:时间轮组件、按延迟分桶的 delay topic + 定时扫描等

RocketMQ 4.x 默认 18 个延迟等级(通过 messageDelayLevel 配置,可改):

shell
# broker 配置,等级从 1 开始
messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
# broker 配置,等级从 1 开始
messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
java
Message msg = new Message("ORDER_TOPIC", "closeOrder".getBytes());
msg.setDelayTimeLevel(16);   // 等级 16 = 30 分钟,订单 30 分钟未支付则取消
producer.send(msg);
Message msg = new Message("ORDER_TOPIC", "closeOrder".getBytes());
msg.setDelayTimeLevel(16);   // 等级 16 = 30 分钟,订单 30 分钟未支付则取消
producer.send(msg);

注意等级是"相对发送时刻的固定档位",1-18 级没有 15 分钟等中间档位,选型时要确认业务延迟值能对上档位。

5.5 调度框架 vs MQ 延迟消息:如何选择 ​

对比维度调度框架(XXL-Job/Quartz)MQ 延迟消息
触发模型时间规则(cron/周期/固定延迟)业务事件 + 相对延迟
任务来源预先配置、周期性重复事件驱动、每次动态产生
可靠性触发后执行,失败可重试可告警消息持久化,消费失败进重试队列
运维界面有控制台可管理可查看依赖消息中间件的控制台
典型场景每日对账、定时报表、批量扫描订单超时关单、延迟通知、事件补偿

选择判断口诀:触发源是"时间"还是"业务事件 + 相对延迟"。

  • 每天凌晨 2 点跑对账、每 5 分钟扫一次缓存 → 调度框架;
  • 用户下单后 30 分钟未支付要关单(每笔订单的 30 分钟是独立的)→ MQ 延迟消息更自然,量大时还能削峰;
  • 需要"运营手动重推、可查询待处理列表"的补偿场景 → 调度框架轮询状态表更合适;
  • 高可靠业务常用双保险:MQ 延迟消息到期触发主流程,定时扫描状态表兜底(防止 MQ 消息丢失导致状态卡死)。

六、选型对比总表 ​

6.1 四大方案综合对比 ​

对比维度Spring @ScheduledQuartzXXL-JobElastic-Job
使用成本最低,一个注解中等,概念多中等,需部署调度中心高,需维护 ZK
集群/防重复无,需自加分布式锁支持(DB 集群模式)支持(调度中心+执行器集群)支持(ZK 协调)
任务持久化无JDBC JobStore调度中心 DBZK + 本地
管理界面无无完善(任务/日志/告警/报表)简单控制台
动态任务改代码(或用 SchedulingConfigurer 动态化 cron)代码注册控制台可视化增删改,实时生效代码 + 控制台
失败重试/告警无无(只有 misfire)有(重试次数 + 邮件/扩展告警)有失效转移
分片/路由无无10 种路由策略 + 分片广播分片作业
学习与运维成本极低低-中中(多一个组件要部署)中-高
社区活跃度随 Spring 迭代老牌稳定,更新放缓国内最流行,更新活跃相对平稳
适用规模单体/任务少需要持久化的单体或小集群中大规模分布式系统(推荐)重度 ZK 团队

6.2 选型决策建议 ​

  • 单体应用、任务个位数、可接受重启丢任务:@Scheduled 够用,最多加个 ShedLock;
  • 任务重要、需要"宕机恢复后不丢调度"、不想引入平台:Quartz + JDBC JobStore 集群;
  • 分布式多实例、需要可视化运维/告警/分片/动态配置:XXL-Job 是当前 Java 团队事实标准,优先选它;
  • 已有 ZooKeeper 基础设施、任务天然按分片切分:Elastic-Job;
  • 需要 DAG 工作流或 MapReduce 批计算:PowerJob 更合适;
  • 避免一上来就上平台:任务总量不到几十个、实例不超过三五台的场景,@Scheduled + 分布式锁的复杂度远低于维护一套调度平台。

七、生产实践与常见坑 ​

7.1 时区问题 ​

cron 的"2 点"是按调度器所在 JVM 的默认时区推算的。常见事故:服务器时区被设成 UTC,0 0 2 * * ? 实际在北京时间 10 点执行;或调度中心与执行器分处不同时区导致日志时间错乱。

  • 服务器统一 Asia/Shanghai(或全 UTC),并显式统一;
  • Spring 中可指定时区:new CronTrigger(cron, TimeZone.getTimeZone("Asia/Shanghai"));
  • XXL-Job 保证调度中心服务器时区正确,任务日志时间以调度中心为准;
  • 代码里计算业务时间不要依赖 new Date() 的本地时区,用 ZoneId 显式指定。

7.2 服务器集群时钟同步 ​

Quartz 集群的 misfire 判定、XXL-Job 的超时判定、雪花 ID 的时间戳都依赖各节点时钟一致。时钟漂移的后果:明明只触发一次的任务被判定 misfire 补跑、任务日志时间倒挂、生成重复 ID。

shell
# chrony(推荐,CentOS/RHEL 系)
yum install -y chrony
systemctl enable --now chronyd

# 或 ntpdate 立即校准(Windows 服务器开启 W32Time 自动同步)
ntpdate -u ntp.aliyun.com
# chrony(推荐,CentOS/RHEL 系)
yum install -y chrony
systemctl enable --now chronyd

# 或 ntpdate 立即校准(Windows 服务器开启 W32Time 自动同步)
ntpdate -u ntp.aliyun.com

云服务器默认开启 NTP,自建机房务必配置统一 NTP 源并纳入监控。

7.3 任务幂等设计 ​

无论用哪种调度框架,"重试"和"重复触发"都真实存在(XXL-Job 失败重试、Quartz misfire 补跑、多实例并发)。任务必须幂等,三板斧:

  1. 业务幂等键:处理前先插入/校验唯一键,重复执行直接跳过。例如发券任务用"任务批次号 + 用户 ID"做唯一约束;
  2. 状态机前置检查:只有 PENDING -> PROCESSING 才能继续,已处理的直接返回:
sql
-- 只有状态仍为待处理的行能被抢到(配合 UPDATE 的原子性)
UPDATE order SET status = 'CLOSING'
WHERE id = ? AND status = 'PENDING';
-- 影响行数为 0 说明已被其它执行处理,本任务直接跳过
-- 只有状态仍为待处理的行能被抢到(配合 UPDATE 的原子性)
UPDATE order SET status = 'CLOSING'
WHERE id = ? AND status = 'PENDING';
-- 影响行数为 0 说明已被其它执行处理,本任务直接跳过
  1. 结果可重复:把业务设计成"覆盖式"(幂等写、SETNX、Redis 去重),重复执行不产生副作用。

7.4 线程池参数 ​

  • 调度线程池与业务执行线程池分离:调度线程只做触发,耗时逻辑交业务线程池,避免相互拖累;
  • 队列有界 + 明确拒绝策略:无界队列在任务洪峰时会积压大量任务拖垮内存;
  • 与下游容量对齐:任务并发线程数要匹配数据库连接池、下游 RPC 线程池,否则任务并发上去先打垮的是数据库;
  • 监控:观察调度线程活跃度、队列深度、任务执行时长分布,长耗时任务优先拆片。

7.5 @Scheduled 不生效排查清单 ​

#症状原因检查点
1没加 @EnableScheduling启动类或配置类是否标注
2任务类未被 Spring 管理是否缺少 @Component/@Service
3方法 private / final@Scheduled 只支持 public 非 final 实例方法
4类内部 this 调用把逻辑拆到独立 Bean 方法上标注(@Scheduled 注解的方法本身由容器调度,自调用不影响,但 private 会)
5cron 写成 7 段(带年)启动报 cron expression must consist of 6 fields
6cron 语义理解错如想"每 5 分钟"却写 0 */5 * * * ? 的变体,先本地用 CronTrigger 推算几次 nextTime 验证
7时区不对服务器 UTC,任务按本地 8 点写却在 0 点跑
8多实例重复执行以为"没生效/重复执行",实则是集群每台各跑一次(见 2.6)

7.6 XXL-Job 回调/触发失败排查 ​

现象常见原因处理
调度中心显示"调度成功"但执行器没跑执行器没注册上/AppName 不匹配看执行器管理页是否在线,检查 appname 与 accessToken 是否一致
执行器注册地址不通自动探测到内网 IP 或容器漂移 IPxxl.job.executor.ip 手动指定对外可达地址
任务报了错却看不到日志日志目录无权限 / logpath 不存在保证 logpath 目录存在且可写,业务日志用 XxlJobHelper.log 输出
回调失败导致任务显示执行中调度中心访问不到执行器(防火墙/回调地址)确认调度中心到执行器端口网络连通
失败后被疯狂重试重试次数配大 + 任务不幂等任务加幂等,重试次数按需收敛
日志时间错乱/超时误判调度中心与执行器时间不同步统一 NTP(见 7.2)

7.7 长任务与任务拆分 ​

不要在定时任务里放"小时级"的单体 SQL/单线程循环。实践:

  • 能拆则拆:XXL-Job 分片广播把数据按片切分并行(4.6);
  • 拆不了就做进度自管理:任务内分批处理(每批 1000 条),处理进度写入任务状态表,重试时从断点继续;
  • 设定合理的执行超时与告警阈值,超过阈值要能看到是"慢"还是"卡死"(是否有死锁/外部依赖 hang)。

八、面试考点速查 ​

8.1 Q1:cron 表达式每 5 分钟怎么写?Quartz 与 Linux cron 有什么不同? ​

0 0/5 * * * ?(秒 分 时 日 月 周 六段式)。Linux crontab 是 5 段(分起头,无秒);Quartz/Spring 是 6 段,从秒开始。周字段数字语义不同:Linux 0=周日,Quartz 1=周日,Spring 遵循 0-6(0 和 7 均为周日)——详见 1.4。

8.2 Q2:fixedRate 与 fixedDelay 有什么区别? ​

fixedRate 按上一次开始时间加间隔计算下次触发;fixedDelay 按上一次结束时间加间隔。前者关心"频率",执行耗时长时会排队追赶;后者保证"两次之间至少间隔多久",天然防重叠。

8.3 Q3:服务多实例部署,@Scheduled 定时任务重复执行怎么办? ​

思路:要么只让一个实例调度,要么让多次执行互斥。方案由轻到重:环境开关指定单一调度节点(无自动故障转移)→ ShedLock/分布式锁(Redis SETNX、ZooKeeper 临时节点、DB 行锁)→ 引入 XXL-Job 由调度中心统一触发。核心要点:任何方案都要配合任务幂等兜底。

8.4 Q4:分布式锁如何实现?各有什么特点? ​

方案原理优点缺点
Redis SETNX + 过期时间SET key value NX EX ttl,释放时 Lua 校验简单、性能高需处理锁续期(Redisson watchdog)与主从切换丢锁
ZooKeeper 临时顺序节点创建节点成功即抢到锁,会话断开自动释放无过期时间难题、强一致吞吐低、需维护 ZK
数据库唯一键/行锁INSERT 唯一键或 SELECT FOR UPDATE无额外组件性能差、有单点风险

8.5 Q5:分片任务怎么设计? ​

典型是 XXL-Job 分片广播:触发时每台执行器都执行,通过 XxlJobHelper.getShardIndex()/getShardTotal() 拿到自己的分片号,按 数据键 % total == index 只处理本片数据,互不重叠,整体并行 N 倍。设计要点:分片键分布要均匀;单片内仍需行级幂等(重试会重复);实例增减导致 total 变化,规则按"当次触发参数"计算。

8.6 Q6:Quartz 的核心概念和 Misfire 是什么? ​

四要素:Job(业务逻辑)、JobDetail(任务定义,含 JobDataMap)、Trigger(触发规则)、Scheduler(调度器)。Misfire 是"该触发没触发"(线程池忙/宕机恢复等),需配置补跑策略:IgnoreMisfires 全部补跑、FireAndProceed 补最近一次、DoNothing 丢弃错过。

8.7 Q7:Quartz 集群是怎么避免重复执行的? ​

多节点共享同一个数据库,节点对 qrtz_locks 表行做 SELECT ... FOR UPDATE 抢占,抢到锁的节点才去获取到期的 Trigger 并推进其下次触发时间;节点通过 qrtz_scheduler_state 周期性 check-in 心跳。本质是"数据库锁 + 时间戳抢占",吞吐受 DB 限制,且强依赖节点时钟同步。

8.8 Q8:XXL-Job 的架构与路由策略?故障转移和分片广播的区别? ​

调度中心(触发)+ 执行器(执行)分离,RPC 通信,执行器心跳注册。路由策略见 4.5。故障转移是"选一台执行,失败了换下一台",保证"至少一台成功执行";分片广播是"所有实例都执行,各自处理 1/N 数据",用于并行处理大数据量。前者处理"同一个任务",后者处理"切分后的子任务"。

8.9 Q9:定时任务和 MQ 延迟消息有什么区别?什么时候用哪个? ​

触发模型不同:定时任务按时间规则周期性触发;延迟消息由业务事件驱动、事件发生后延迟一次投递。周期性到点任务(对账、报表、扫描)用调度框架;"事件 + 相对延迟"(订单超时关单、延迟通知)用 MQ 延迟消息(RocketMQ 延迟等级 / RabbitMQ 延迟插件;Kafka 无原生延迟需自研)。关键业务可以"延迟消息 + 定时扫描兜底"双保险。

8.10 Q10:定时任务的幂等性怎么保证? ​

任务可能因重试、多实例、misfire 补跑重复执行。保证手段:唯一业务键去重(去重表/唯一索引)、状态机前置检查(只有 PENDING 才能被抢占为 PROCESSING)、结果覆盖式设计(幂等写)。原则是"重复执行与一次执行效果等价"。

8.11 Q11:@Scheduled 默认几个线程?一个任务阻塞会怎样? ​

Spring 默认单调度线程。一个任务内部阻塞(RPC/慢 SQL)会占住唯一的调度线程,其它任务到点无法触发,表现为"定时任务集体延迟/不执行"。解法:调大 spring.task.scheduling.pool.size、把耗时逻辑交给 @Async 线程池、或任务方法快速返回由业务线程池异步处理。

8.12 Q12:不改代码怎么动态调整定时任务的执行时间? ​

Spring 侧实现 SchedulingConfigurer 把 cron 存数据库/配置中心,触发时读取(见 2.4);更彻底的是用 XXL-Job,控制台直接改 cron/参数,实时生效无需发版。生产上建议把"执行时间可能被运营调整"的任务都做成可配置 cron。

8.13 Q13:调度中心挂了,任务还会执行吗? ​

Quartz 集群:单节点宕机,其它节点通过心跳感知后接管未完成任务,任务不丢。XXL-Job:任务配置与调度都在调度中心 DB,调度中心整体宕机期间不会有新触发,但任务不丢;恢复后按 misfire 策略/手动执行补跑。执行器侧不受调度中心宕机影响(它只被动接收触发)。结论:调度中心要做集群 + 数据库高可用,关键任务再配一份"业务侧状态扫描兜底"。

8.14 Q14:如何从可靠性角度评估一套调度方案? ​

看三层:不丢(触发记录持久化、宕机恢复补跑、misfire 策略)、不重(集群互斥、路由/阻塞策略、任务幂等)、可追溯(执行日志、失败告警、报表监控)。@Scheduled 三层都靠自己;Quartz 解决不丢与集群互斥;XXL-Job 三层基本内置,是面试与生产中最容易讲出体系的方案。