Spring Boot 任务调度与分布式锁实战:从 `@Scheduled` 到 ShedLock、Quartz 与幂等补偿

📅 2026/7/19 13:23:46
Spring Boot 任务调度与分布式锁实战:从 `@Scheduled` 到 ShedLock、Quartz 与幂等补偿
1. 定时任务最容易踩的四类坑很多线上事故不是因为 cron 写错而是因为我们默认“任务只会执行一次”。在分布式系统里这个默认假设通常不成立。常见风险有四类多实例重复执行一个服务部署了 3 个 Pod每个 Pod 都加载同一段Scheduled代码到点后会同时触发 3 次。同节点重叠执行任务每 1 分钟触发一次但实际执行需要 3 分钟。如果线程池允许并发就可能出现同一个任务交叠运行。中途失败没有可恢复状态任务处理了 1000 条数据跑到第 600 条时应用重启。如果没有任务流水、处理标记或幂等条件下一次很难判断应该从哪里继续。锁释放与业务提交不一致分布式锁只保护“任务入口”不能证明“业务结果只发生一次”。如果任务拿到锁后调用外部支付、发券、发消息仍然要靠业务幂等兜底。所以生产级定时任务的目标不是追求绝对的“只执行一次”而是做到任务入口尽量单点触发业务状态更新天然幂等失败后可以重试重复触发不会产生重复副作用能观测到任务耗时、成功数、失败数和跳过原因。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 定时任务开始。一个常见的订单超时关闭任务如下Slf4jComponentRequiredArgsConstructorpublicclassOrderTimeoutJob{privatefinalOrderTimeoutServiceorderTimeoutService;Scheduled(cron0 */5 * * * *,zoneAsia/Shanghai)publicvoidcloseTimeoutOrders(){longstartSystem.nanoTime();intclosedorderTimeoutService.closeTimeoutOrders();longcostMsTimeUnit.NANOSECONDS.toMillis(System.nanoTime()-start);log.info(close timeout orders finished, closed{}, costMs{},closed,costMs);}}这段代码适合开发、测试或单实例部署。生产中还需要显式配置调度线程池避免所有任务挤在默认线程里ConfigurationEnableSchedulingpublicclassSchedulingConfigimplementsSchedulingConfigurer{BeanpublicThreadPoolTaskSchedulertaskScheduler(){ThreadPoolTaskSchedulerschedulernewThreadPoolTaskScheduler();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为示例真实项目建议统一放进依赖管理并定期升级。dependenciesdependencygroupIdnet.javacrumbs.shedlock/groupIdartifactIdshedlock-spring/artifactIdversion7.7.0/version/dependencydependencygroupIdnet.javacrumbs.shedlock/groupIdartifactIdshedlock-provider-jdbc-template/artifactIdversion7.7.0/version/dependency/dependencies创建锁表CREATETABLEshedlock(nameVARCHAR(64)NOTNULLPRIMARYKEY,lock_untilTIMESTAMP(3)NOTNULL,locked_atTIMESTAMP(3)NOTNULL,locked_byVARCHAR(255)NOTNULL);再配置LockProviderConfigurationEnableSchedulingEnableSchedulerLock(defaultLockAtMostForPT10M)publicclassShedLockConfig{BeanpublicLockProviderlockProvider(DataSourcedataSource){returnnewJdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder().withJdbcTemplate(newJdbcTemplate(dataSource)).usingDbTime().build());}}usingDbTime()很重要。多节点机器时间可能存在偏差用数据库时间作为锁判断依据可以减少时钟漂移带来的误判。任务上加锁Slf4jComponentRequiredArgsConstructorpublicclassOrderTimeoutJob{privatefinalOrderTimeoutServiceorderTimeoutService;Scheduled(cron0 */5 * * * *,zoneAsia/Shanghai)SchedulerLock(nameorderTimeoutJob,lockAtMostForPT8M,lockAtLeastForPT10S)publicvoidcloseTimeoutOrders(){intclosedorderTimeoutService.closeTimeoutOrders();log.info(order timeout job finished, closed{},closed);}}两个参数要认真设置lockAtMostFor兜底锁时间。节点宕机后锁不会永久不释放。它应该大于任务正常最长耗时但不能大到影响恢复。lockAtLeastFor最短持锁时间。可以防止任务执行太快时多个节点在同一时间窗口连续触发。经验上先观察任务 P99 耗时再设置lockAtMostFor。如果任务 P99 是 2 分钟可以先设 5 到 8 分钟如果任务经常超过锁时间应该优化任务拆分而不是盲目加大锁时间。6. 分布式锁不能替代业务幂等这是整篇文章最重要的一句话分布式锁保护的是“谁进入任务”不是“业务效果只发生一次”。比如一个关闭超时订单的任务即使用了 ShedLock也要在数据库更新时加条件RepositorypublicinterfaceOrderRepositoryextendsJpaRepositoryOrderEntity,Long{ModifyingQuery( 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 层根据影响行数判断是否真的关闭成功ServiceRequiredArgsConstructorpublicclassOrderTimeoutService{privatestaticfinalintBATCH_SIZE200;privatefinalOrderQueryRepositoryorderQueryRepository;privatefinalOrderRepositoryorderRepository;privatefinalApplicationEventPublishereventPublisher;TransactionalpublicintcloseTimeoutOrders(){InstanttimeoutBeforeInstant.now().minus(Duration.ofMinutes(30));ListLongorderIdsorderQueryRepository.findPendingTimeoutOrderIds(timeoutBefore,BATCH_SIZE);intclosed0;InstantnowInstant.now();for(LongorderId:orderIds){intaffectedorderRepository.closeIfTimeout(orderId,timeoutBefore,now);if(affected1){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){JobRunLoglognewJobRunLog();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){StringmessageStringUtils.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 示例ComponentDisallowConcurrentExecutionRequiredArgsConstructorpublicclassInvoiceReconciliationJobextendsQuartzJobBean{privatefinalInvoiceReconciliationServicereconciliationService;OverrideprotectedvoidexecuteInternal(JobExecutionContextcontext){JobDataMapdataMapcontext.getMergedJobDataMap();StringtenantIddataMap.getString(tenantId);reconciliationService.reconcile(tenantId);}}配置 JobDetail 和 TriggerConfigurationpublicclassQuartzJobConfig{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(){inttotal0;while(true){intclosedtransactionTemplate.execute(status-closeOneBatch(Instant.now().minus(Duration.ofMinutes(30)),200));if(closed0){break;}totalclosed;if(total2000){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){StringbizKeytimeout-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){DurationbackoffcalculateBackoff(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最近一次成功时间。示例ComponentRequiredArgsConstructorpublicclassJobMetrics{privatefinalMeterRegistrymeterRegistry;publicTTrecord(StringjobName,SupplierTsupplier){Timer.SamplesampleTimer.start(meterRegistry);try{Tresultsupplier.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 Schedulinghttps://docs.spring.io/spring-framework/reference/integration/scheduling.htmlSpringScheduledJavadochttps://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/annotation/Scheduled.htmlShedLock GitHubhttps://github.com/lukas-krecan/ShedLockShedLock Maven Centralhttps://central.sonatype.com/artifact/net.javacrumbs.shedlock/shedlock-springSpring Boot Quartz Schedulerhttps://docs.spring.io/spring-boot/reference/io/quartz.htmlQuartz JDBC JobStore Clusteringhttps://www.quartz-scheduler.org/documentation/quartz-2.3.0/configuration/ConfigJDBCJobStoreClustering.html