Spring Boot 任务调度与分布式锁实战:从 `@Scheduled` 到 ShedLock、Quartz 与幂等补偿
2026/7/19 13:23:38 网站建设 项目流程

1. 定时任务最容易踩的四类坑

很多线上事故不是因为 cron 写错,而是因为我们默认“任务只会执行一次”。在分布式系统里,这个默认假设通常不成立。

常见风险有四类:

  1. 多实例重复执行
    一个服务部署了 3 个 Pod,每个 Pod 都加载同一段@Scheduled代码,到点后会同时触发 3 次。

  2. 同节点重叠执行
    任务每 1 分钟触发一次,但实际执行需要 3 分钟。如果线程池允许并发,就可能出现同一个任务交叠运行。

  3. 中途失败没有可恢复状态
    任务处理了 1000 条数据,跑到第 600 条时应用重启。如果没有任务流水、处理标记或幂等条件,下一次很难判断应该从哪里继续。

  4. 锁释放与业务提交不一致
    分布式锁只保护“任务入口”,不能证明“业务结果只发生一次”。如果任务拿到锁后调用外部支付、发券、发消息,仍然要靠业务幂等兜底。

所以,生产级定时任务的目标不是追求绝对的“只执行一次”,而是做到:

  • 任务入口尽量单点触发;
  • 业务状态更新天然幂等;
  • 失败后可以重试;
  • 重复触发不会产生重复副作用;
  • 能观测到任务耗时、成功数、失败数和跳过原因。

2. 先选对方案:@Scheduled、ShedLock 还是 Quartz

选型时不要一上来就堆框架。先看任务的业务属性。

场景推荐方案原因
单实例应用、低风险清理任务@Scheduled简单直接,维护成本低
多实例部署,但任务定义固定@Scheduled+ ShedLock保留 Spring 调度模型,用共享存储避免多节点同时跑
需要页面动态创建、暂停、删除任务QuartzJob、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注解,而在分布式环境下如何让任务“可重复触发但不重复伤害业务”。

我的实践建议是:

  1. 简单任务先用@Scheduled,不要过度设计。
  2. 多实例固定任务使用@Scheduled + ShedLock,成本低、接入快。
  3. 动态任务、租户级任务和复杂 misfire 策略使用 Quartz。
  4. 所有核心业务任务都必须做幂等,不能把希望寄托在分布式锁上。
  5. 任务要有流水、指标、告警和补偿入口,否则上线后就是一只藏在夜里的小怪兽。

真正可靠的定时任务不是“永远不失败”,而是失败后可发现、可重试、可恢复,重复执行也不会产生错误业务结果。

参考资料

  • 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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询