1. 定时任务最容易踩的四类坑
很多线上事故不是因为 cron 写错,而是因为我们默认“任务只会执行一次”。在分布式系统里,这个默认假设通常不成立。
常见风险有四类:
多实例重复执行
一个服务部署了 3 个 Pod,每个 Pod 都加载同一段@Scheduled代码,到点后会同时触发 3 次。同节点重叠执行
任务每 1 分钟触发一次,但实际执行需要 3 分钟。如果线程池允许并发,就可能出现同一个任务交叠运行。中途失败没有可恢复状态
任务处理了 1000 条数据,跑到第 600 条时应用重启。如果没有任务流水、处理标记或幂等条件,下一次很难判断应该从哪里继续。锁释放与业务提交不一致
分布式锁只保护“任务入口”,不能证明“业务结果只发生一次”。如果任务拿到锁后调用外部支付、发券、发消息,仍然要靠业务幂等兜底。
所以,生产级定时任务的目标不是追求绝对的“只执行一次”,而是做到:
- 任务入口尽量单点触发;
- 业务状态更新天然幂等;
- 失败后可以重试;
- 重复触发不会产生重复副作用;
- 能观测到任务耗时、成功数、失败数和跳过原因。
2. 先选对方案:@Scheduled、ShedLock 还是 Quartz
选型时不要一上来就堆框架。先看任务的业务属性。
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 单实例应用、低风险清理任务 | @Scheduled | 简单直接,维护成本低 |
| 多实例部署,但任务定义固定 | @Scheduled+ ShedLock | 保留 Spring 调度模型,用共享存储避免多节点同时跑 |
| 需要页面动态创建、暂停、删除任务 | Quartz | Job、Trigger、Calendar 模型更完整 |
| 任务有强工作流依赖和可视化编排 | 外部调度平台 | 例如 Airflow、DolphinScheduler、K8s CronJob 等 |
| 任务会修改核心资金/库存/权益状态 | 调度方案 + 业务幂等 + 补偿 | 锁只是入口保护,业务状态必须自我保护 |
Spring 官方文档说明@Scheduled可用于 cron、fixed delay、fixed rate 等触发方式,也提醒重复声明的调度可能独立触发,因此表达式和并发行为需要主动设计。ShedLock 的定位则很明确:保证同一时刻最多一个节点执行同一个定时任务;如果任务已经在其他节点执行,本节点会跳过而不是等待。Quartz 则更适合企业级调度,尤其是需要 JDBC JobStore、集群、失败恢复和动态任务管理的场景。
3. 用@Scheduled写一个可靠的单机任务
先从最基础的 Spring Boot 定时任务开始。一个常见的订单超时关闭任务如下:
@Slf4j@Component@RequiredArgsConstructorpublicclassOrderTimeoutJob{privatefinalOrderTimeoutServiceorderTimeoutService;@Scheduled(cron="0 */5 * * * *",zone="Asia/Shanghai")publicvoidcloseTimeoutOrders(){longstart=System.nanoTime();intclosed=orderTimeoutService.closeTimeoutOrders();longcostMs=TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-start);log.info("close timeout orders finished, closed={}, costMs={}",closed,costMs);}}这段代码适合开发、测试或单实例部署。生产中还需要显式配置调度线程池,避免所有任务挤在默认线程里:
@Configuration@EnableSchedulingpublicclassSchedulingConfigimplementsSchedulingConfigurer{@BeanpublicThreadPoolTaskSchedulertaskScheduler(){ThreadPoolTaskSchedulerscheduler=newThreadPoolTaskScheduler();scheduler.setPoolSize(4);scheduler.setThreadNamePrefix("biz-scheduler-");scheduler.setAwaitTerminationSeconds(30);scheduler.setWaitForTasksToCompleteOnShutdown(true);scheduler.initialize();returnscheduler;}@OverridepublicvoidconfigureTasks(ScheduledTaskRegistrarregistrar){registrar.setTaskScheduler(taskScheduler());}}这里有两个细节值得注意。
第一,线程池不是越大越好。线程池变大后,多个任务之间不容易互相阻塞,但同一个任务如果触发频率太高,也更容易发生重叠执行。任务是否允许并发,要由业务语义决定,而不是由线程池碰运气。
第二,定时任务不要在方法里直接堆业务逻辑。Job层只负责触发、日志和指标,真正的状态变更放到 Service。这样后续加锁、手动补偿、接口触发重跑都会更干净。
4. 多实例部署后,为什么@Scheduled会重复跑
假设订单服务部署了 3 个实例:
order-service-1 @Scheduled closeTimeoutOrders order-service-2 @Scheduled closeTimeoutOrders order-service-3 @Scheduled closeTimeoutOrders到了 10:00:00,三个实例都会执行同一个方法。Spring 并不知道“这些实例属于同一个集群”,它只在当前 JVM 内调度任务。
如果任务只是清理临时文件,重复跑也许问题不大。但如果任务要关闭订单、发放补偿券、推送短信、调用三方系统,重复执行就可能变成真实损失。
这时常见做法是加一层分布式锁。ShedLock 是 Spring 定时任务里很轻量的一种选择:它通过数据库、Redis、MongoDB 等共享存储记录锁状态,让多个节点争抢同一把锁,抢到的节点执行任务,没抢到的节点跳过。
5. 使用 ShedLock 保护任务入口
以 JDBC 存储为例,先引入依赖。下面版本以 Maven Central 上的7.7.0为示例,真实项目建议统一放进依赖管理并定期升级。
<dependencies><dependency><groupId>net.javacrumbs.shedlock</groupId><artifactId>shedlock-spring</artifactId><version>7.7.0</version></dependency><dependency><groupId>net.javacrumbs.shedlock</groupId><artifactId>shedlock-provider-jdbc-template</artifactId><version>7.7.0</version></dependency></dependencies>创建锁表:
CREATETABLEshedlock(nameVARCHAR(64)NOTNULLPRIMARYKEY,lock_untilTIMESTAMP(3)NOTNULL,locked_atTIMESTAMP(3)NOTNULL,locked_byVARCHAR(255)NOTNULL);再配置LockProvider:
@Configuration@EnableScheduling@EnableSchedulerLock(defaultLockAtMostFor="PT10M")publicclassShedLockConfig{@BeanpublicLockProviderlockProvider(DataSourcedataSource){returnnewJdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder().withJdbcTemplate(newJdbcTemplate(dataSource)).usingDbTime().build());}}usingDbTime()很重要。多节点机器时间可能存在偏差,用数据库时间作为锁判断依据,可以减少时钟漂移带来的误判。
任务上加锁:
@Slf4j@Component@RequiredArgsConstructorpublicclassOrderTimeoutJob{privatefinalOrderTimeoutServiceorderTimeoutService;@Scheduled(cron="0 */5 * * * *",zone="Asia/Shanghai")@SchedulerLock(name="orderTimeoutJob",lockAtMostFor="PT8M",lockAtLeastFor="PT10S")publicvoidcloseTimeoutOrders(){intclosed=orderTimeoutService.closeTimeoutOrders();log.info("order timeout job finished, closed={}",closed);}}两个参数要认真设置:
lockAtMostFor:兜底锁时间。节点宕机后,锁不会永久不释放。它应该大于任务正常最长耗时,但不能大到影响恢复。lockAtLeastFor:最短持锁时间。可以防止任务执行太快时多个节点在同一时间窗口连续触发。
经验上,先观察任务 P99 耗时,再设置lockAtMostFor。如果任务 P99 是 2 分钟,可以先设 5 到 8 分钟;如果任务经常超过锁时间,应该优化任务拆分,而不是盲目加大锁时间。
6. 分布式锁不能替代业务幂等
这是整篇文章最重要的一句话:分布式锁保护的是“谁进入任务”,不是“业务效果只发生一次”。
比如一个关闭超时订单的任务,即使用了 ShedLock,也要在数据库更新时加条件:
@RepositorypublicinterfaceOrderRepositoryextendsJpaRepository<OrderEntity,Long>{@Modifying@Query(""" update OrderEntity o set o.status = 'CLOSED', o.closedAt = :now, o.updatedAt = :now where o.id = :orderId and o.status = 'PENDING' and o.createdAt < :timeoutBefore """)intcloseIfTimeout(@Param("orderId")LongorderId,@Param("timeoutBefore")InstanttimeoutBefore,@Param("now")Instantnow);}Service 层根据影响行数判断是否真的关闭成功:
@Service@RequiredArgsConstructorpublicclassOrderTimeoutService{privatestaticfinalintBATCH_SIZE=200;privatefinalOrderQueryRepositoryorderQueryRepository;privatefinalOrderRepositoryorderRepository;privatefinalApplicationEventPublishereventPublisher;@TransactionalpublicintcloseTimeoutOrders(){InstanttimeoutBefore=Instant.now().minus(Duration.ofMinutes(30));List<Long>orderIds=orderQueryRepository.findPendingTimeoutOrderIds(timeoutBefore,BATCH_SIZE);intclosed=0;Instantnow=Instant.now();for(LongorderId:orderIds){intaffected=orderRepository.closeIfTimeout(orderId,timeoutBefore,now);if(affected==1){closed++;eventPublisher.publishEvent(newOrderClosedEvent(orderId));}}returnclosed;}}这样即使任务被重复触发,或者某个订单被人工取消、支付成功、其他流程关闭,条件更新也会让重复操作变成无害的affected = 0。
对核心业务来说,真正可靠的防线通常是:
- 状态机约束:只有合法状态才能流转;
- 唯一键:同一个业务事件只能写入一次;
- 条件更新:只更新仍满足条件的数据;
- 任务流水:记录本次处理批次、结果和错误;
- 补偿任务:允许失败数据后续重跑。
7. 给任务加流水,方便排障和补偿
一个定时任务如果只打一行日志,出了问题很难追。更稳的做法是记录任务运行流水。
CREATETABLEjob_run_log(id BIGSERIALPRIMARYKEY,job_nameVARCHAR(128)NOTNULL,trigger_timeTIMESTAMP(3)NOTNULL,start_timeTIMESTAMP(3)NOTNULL,end_timeTIMESTAMP(3),statusVARCHAR(32)NOTNULL,scanned_countINTNOTNULLDEFAULT0,success_countINTNOTNULLDEFAULT0,failure_countINTNOTNULLDEFAULT0,error_messageTEXT,created_atTIMESTAMP(3)NOTNULLDEFAULTCURRENT_TIMESTAMP);任务执行时写入流水:
publicJobRunLogstart(StringjobName){JobRunLoglog=newJobRunLog();log.setJobName(jobName);log.setTriggerTime(Instant.now());log.setStartTime(Instant.now());log.setStatus("RUNNING");returnjobRunLogRepository.save(log);}publicvoidsucceed(LongrunId,intscanned,intsuccess,intfailure){jobRunLogRepository.finish(runId,"SUCCESS",scanned,success,failure,null,Instant.now());}publicvoidfail(LongrunId,Exceptionex){Stringmessage=StringUtils.truncate(ex.getMessage(),2000);jobRunLogRepository.finish(runId,"FAILED",0,0,0,message,Instant.now());}有了流水之后,排障时就能回答这些问题:
- 最近一次任务有没有执行?
- 扫描了多少条?
- 成功多少条?
- 失败多少条?
- 是否连续失败?
- 平均耗时和 P99 耗时是否变长?
- 哪个版本发布后失败率开始上升?
这比单纯依赖日志可靠得多。
8. 什么时候应该用 Quartz
如果只是固定 cron 的后台任务,@Scheduled + ShedLock往往足够。如果你的需求开始变成下面这样,就该考虑 Quartz:
- 任务需要动态创建、暂停、恢复、删除;
- 每个租户有不同 cron;
- 任务定义要持久化;
- 任务错过触发后需要 misfire 策略;
- 多节点下需要调度器级别的故障转移;
- 运维或运营需要在后台管理任务。
Spring Boot 对 Quartz 有自动配置支持。默认使用内存 JobStore,也可以配置 JDBC JobStore。
spring:quartz:job-store-type:jdbcjdbc:initialize-schema:neverproperties:org.quartz.scheduler.instanceName:orderSchedulerorg.quartz.scheduler.instanceId:AUTOorg.quartz.jobStore.isClustered:trueorg.quartz.jobStore.clusterCheckinInterval:10000org.quartz.threadPool.threadCount:8一个 Quartz Job 示例:
@Component@DisallowConcurrentExecution@RequiredArgsConstructorpublicclassInvoiceReconciliationJobextendsQuartzJobBean{privatefinalInvoiceReconciliationServicereconciliationService;@OverrideprotectedvoidexecuteInternal(JobExecutionContextcontext){JobDataMapdataMap=context.getMergedJobDataMap();StringtenantId=dataMap.getString("tenantId");reconciliationService.reconcile(tenantId);}}配置 JobDetail 和 Trigger:
@ConfigurationpublicclassQuartzJobConfig{@BeanpublicJobDetailinvoiceReconciliationJobDetail(){returnJobBuilder.newJob(InvoiceReconciliationJob.class).withIdentity("invoiceReconciliationJob").usingJobData("tenantId","default").storeDurably().requestRecovery(true).build();}@BeanpublicTriggerinvoiceReconciliationTrigger(JobDetailinvoiceReconciliationJobDetail){returnTriggerBuilder.newTrigger().forJob(invoiceReconciliationJobDetail).withIdentity("invoiceReconciliationTrigger").withSchedule(CronScheduleBuilder.cronSchedule("0 0/10 * * * ?").withMisfireHandlingInstructionDoNothing()).build();}}@DisallowConcurrentExecution可以避免同一个 JobDetail 并发执行,但它不是业务幂等的替代品。Quartz 集群也依赖共享数据库协调调度状态。数据库、任务表、业务状态三者都要监控。
9. 任务批处理要小步快跑
很多慢任务的问题来自“一次想处理太多”。比如一次扫描 50 万条订单,然后在一个事务里全部关闭。这种写法有几个风险:
- 锁持有时间长;
- 事务日志膨胀;
- 失败后回滚成本高;
- 任务耗时超过分布式锁时间;
- 对数据库造成突刺流量。
更稳的做法是分页、小批量、可重入。
publicintcloseTimeoutOrders(){inttotal=0;while(true){intclosed=transactionTemplate.execute(status->closeOneBatch(Instant.now().minus(Duration.ofMinutes(30)),200));if(closed==0){break;}total+=closed;if(total>=2000){break;}}returntotal;}这段代码给任务设置了单次最大处理量。即使积压很多,也可以通过多轮调度慢慢追平,避免一次任务把数据库打穿。真正需要快速追数据时,可以临时调大批量或启动专门补偿任务,而不是让常规定时任务无限制狂奔。
10. 失败重试与补偿设计
如果任务只是更新本库状态,失败后重跑通常足够。如果任务会调用外部系统,就要设计补偿表。
例如发放超时补偿券:
CREATETABLEcoupon_grant_task(id BIGSERIALPRIMARYKEY,biz_keyVARCHAR(128)NOTNULL,user_idBIGINTNOTNULL,coupon_typeVARCHAR(64)NOTNULL,statusVARCHAR(32)NOTNULL,retry_countINTNOTNULLDEFAULT0,next_retry_atTIMESTAMP(3)NOTNULL,last_errorTEXT,created_atTIMESTAMP(3)NOTNULLDEFAULTCURRENT_TIMESTAMP,updated_atTIMESTAMP(3)NOTNULLDEFAULTCURRENT_TIMESTAMP,CONSTRAINTuk_coupon_grant_biz_keyUNIQUE(biz_key));业务事件先写补偿任务,真正发券由异步任务消费:
@TransactionalpublicvoidcreateCouponGrantTask(LongorderId,LonguserId){StringbizKey="timeout-order:"+orderId;couponGrantTaskRepository.insertIgnore(bizKey,userId,"TIMEOUT_COMPENSATION");}补偿任务执行时,依然用唯一业务键保护外部副作用:
publicvoidgrantCoupon(CouponGrantTasktask){try{couponClient.grant(task.getUserId(),task.getCouponType(),task.getBizKey());couponGrantTaskRepository.markSuccess(task.getId());}catch(Exceptionex){Durationbackoff=calculateBackoff(task.getRetryCount());couponGrantTaskRepository.markRetry(task.getId(),ex.getMessage(),Instant.now().plus(backoff));}}这里的关键不是“失败后立刻无限重试”,而是:
- 记录失败原因;
- 限制重试次数;
- 指数退避;
- 超过阈值后转人工或告警;
- 外部接口必须支持业务幂等键。
11. 可观测性:任务必须有指标
上线前至少加这几个指标:
job.duration:任务耗时;job.success.count:成功处理数量;job.failure.count:失败数量;job.skipped.count:因为没拿到锁而跳过的次数;job.lock.wait:等待锁或获取锁耗时;job.last.success.time:最近一次成功时间。
示例:
@Component@RequiredArgsConstructorpublicclassJobMetrics{privatefinalMeterRegistrymeterRegistry;public<T>Trecord(StringjobName,Supplier<T>supplier){Timer.Samplesample=Timer.start(meterRegistry);try{Tresult=supplier.get();meterRegistry.counter("job.run","job",jobName,"status","success").increment();returnresult;}catch(RuntimeExceptionex){meterRegistry.counter("job.run","job",jobName,"status","failure").increment();throwex;}finally{sample.stop(Timer.builder("job.duration").tag("job",jobName).register(meterRegistry));}}}告警规则可以很朴素:
- 连续 3 次失败告警;
- 最近 30 分钟没有成功执行告警;
- 单次耗时超过历史 P99 两倍告警;
- 失败数量超过阈值告警。
定时任务不怕失败,怕的是失败了没人知道。
12. 生产落地清单
上线前可以按下面清单逐项检查:
- cron 表达式是否明确时区;
- 任务线程池是否单独配置;
- 多实例部署时是否有分布式锁或外部调度;
lockAtMostFor是否大于任务 P99 耗时;- 任务是否允许重叠执行;
- 核心状态更新是否有条件更新;
- 外部副作用是否有业务幂等键;
- 是否有任务流水;
- 是否支持失败重试和补偿;
- 是否限制单次最大处理量;
- 是否有耗时、成功、失败、跳过指标;
- 是否能手动触发补偿任务;
- 是否验证过节点宕机、重启、锁超时场景。
13. 总结
Spring Boot 定时任务的难点不在@Scheduled注解,而在分布式环境下如何让任务“可重复触发但不重复伤害业务”。
我的实践建议是:
- 简单任务先用
@Scheduled,不要过度设计。 - 多实例固定任务使用
@Scheduled + ShedLock,成本低、接入快。 - 动态任务、租户级任务和复杂 misfire 策略使用 Quartz。
- 所有核心业务任务都必须做幂等,不能把希望寄托在分布式锁上。
- 任务要有流水、指标、告警和补偿入口,否则上线后就是一只藏在夜里的小怪兽。
真正可靠的定时任务不是“永远不失败”,而是失败后可发现、可重试、可恢复,重复执行也不会产生错误业务结果。
参考资料
- Spring Framework Task Execution and Scheduling:https://docs.spring.io/spring-framework/reference/integration/scheduling.html
- Spring
@ScheduledJavadoc:https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/annotation/Scheduled.html - ShedLock GitHub:https://github.com/lukas-krecan/ShedLock
- ShedLock Maven Central:https://central.sonatype.com/artifact/net.javacrumbs.shedlock/shedlock-spring
- Spring Boot Quartz Scheduler:https://docs.spring.io/spring-boot/reference/io/quartz.html
- Quartz JDBC JobStore Clustering:https://www.quartz-scheduler.org/documentation/quartz-2.3.0/configuration/ConfigJDBCJobStoreClustering.html