定时任务调度
定时任务调度深度聚合:覆盖 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 crontab | 0-6,0 表示周日(部分实现 0 和 7 都表示周日) | 5 段式,无秒字段 |
| Quartz / XXL-Job | 1-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 即可。一旦应用多实例部署,每个实例都会执行同一段定时逻辑,出现重复对账、重复发券、重复推送。要解决"一个任务在集群中只执行一次、且可观测可运维",只有两条路:
- 加分布式锁(Redis SETNX / ZooKeeper / 数据库锁),轻量但锁的续期、释放、故障转移都要自己处理;
- 换专用调度框架(Quartz 集群 / XXL-Job),由框架保证触发互斥,并提供重试、告警、路由、分片等能力。
二、Spring @Scheduled 与 @EnableScheduling
2.1 快速上手
@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 规则推算 | 指定具体时刻/复杂规则 |
@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),会占住调度线程,导致其它任务"到点不触发"。
spring:
task:
scheduling:
pool:
size: 5 # 调度线程池大小(负责"到点触发")
thread-name-prefix: scheduling-
execution:
pool:
core-size: 8 # @Async 业务执行线程池
max-size: 16
queue-capacity: 1000spring:
task:
scheduling:
pool:
size: 5 # 调度线程池大小(负责"到点触发")
thread-name-prefix: scheduling-
execution:
pool:
core-size: 8 # @Async 业务执行线程池
max-size: 16
queue-capacity: 1000更彻底的方案是把任务的"触发"和"执行"分离——触发线程只负责提交,真正干活放到 @Async 线程池:
@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 挪到数据库/配置中心,运行时生效:
@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) 方法 |
| JobDetail | Job 的静态描述:绑定哪个 Job 类、JobKey、分组、携带的参数 |
| Trigger | 触发规则:什么时候触发、触发多少次(SimpleTrigger/CronTrigger 等) |
| Scheduler | 调度器:把 JobDetail 和 Trigger 组装起来统一调度 |
| JobDataMap | Job 执行时可获取的参数容器 |
| JobStore | 任务与触发器的存储介质(内存 / JDBC) |
3.2 Job 与 JobDetail 的关系
Quartz 每次触发时会通过反射 jobClass.newInstance() 创建新的 Job 实例,执行完即丢弃——因此 Job 默认是"无状态"的,不能在 Job 字段里保存跨次执行的中间数据(线程安全因此天然满足)。需要保留状态时用 JobDataMap 或 @PersistJobDataAfterExecution。
JobDetail 才是"任务本体",包含 JobKey(全局唯一标识)、Job 类、JobDataMap:
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 的。
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) | 一次性任务、固定次数的定时执行 |
| CronTrigger | cron 表达式,日历规则最强 | 复杂的"每天/每周/每月"定点任务 |
| 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 | 丢弃所有错过的触发,等下一个正常触发点 | 心跳、缓存刷新类,错过就错过 |
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:
<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>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 执行
}
}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: 15000spring:
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 持久化
| 对比维度 | RAMJobStore | JDBCJobStore |
|---|---|---|
| 存储位置 | 内存 | 数据库 |
| 重启后任务 | 全部丢失 | 自动恢复继续调度 |
| 集群支持 | 不支持 | 支持(配合 isClustered) |
| 性能 | 高 | 每次调度有 DB 读写,吞吐受限 |
| 适用 | 单体、任务可丢 | 需要持久化与集群的正式环境 |
JDBC JobStore 需要初始化约 12 张 qrtz_ 前缀的表,关键几张:
| 表 | 作用 |
|---|---|
| qrtz_job_details | JobDetail 定义(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
+---------------------+执行流程:
- 每个节点周期性向
qrtz_scheduler_state上报心跳(默认约 15 秒 check-in); - 触发前,节点对
qrtz_locks中对应行执行SELECT ... FOR UPDATE拿锁; - 拿锁成功后查询到期的 Trigger,更新其下一次触发时间,再释放锁;
- 宕机节点未完成的任务按 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 调度中心搭建
# 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 集成
<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>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 # 日志保留天数@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;
}
}任务方法示例:
@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()(总分片数)协作切分数据:
@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 调度类型与动态配置
调度中心为任务支持的调度类型:
| 调度类型 | 说明 |
|---|---|
| cron | Quartz 风格表达式,最常用 |
| 固定速度 | 按固定间隔触发(等价 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_logglue | Glue 模式的任务代码 |
| 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 方法时,只有一个能抢到锁执行。
@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 的延迟消息能力盘点(与《消息队列》呼应)
| 中间件 | 延迟消息能力 | 关键机制 |
|---|---|---|
| RocketMQ | 4.x 仅 18 个固定延迟等级;5.x 支持任意时间(时间轮) | 消息先进入 SCHEDULE_TOPIC_XXXX,到期由 ScheduleMessageService 扫描转发回原 Topic |
| RabbitMQ | DLX+TTL 模拟 / 官方延迟插件(任意延迟) | 死信队列转发;延迟交换机(已在《消息队列》2.4 详述) |
| Kafka | 无原生延迟消息 | 需自研:时间轮组件、按延迟分桶的 delay topic + 定时扫描等 |
RocketMQ 4.x 默认 18 个延迟等级(通过 messageDelayLevel 配置,可改):
# 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 2hMessage 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 @Scheduled | Quartz | XXL-Job | Elastic-Job |
|---|---|---|---|---|
| 使用成本 | 最低,一个注解 | 中等,概念多 | 中等,需部署调度中心 | 高,需维护 ZK |
| 集群/防重复 | 无,需自加分布式锁 | 支持(DB 集群模式) | 支持(调度中心+执行器集群) | 支持(ZK 协调) |
| 任务持久化 | 无 | JDBC JobStore | 调度中心 DB | ZK + 本地 |
| 管理界面 | 无 | 无 | 完善(任务/日志/告警/报表) | 简单控制台 |
| 动态任务 | 改代码(或用 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。
# 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 补跑、多实例并发)。任务必须幂等,三板斧:
- 业务幂等键:处理前先插入/校验唯一键,重复执行直接跳过。例如发券任务用"任务批次号 + 用户 ID"做唯一约束;
- 状态机前置检查:只有
PENDING -> PROCESSING才能继续,已处理的直接返回:
-- 只有状态仍为待处理的行能被抢到(配合 UPDATE 的原子性)
UPDATE order SET status = 'CLOSING'
WHERE id = ? AND status = 'PENDING';
-- 影响行数为 0 说明已被其它执行处理,本任务直接跳过-- 只有状态仍为待处理的行能被抢到(配合 UPDATE 的原子性)
UPDATE order SET status = 'CLOSING'
WHERE id = ? AND status = 'PENDING';
-- 影响行数为 0 说明已被其它执行处理,本任务直接跳过- 结果可重复:把业务设计成"覆盖式"(幂等写、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 会) |
| 5 | cron 写成 7 段(带年) | 启动报 cron expression must consist of 6 fields |
| 6 | cron 语义理解错 | 如想"每 5 分钟"却写 0 */5 * * * ? 的变体,先本地用 CronTrigger 推算几次 nextTime 验证 |
| 7 | 时区不对 | 服务器 UTC,任务按本地 8 点写却在 0 点跑 |
| 8 | 多实例重复执行 | 以为"没生效/重复执行",实则是集群每台各跑一次(见 2.6) |
7.6 XXL-Job 回调/触发失败排查
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 调度中心显示"调度成功"但执行器没跑 | 执行器没注册上/AppName 不匹配 | 看执行器管理页是否在线,检查 appname 与 accessToken 是否一致 |
| 执行器注册地址不通 | 自动探测到内网 IP 或容器漂移 IP | xxl.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 三层基本内置,是面试与生产中最容易讲出体系的方案。
Cao Note