Appearance
第68课:任务调度
🎯 学习目标
- 理解定时任务、异步任务、分布式调度和消息延迟任务的区别。
- 掌握
@Scheduled、@Async、线程池配置和 Quartz 基本用法。 - 能识别任务重复执行、单线程阻塞、集群多实例重复跑、异常吞掉等常见问题。
- 能根据任务可靠性和分布式要求选择 Spring Scheduling、Quartz、XXL-JOB 或消息队列。
- 能设计可观测、可重试、可幂等的后台任务。
📖 一、任务类型
常见后台任务:
text
每天凌晨生成报表
每 5 分钟同步第三方数据
订单 30 分钟未支付自动取消
批量发送邮件
定期清理过期数据
异步导出 Excel这些任务看起来相似,但可靠性要求不同。
text
本地定时任务:简单,但集群下会重复执行。
异步任务:把耗时操作放到线程池,不代表定时。
Quartz:支持持久化和更复杂调度。
分布式任务平台:支持集群、分片、可视化、失败重试。
消息延迟任务:适合按事件触发的延迟处理。📖 二、@Scheduled
启用:
java
@SpringBootApplication
@EnableScheduling
public class Application {
}固定延迟:
java
@Component
public class ReportTask {
@Scheduled(fixedDelay = 5000)
public void runAfterPreviousFinished() {
log.info("任务结束 5 秒后再执行下一次");
}
}固定速率:
java
@Scheduled(fixedRate = 5000)
public void runEvery5Seconds() {
log.info("每 5 秒触发一次");
}Cron:
java
@Scheduled(cron = "0 0 8 * * ?")
public void runAt8AM() {
log.info("每天 8 点执行");
}fixedDelay 更适合任务耗时不稳定的场景,避免任务堆叠。
📖 三、调度线程池
默认调度线程可能不足。应显式配置:
java
@Configuration
public class SchedulingConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(8);
scheduler.setThreadNamePrefix("scheduled-task-");
scheduler.setErrorHandler(t -> log.error("scheduled task failed", t));
scheduler.initialize();
registrar.setTaskScheduler(scheduler);
}
}如果一个任务阻塞默认线程,其他定时任务也可能延迟。
📖 四、@Async 异步任务
启用:
java
@EnableAsync
@Configuration
public class AsyncConfig {
@Bean("taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("async-task-");
executor.initialize();
return executor;
}
}使用:
java
@Service
public class ExportService {
@Async("taskExecutor")
public CompletableFuture<String> export(Long taskId) {
doExport(taskId);
return CompletableFuture.completedFuture("done");
}
}注意:@Async 也基于代理,同类自调用会失效。
📖 五、Quartz
Quartz 适合更复杂的调度:
java
public class ReportJob extends QuartzJobBean {
@Override
protected void executeInternal(JobExecutionContext context) {
log.info("generate report");
}
}配置 JobDetail 和 Trigger:
java
@Bean
public JobDetail reportJobDetail() {
return JobBuilder.newJob(ReportJob.class)
.withIdentity("reportJob")
.storeDurably()
.build();
}
@Bean
public Trigger reportTrigger() {
return TriggerBuilder.newTrigger()
.forJob(reportJobDetail())
.withIdentity("reportTrigger")
.withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?"))
.build();
}Quartz 支持持久化和集群,但配置和运维比 @Scheduled 复杂。
📖 六、集群重复执行
如果应用部署 3 个实例:
text
app-1 执行定时任务
app-2 也执行定时任务
app-3 也执行定时任务如果任务不是幂等,就会重复处理。
解决方案:
text
使用分布式锁
使用 Quartz 集群模式
使用 XXL-JOB 等任务平台
使用消息队列让任务只被一个消费者消费简单分布式锁:
java
@Scheduled(cron = "0 */5 * * * ?")
public void syncData() {
String lockValue = UUID.randomUUID().toString();
if (!redisLock.tryLock("job:sync-data", lockValue, Duration.ofMinutes(4))) {
return;
}
try {
doSync();
} finally {
redisLock.unlock("job:sync-data", lockValue);
}
}锁 TTL 必须小于任务周期且大于正常执行时间,长任务还要考虑续期。
📖 七、任务可靠性设计
后台任务必须考虑:
text
失败是否重试?
重试多少次?
是否幂等?
是否能断点续跑?
是否有执行日志?
是否有超时?
是否有告警?
是否支持手动补偿?任务表设计示例:
text
task_id
task_type
status
retry_count
next_retry_time
started_at
finished_at
error_message不要只在内存里启动一个线程处理重要任务。
⚠️ 八、常见陷阱
1. 定时任务异常后无感知
必须记录错误日志和告警。
2. 集群重复执行
单机定时任务上线到多实例环境,最容易重复跑。
3. 默认线程池阻塞
一个慢任务拖住调度线程,其他任务延迟。
4. @Async 自调用失效
同类内部调用异步方法不会经过代理。
5. 任务不可幂等
重试和重复执行都可能造成重复扣款、重复发券、重复发送通知。
🆚 九、Java vs C 对比
| 维度 | C 常见方式 | Spring 生态 |
|---|---|---|
| 定时 | cron、线程 sleep | @Scheduled、Quartz |
| 异步 | pthread/thread pool | @Async、Executor |
| 分布式调度 | 自研锁/任务表 | Quartz 集群、XXL-JOB |
| 可靠性 | 手动记录状态 | 任务表、重试、告警 |
Java 生态提供了很多调度工具,但任务可靠性仍然要靠业务设计保证。
💡 十、最佳实践
- 简单单机任务用
@Scheduled。 - 多实例部署必须防重复执行。
- 耗时任务配置独立线程池。
- 重要任务必须有执行记录和失败重试。
- 所有任务都应尽量幂等。
- 定时任务中不要无上限处理全量数据,使用分页或游标。
- 异步任务异常必须记录。
- 复杂分布式调度优先使用成熟任务平台。
🔍 十一、自测问题
text
fixedDelay 和 fixedRate 有什么区别?
@Scheduled 默认线程有什么风险?
@Async 为什么可能自调用失效?
多实例部署下定时任务为什么会重复执行?
分布式锁调度有什么风险?
Quartz 相比 @Scheduled 多解决了什么?
重要任务为什么需要任务表?
为什么任务必须设计幂等?🧭 十二、任务上线检查清单
text
任务是否幂等?
是否会在多实例重复执行?
是否有独立线程池?
是否记录开始、结束、耗时和结果?
失败是否重试?
重试是否有上限?
是否支持手动补偿?
是否有超时控制?
是否有告警?
是否能分页处理大数据?后台任务没有用户实时盯着,更需要日志、状态和告警。
🧪 十三、实战案例:每日报表
text
每天 02:00 生成昨日订单报表。
任务开始时写 task_run 记录。
分页读取订单数据。
生成文件上传对象存储。
更新 task_run 状态为 SUCCESS。
失败时记录错误并允许重试。关键设计:
text
使用业务日期作为幂等键。
重复执行同一天报表时覆盖或跳过。
分页处理,避免一次加载全部数据。
执行超时要告警。📌 十四、学习建议
建议先用 @Scheduled 写单机任务,再启动两个应用实例观察重复执行。随后加入 Redis 分布式锁或 Quartz 集群模式,对比行为。
这个实验能让你真正理解“本地定时任务”和“分布式调度”的区别。
📚 十五、调度方案选择表
| 场景 | 推荐 |
|---|---|
| 单机简单定时 | @Scheduled |
| 本地异步执行 | @Async + 线程池 |
| 持久化复杂调度 | Quartz |
| 多实例分布式任务 | XXL-JOB、Quartz 集群 |
| 订单超时取消 | 延迟消息或任务表扫描 |
| 大批量数据处理 | 分片任务 |
任务越重要,越要避免只依赖内存状态。
📌 十六、任务日志建议
每次执行记录:
text
任务名
业务日期
开始时间
结束时间
耗时
处理数量
成功数量
失败数量
错误信息
触发方式没有执行记录的后台任务,出了问题很难补偿。
🎓 小结
任务调度不是写一个 @Scheduled 就结束。真实项目要考虑线程池、异常、集群重复执行、幂等、重试、补偿和监控。任务越重要,越不能只依赖内存线程和临时日志。