
搞定时任务之前我估计不少人和我一样项目里第一个想到的就是 Scheduled 配上 cron 表达式。但真到了线上问题就开始排队了服务起了两个实例定时任务跑了两遍数据库改了执行时间结果要重新打包部署任务执行时间太长把后面的任务全部顶掉。这些场景我都实际踩过所以这篇把 SpringBoot 里定时任务的三种主流方式从头到尾拆一遍每种方式的适用场景、实现步骤、容易踩的坑都会讲到。如果你正在做数据同步、定时报表、状态检查这类功能看完基本能直接照着选型落地。这篇文章主要面向两类人一是刚接触 SpringBoot 定时任务、想在项目里快速使用的新手二是已经用了 Scheduled但被重复执行、动态调度、持久化这些问题困扰想寻找更优方案的开发者。我会按“最简可用 - 动态可配 - 企业级调度”的顺序把三套方案讲透还会额外补充分布式场景下常见的处理思路。1. 定时任务的三条路先对号入座再动手SpringBoot 里实现定时任务的方式掰开揉碎就三条路注解方式、接口方式、集成 Quartz。很多人一上来就问“哪个最好”其实没有最好的只有最匹配当前场景的。先把三者的定位搞清楚选型就顺了。1.1 三种方式各自是什么第一种是Scheduled注解这是 Spring 框架自带的调度能力加上EnableScheduling就能用。它的特点是轻量、快速适合固定频率或固定 cron 表达式就能满足需求的场景比如每天凌晨清理日志、每隔 5 分钟拉取一次接口数据这类简单逻辑。第二种是实现SchedulingConfigurer接口。它同样基于 Spring 的调度框架但最核心的价值是把定时策略从“写死在代码里”变成了“动态获取”。什么意思就是定时任务的执行周期可以存在数据库或配置中心里运行过程中修改配置项下一次调度就会按新周期执行不需要重新编译发布。适合需要经常调整执行频率的业务比如对账任务、数据同步任务。第三种是集成 Quartz。Quartz 是老牌的开源任务调度框架跟 Spring 原生的调度器相比它多了 Job 持久化、集群部署、失败重试、丰富的 Trigger 策略。如果你的项目里有很多复杂的任务依赖关系或者需要任务在多个服务实例之间不重复执行、还能在服务重启后自动恢复那 Quartz 是更稳妥的选择。1.2 选型时的底层判断逻辑我自己的选型逻辑一般是这四条判断链如果项目只是少量简单任务代码改一次以后基本不动直接Scheduled不折腾。如果任务执行周期要由运营或业务人员调整不能每次改完都发版选SchedulingConfigurer 数据库存储。如果任务是重量级的比如批处理、数据清洗、多个子任务存在先后顺序或者以后可能要上集群直接 Quartz。如果你已经在用 Spring Cloud Alibaba 这类微服务体系项目里实例数超过两台且强依赖任务不重复触发建议即使选 Quartz 也要把持久化和集群开关打开或者直接上 xxl-job 这类分布式调度平台最后单独说。这三条路不是互斥的一个项目里完全可以混用。比如核心报表用 Quartz 管简单的缓存刷新用 Scheduled都是很常见的做法。2. Scheduled 注解最快的定时任务落地方案不需要额外依赖不需要建表只要在一个方法上加上注解Spring 容器启动后就会按照你设定的规则自动调用。这种低摩擦的开发体验是它成为大多数人第一个定时任务方案的原因。2.1 三步开启注解定时任务第一步启动类加EnableSchedulingSpringBootApplication EnableScheduling public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }第二步新建一个组件类在方法上加ScheduledComponent public class DataSyncTask { private static final Logger log LoggerFactory.getLogger(DataSyncTask.class); Scheduled(cron 0 0 2 * * ?) public void syncData() { log.info(开始同步小票数据时间{}, LocalDateTime.now()); // 业务逻辑增量拉取、清洗写入 } }第三步启动项目控制台就会在每天凌晨 2 点打印同步日志。整个过程确实没什么门槛但我要提醒你一点Scheduled注解默认只有一个单线程的调度器如果你的项目里有多个定时任务它们会共用同一个线程。前面强调了线程模型其实就是为这个坑做铺垫。2.2 单线程调度器的死锁问题怎么解这是 Scheduled 最隐蔽的坑。假设项目里有两个定时任务Component public class MultiTask { Scheduled(cron 0 */1 * * * ?) public void taskA() { // 模拟耗时操作 Thread.sleep(300000); // 睡 5 分钟 } Scheduled(cron 0 */2 * * * ?) public void taskB() { System.out.println(taskB 执行了); } }默认情况下taskA 一旦执行到Thread.sleep(300000)taskB 也要等到 taskA 跑完才能开始即使它的 cron 时间已经到了。这会导致非常严重的任务堆积和调度延迟。解决方式在配置类里显式声明一个线程池让所有定时任务都跑在独立线程里Configuration public class SchedulerConfig { Bean public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); scheduler.setPoolSize(8); scheduler.setThreadNamePrefix(scheduled-task-); scheduler.initialize(); return scheduler; } }设置线程池大小的时候我习惯核心任务数 预留缓冲一般 5-10 个足够不用贪大。任务多了要考虑的是拆分服务和队列而不是无限加线程。2.3 Scheduled 的三种调度参数怎么区分Scheduled里常用的三个参数语法相近但语义完全不同用错了场景会造成定时任务行为和预期不符。参数语义典型场景fixedDelay上次任务执行完成后延迟 N 毫秒再执行下次数据同步、报表生成两次任务不能重叠fixedRate固定频率上次任务开始后N 毫秒执行下次心跳检测、监控数据上报cron按 cron 表达式匹配具体时间点每天凌晨、每月 1 号这类精确时间触发fixedRate的坑在于如果上次任务阻塞了 30 秒而周期是 10 秒一次Spring 默认不会并发执行同一个任务而是等上次任务结束后立刻紧凑地补跑错过的调度。所以如果你的需求是“严格每 10 秒上报一次”用 fixedRate 在上游能力不足时会打出一阵突发流量。3. Cron 表达式拆解这一关过了定时任务就掌握了一半做定时任务cron 表达式是绕不开的。很多人在这一步踩了坑不是不努力的问题而是六七个星号摆在那里确实很容易被绕晕。我直接用实际案例把每个字段的含义讲透。3.1 七位 vs 六位不要被格式吓到Spring 的Scheduled支持六位 cron秒 分 时 日 月 周注意没有“年”Quartz 原生支持七位但 Spring 默认六位。每位取值范围如下字段取值范围允许特殊字符秒0-59, - * /分0-59, - * /时0-23, - * /日1-31, - * ? / L W月1-12 或 JAN-DEC, - * /周1-7 或 SUN-SAT, - * ? / L #这中间最容易出问题的是“日”和“周”的关系两者是互斥的一般只设置其中一个另一个写?。比如“每月 15 日触发”写成0 0 9 15 * ?如果写成0 0 9 15 * MON在 Quartz 的规则里就是“每月 15 日且必须同时是周一才触发”这种双重限制在绝大多数业务里不是你想要的含义。3.2 高频写法速查我整理了开发中最常用到的几种 cron 写法建议存一份业务场景cron 表达式每 5 秒执行一次*/5 * * * * ?每 10 分钟执行一次0 */10 * * * ?每天凌晨 2 点执行0 0 2 * * ?每周一上午 9 点执行0 0 9 ? * MON每月 1 号凌晨 3 点执行0 0 3 1 * ?工作日周一到周五上午 9 点半执行0 30 9 ? * MON-FRI另外注意L和W这两个字符很多初学者用不对。L表示最后一天比如0 0 12 L * ?是每月最后一天中午 12 点执行。W是离指定日期最近的工作日0 0 9 15W * ?表示每月 15 号最近的那个工作日触发遇到周六就提前到周五遇到周日后延到周一。这两个字符能解决很多“自然月最后一个工作日处理”之类的需求但刚上手时别急着用先跑起来再逐步加难度。3.3 写 cron 常见的两个坑第一个坑*和?混淆。*表示该字段任意值?表示不指定值。秒、分、时、日、月都可以用*但“日”和“周”只能用一个绝对不能出现0 0 0 * * *再加周几的情况否则 Quartz 会抛出异常。第二个坑服务器时区。cron 表达式跟服务器时区直接相关如果你用的云服务器默认是 UTC 时间你写的每天凌晨 2 点实际上是北京时间上午 10 点。上线前先date命令确认服务器时区或者统一使用 Asia/Shanghai。这个坑排查起来最让人抓狂因为代码看起来完全没问题结果就是时间不对。4. SchedulingConfigurer 动态定时任务修改执行周期不用发版业务系统里有一种特别常见但又很尴尬的需求定时任务的执行时间经常要调。比如数据同步刚开始是每小时同步一次运营提出要改成每 30 分钟一次。用 Scheduled 就必须改代码、重新打包、重新发布。而 SchedulingConfigurer 能帮我们摆脱这个局面。4.1 核心原理把 cron 的来源从代码挪到数据库SchedulingConfigurer 的原理说穿了很简单Spring 在启动时把定时任务注册进调度器key 是任务名称value 是CronTrigger。每次调度执行时调度器都会重新从CronTrigger里获取下一次执行时间。所以只要让CronTrigger在运行时从数据库读取最新 cron 表达式就能实现“改了数据库任务周期立刻生效”。4.2 从数据库读取 cron 的一次完整实践先准备一张表专门存定时任务的配置CREATE TABLE t_task_config ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_key VARCHAR(64) UNIQUE NOT NULL COMMENT 任务标识, cron_expression VARCHAR(64) NOT NULL COMMENT cron表达式, enabled TINYINT DEFAULT 1 COMMENT 是否启用 ); INSERT INTO t_task_config (task_key, cron_expression) VALUES (dataSyncTask, 0 */30 * * * ?);然后是核心代码Component public class DynamicScheduleTask implements SchedulingConfigurer { Resource private TaskConfigMapper taskConfigMapper; Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.addTriggerTask( // 1. 要执行的任务逻辑 () - syncData(), // 2. 触发策略 triggerContext - { // 每次调度都从数据库读取最新 cron String cron taskConfigMapper.getCronByKey(dataSyncTask); if (StringUtils.isBlank(cron)) { return null; // 返回 null 表示停止调度 } CronTrigger cronTrigger new CronTrigger(cron); return cronTrigger.nextExecutionTime(triggerContext); } ); } private void syncData() { System.out.println(数据同步任务执行 LocalDateTime.now()); } }这段代码的关键就在 lambda 表达式的triggerContext部分。每次任务执行完Spring 调度器都会问“下一次什么时候执行”我们就在这里从数据库里查一次最新配置拿到最新 cron 再计算下一次执行时间。因此修改数据库里的cron_expression下一个周期就会按新配置执行。4.3 动态定时任务的注意事项用这套方案时有两个细节必须注意。第一taskConfigMapper.getCronByKey(dataSyncTask)每次触发都会查一次数据库。如果任务很频繁比如每秒触发对数据库的扫描会变得明显。我的处理办法是加一层本地缓存cron 变动时通过刷新接口更新缓存查询优先走缓存。第二查询结果要判空。如果你在数据库里把任务停用了返回空字符串或 null调度器就收不到下一次执行时间任务自然停止这比硬删除配置要优雅很多。还要提醒一点SchedulingConfigurer 同样需要配置线程池否则多个动态任务也会互相阻塞。配置方法跟 Scheduled 里的SchedulerConfig完全一样直接复用同一个TaskScheduler的 Bean 就能生效。5. Quartz 整合把定时任务升级成一套调度系统当定时任务变得复杂比如任务之间有依赖、需要手工暂停和恢复、服务挂了重启后要接着跑未完成的任务Scheduled 和 SchedulingConfigurer 都不够用。这也是我最终把核心任务迁移到 Quartz 的原因。5.1 四个核心组件先记住Job你要执行的业务逻辑实现org.quartz.Job接口重写execute方法。JobDetailJob 的描述和容器通过JobBuilder.newJob(你的Job类.class)创建可以传参数给 Job。Trigger触发策略包括CronTrigger按 cron和SimpleTrigger按间隔、重复次数。Scheduler调度器负责把 JobDetail 和 Trigger 绑定并执行。一句话概括Scheduler 拿着 Trigger 到时间了就根据 JobDetail 里的信息去实例化 Job 并执行。5.2 SpringBoot 集成 Quartz 的完整步骤引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-quartz/artifactId /dependency定义一个 Job。要注意Job 是每次执行时newInstance出来的所以不能通过构造函数注入 Spring Bean而是通过JobDataMap传参public class ReportJob implements Job { Override public void execute(JobExecutionContext context) throws JobExecutionException { JobDataMap dataMap context.getJobDetail().getJobDataMap(); String reportName dataMap.getString(reportName); System.out.println(生成报表 reportName 时间 LocalDateTime.now()); } }配置一个 JobDetail 和 Trigger把它们注册进 SchedulerConfiguration public class QuartzConfig { Bean public JobDetail reportJobDetail() { return JobBuilder.newJob(ReportJob.class) .withIdentity(reportJob) .storeDurably() .usingJobData(reportName, sale_daily) .build(); } Bean public Trigger reportTrigger() { CronScheduleBuilder cron CronScheduleBuilder.cronSchedule(0 0 1 * * ?); return TriggerBuilder.newTrigger() .forJob(reportJobDetail()) .withIdentity(reportTrigger) .withSchedule(cron) .build(); } }SpringBoot 的 Quartz 自动配置类会扫描容器里的 JobDetail 和 Trigger Bean自动注册到 Scheduler。启动应用后每天凌晨 1 点就会生成日报表。这里要特别提一句storeDurably()这个方法是必须的。它表示 JobDetail 即使没有关联 Trigger 也要持久化保存。没有这个方法Quartz 启动时会报Jobs added with no trigger must be durable的异常。5.3 持久化与集群Quartz 的另一半能力默认情况下 Quartz 的调度信息存在内存中RAMJobStore应用重启后所有任务配置都会丢失。如果需要持久化Jan 要改成org.quartz.impl.jdbcjobstore.JobStoreTX并创建 Quartz 官方提供的数据库脚本表。SpringBoot 里配置持久化的方式spring: quartz: job-store-type: jdbc jdbc: initialize-schema: always properties: org.quartz.jobStore: class: org.quartz.impl.jdbcjobstore.JobStoreTX driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate tablePrefix: QRTZ_ isClustered: true clusterCheckinInterval: 15000关键在于isClustered: true。打开集群模式后多个服务实例共享同一张 Quartz 数据库表Quartz 通过数据库行锁来保证同一时刻只有一个实例执行某个任务从根本上解决多实例重复执行的问题。我在实际项目中就遇到过这样一个教训两个实例部署同一个服务没开集群模式结果每天凌晨的报表任务被生成两遍运营收到两份重复的邮件。后来排查到是 Quartz 的集群开关没打开改配置后问题才消失。6. 从单机走向分布式定时任务重复执行与分布式调度方案如果你用的是第一章到第五章任何一个单机方案只要服务部署多个实例都会遇到同一个问题同一个定时任务在每个实例上都跑一遍。这在微服务架构里是不可避免的所以单独讲一节。6.1 重复执行是怎么发生的定时任务本身不感知“其他实例的存在”。两个实例的 Spring 容器各自启动各自的调度器到时间点各自执行任务代码。如果这个任务只是查数据做缓存影响不大但如果任务是发邮件、扣库存、同步数据那重复执行就是生产事故。6.2 轻量方案数据库锁或 Redis 分布式锁如果你的团队暂时不想引入额外的调度平台可以先上一把分布式锁让任务在执行前先抢锁抢不到就跳过。用 Redis 的 SETNX 实现一个最简单版本public void syncDataWithLock() { String lockKey lock:dataSync; String requestId UUID.randomUUID().toString(); Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(30)); if (Boolean.TRUE.equals(locked)) { try { // 业务逻辑 syncData(); } finally { String value stringRedisTemplate.opsForValue().get(lockKey); if (requestId.equals(value)) { stringRedisTemplate.delete(lockKey); } } } else { System.out.println(其他实例正在执行跳过); } }两个细节注意锁要设置过期时间防止任务执行到一半进程崩了锁不释放释放时先校验 requestId防止误删其他请求的锁。这个方案能挡住 80% 的重复执行问题但缺点是任务必须等上一个任务执行完才能开始不适合长耗时任务。如果任务一批要跑 20 分钟锁过期后另一个实例又进来了就变成了两个实例同时跑。6.3 严肃解法引入 xxl-job 这类分布式调度平台当任务数量多、执行时间长、对监控和失败重试有要求时我会直接建议上 xxl-job。原因在于它把“调度中心”和“执行器”分离调度中心负责按时触发任务通过 HTTP 调用各服务执行器由数据库锁保证同一条调度记录不会发给多个执行器。它还自带可视化控制台可以随时手动执行、暂停、查看执行日志和失败原因比自己在代码里加各种判断靠谱得多。如果选 xxl-job架构上要做两个改动项目里引入 xxl-job 的依赖加上执行器配置端口、AppName、调度中心地址。原来用 Scheduled 的方法改成继承IJobHandler并加上XxlJob(数据同步任务)注解任务逻辑不用大改但触发权从 Spring 调度器移交给了调度中心。对比一下两种轻量和重量方案的使用边界维度分布式锁/数据库锁xxl-job接入成本低不需要额外部署需要单独部署调度中心多实例防重手动实现需要考虑锁过期场景平台内置调度中心保证唯一分发任务监控无有完整的执行日志和告警动态调整 cron需要自行开发控制台直接改实时生效适合场景定时任务少、逻辑简单的项目任务多、要降本增效的中大型项目根据我的经验3 个以内简单任务用锁方案能撑住5 个以上任务或任务链路复杂直接上 xxl-job 更省心。前期省掉部署成本后面每次排查任务问题耗费的精力会更多。我在实际项目里做数据同步定时任务时用的是 SchedulingConfigurer 加数据库 cron 配置多个服务实例之间加 Redis 锁防重。这套方案支撑了日均几百万条数据的增量同步任务运行了快两年没出过大问题。直到后来任务量上涨需要做任务依赖编排和失败告警了才逐步把核心任务迁移到 xxl-job。最后分享一个通用建议定时任务的代码逻辑要单独抽方法不要在注解方法里写大段业务方便以后把任务从 Spring 调度器迁到 xxl-job 等平台也方便做单元测试。注解只是入口业务逻辑不要和调度框架耦合这样无论你换什么定时方案主流程几乎不用动。