多实例部署下,如何避免@Scheduled重复执行
每天凌晨两点,结算任务会生成前一天的账单。单机时代一直很正常,改成三副本部署后的第一个早上,财务发现同一批订单被结算了三次。日志里三个实例几乎在同一秒打印了“开始结算”,每个实例都觉得自己是在正常执行。
| 项目 | 说明 |
|---|---|
| 难度 | 中级 |
| 涉及 | Spring@Scheduled、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 里,多个实例通过同一份记录竞争,只有一个能拿到锁。
先在配置类里开启调度和锁:
@Configuration@EnableScheduling@EnableSchedulerLock(defaultLockAtMostFor="30m")publicclassSchedulingConfig{@BeanpublicLockProviderlockProvider(DataSourcedataSource){returnnewJdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder().withJdbcTemplate(newJdbcTemplate(dataSource)).usingDbTime().build());}}usingDbTime()很重要。不同实例的服务器时间可能有几十毫秒甚至几秒的偏差,如果各自用自己的时钟判断锁有没有过期,很容易出现一个实例认为锁已经释放、另一个实例还在执行的情况。统一使用数据库时间,判断标准就一致了。
然后在任务方法上加锁:
@Scheduled(cron="0 0 2 * * *",zone="Asia/Shanghai")@SchedulerLock(name="dailySettlement",lockAtMostFor="30m",lockAtLeastFor="1m")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