多实例部署下,如何避免 `@Scheduled` 重复执行
多实例部署下如何避免Scheduled重复执行每天凌晨两点结算任务会生成前一天的账单。单机时代一直很正常改成三副本部署后的第一个早上财务发现同一批订单被结算了三次。日志里三个实例几乎在同一秒打印了“开始结算”每个实例都觉得自己是在正常执行。项目说明难度中级涉及SpringScheduled、ShedLock、Quartz、任务幂等核心问题Scheduled只在当前 JVM 内调度多个实例之间没有任何协调1. 三个实例三个准点闹钟Scheduled的原理很朴素Spring 在应用启动时创建一个任务调度器到了 cron 指定的时间就在当前进程里触发方法。它不知道外面还有几个实例。三个 Pod 各自启动一个调度器就像三个人各带了一块表都把闹钟定在凌晨两点。时间一到三个人同时开始干活。Pod A 02:00:00 开始结算 Pod B 02:00:00 开始结算 Pod C 02:00:00 开始结算每个实例的日志都正常没有异常也没有重复执行的警告。问题不在某一个实例而在于“调度”这件事本身就是各自为政的。单机部署时这个模型没有问题因为只有一个进程。一旦上了多副本、滚动发布、弹性扩容定时任务就从“一次”变成了“每个实例一次”。如果任务里有发券、结算、扣款、推送这类副作用重复执行就会直接变成业务事故。、图 1三个实例各自到点触发只有拿到共享锁的那个实例继续执行。2. 先加一把所有实例都看得见的锁最直接的修法是任务开始时先抢一把共享锁抢到的实例执行没抢到的直接跳过。ShedLock 就是为这个场景设计的。它把锁记录放在数据库或 Redis 里多个实例通过同一份记录竞争只有一个能拿到锁。先在配置类里开启调度和锁ConfigurationEnableSchedulingEnableSchedulerLock(defaultLockAtMostFor30m)publicclassSchedulingConfig{BeanpublicLockProviderlockProvider(DataSourcedataSource){returnnewJdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder().withJdbcTemplate(newJdbcTemplate(dataSource)).usingDbTime().build());}}usingDbTime()很重要。不同实例的服务器时间可能有几十毫秒甚至几秒的偏差如果各自用自己的时钟判断锁有没有过期很容易出现一个实例认为锁已经释放、另一个实例还在执行的情况。统一使用数据库时间判断标准就一致了。然后在任务方法上加锁Scheduled(cron0 0 2 * * *,zoneAsia/Shanghai)SchedulerLock(namedailySettlement,lockAtMostFor30m,lockAtLeastFor1m)publicvoidsettleDaily(){settlementService.settle(LocalDate.now().minusDays(1));}lockAtMostFor是锁的最长持有时间。它必须比任务的最长执行时间更长。如果任务实际跑了 40 分钟而锁只保留 30 分钟第 31 分钟另一个实例就能拿到锁并开始执行重复问题又回来了。lockAtLeastFor是锁的最短持有时间。它主要用来防止任务执行得太快时锁立刻释放另一个实例因为时钟偏差或调度抖动又触发一次。对于一个每天只跑一次的任务这个值不用太大对于每分钟执行的任务它更有意义。ShedLock 依赖一张shedlock表记录任务名、锁到期时间、加锁时间和持有者。这张表需要按官方 SQL 提前初始化不会自动出现在数据库里。多个实例看到的是同一张表所以能协调起来。3. ShedLock 保证的是“同时只有一个”不是“永远只执行一次”这是使用 ShedLock 时最容易误解的一点。ShedLock 能保证同一时刻只有一个实例在执行任务。但它无法保证任务在所有情况下只执行一次实例 A 拿到锁执行到一半进程崩溃锁到期后实例 B 会重新执行任务执行时间超过lockAtMostFor实例 B 可能提前拿到锁数据库主从切换、网络分区等极端情况也可能让锁的判断出现偏差任务本身被人工重跑、补数据也会再次执行。所以“加锁”只是第一层任务本身还要做到幂等。常见做法是给每次任务执行建立一个唯一标识比如“任务名 业务日期”CREATETABLEjob_run_record(job_nameVARCHAR(64)NOTNULL,biz_dateDATENOTNULL,statusVARCHAR(16)NOTNULL,started_atDATETIMENOTNULL,finished_atDATETIME,PRIMARYKEY(job_name,biz_date));任务开始时先插入一条记录try{jobRunMapper.start(dailySettlement,bizDate);}catch(DuplicateKeyExceptione){log.info({} 已执行过跳过,bizDate);return;}settlementService.settle(bizDate);jobRunMapper.markSuccess(dailySettlement,bizDate);如果插入失败说明这个业务日期已经有人开始处理了直接跳过。这样即使锁失效、任务被重复触发也不会重复结算。更严谨一点还要考虑“开始记录写进去了但任务执行失败”的情况。记录不能永远是RUNNING需要有失败状态、重试策略和人工补偿。任务幂等的关键不是“永远不重复”而是“重复执行不会产生第二份业务结果”。4. 除了 ShedLock还有哪些选择如果项目已经在用 Quartz可以开启集群模式。Quartz 会把任务和触发器存在数据库里多个实例通过数据库锁竞争触发权。它比 ShedLock 重一些但胜在调度、错过触发、失败恢复这些能力更完整。适合任务数量多、调度规则复杂的系统。也可以把任务交给外部调度平台比如 XXL-JOB。应用实例只负责执行调度中心只选一个执行器触发。这样多实例下天然只有一个触发源但需要额外的调度中心和维护成本。如果只是偶尔一两个任务用 Redis 分布式锁也可以。但要注意锁的过期时间、释放时的持有者校验以及 Redis 主从切换可能丢锁的问题。数据库锁通常更稳因为任务调度本来就不追求毫秒级。Kubernetes 的 CronJob 也能做到只触发一次但要设置concurrencyPolicy: Forbid否则上一次没跑完时下一次仍可能并发启动。它适合与业务应用解耦的批处理任务不适合依赖应用内 Bean 的短任务。5. 还有两个容易被忽略的细节第一个是时区。Scheduled的 cron 默认使用服务器时区容器里常常是 UTC。你以为是凌晨两点实际可能在北京时间上午十点执行。显式写上zone Asia/Shanghai比依赖容器默认值可靠。第二个是调度线程池。Spring Boot 默认的调度线程池只有一个线程。如果你有多个Scheduled方法其中一个跑得久其他任务就会排队等待看起来像“任务没有按时执行”。可以通过spring.task.scheduling.pool.size调大线程数但也要注意任务之间是否会争抢同一份资源。总结Scheduled是 JVM 内的闹钟多实例部署时每个实例都会响。解决重复执行先要有一个跨实例的协调机制ShedLock、Quartz 集群或外部调度平台都可以然后再给任务本身加幂等保证锁失效、进程崩溃或人工重跑时不会产生第二份业务结果。锁解决“同时有几个在跑”幂等解决“跑了多次会怎样”两者缺一不可。参考资料ShedLock 官方文档Spring Framework: Task Execution and Scheduling