Spring Scheduled 多实例重复执行:定时任务如何保证只有一个节点运行
Spring Scheduled 在单实例中按计划执行,服务扩容后每个实例都会执行同一任务。重复发券、重复结算和重复扫描,并不是定时框架故障,而是部署模型发生了变化。
关键风险: 用 Redis 锁只让一个节点进入任务是一种轻量方案,但还要考虑任务超过锁租期、节点宕机、重入和补偿。调度互斥只能保证"尽量一个执行者",不能替代业务幂等。
MetaLite 将分布式互斥封装为任务可复用的底座能力。本文先界定定时任务在集群中的正确语义,再用锁实现说明哪些问题由基础设施解决、哪些必须由业务保证。
一、Spring Scheduled 为什么天然会重复
@Scheduled 由当前 Spring ApplicationContext 注册和触发。
它不知道同一个服务在其他机器上是否还有实例,也没有内置 leader 选举。
因此,扩容后每个实例都拥有一份相同调度计划。这不是 Spring 的 Bug,而是进程内调度器的正常语义。
需要集群只执行一次时,必须增加跨实例协调机制,例如:
- Redis 锁;
- 数据库抢占记录;
- 调度平台;
- leader 选举;
- 消息队列派发。
MetaLite 为轻量 Spring Scheduled 场景选择了可选 Redis 锁。
二、为什么不是在每个 Job 里手写加锁
如果每个任务都写:
java
if (!locker.tryLock(key)) {
return;
}
try {
runBusiness();
} finally {
locker.unlock(key, value);
}
很容易出现某个任务忘记 finally、使用固定锁值或直接 DEL。
MetaLite 把所有 @Scheduled 方法统一拦截为 JOB_RUN 入口:
java
@Around("@annotation(org.springframework.scheduling.annotation.Scheduled)")
public Object aroundSpringSchedulerJob(ProceedingJoinPoint pjp) {
return entranceMethodAspectAround(pjp, AspectTypeEnum.JOB_RUN);
}
SpringScheduledJobExecuteHandler 在前置阶段抢锁,后置阶段解锁;JobRunLogger 复用统一入口日志语义。
业务方法只需声明调度与锁策略。
三、一个集群任务怎样声明
当前示例:
java
@RedisLock(
key = "lock:job:pwdExpireCheckJob",
expireMillis = 60000L,
autoRenewal = true
)
@Scheduled(cron = "0 0 5 * * ?")
public void checkPwdExpire() {
// 业务处理
}
三个实例同时进入前置处理,只有一个能通过 Redis SET NX 获得锁。
未获得锁的实例返回:
text
已有其他实例执行该 Job
它不会继续调用业务方法。
这是一种抢占式执行:没有固定 leader,谁先拿到锁谁执行本次任务。
四、Redis 模块关闭时会发生什么
Job 配置对 RedisLocker 使用可选注入。
前置处理器当前逻辑是:
java
if (redisLocker == null) {
return Resp.ok();
}
也就是说,Redis 模块关闭时,@RedisLock 不会阻止 Job 执行。每个服务实例都会继续运行自己的任务。
这保证无 Redis 环境仍能启动,但安全语义是 fail-open。
对于"重复执行绝对不可接受"的结算类任务,更合理的选择可能是启动失败或本次任务失败,而不是静默退回多实例重复执行。
是否允许降级,应成为任务级配置,而不是所有 Job 共用一个决定。
五、没抢到锁是失败,还是正常跳过
当前未获得锁时返回一个错误 Resp,统一切面不会执行目标方法,但仍会进入日志处理。
从技术上看,本实例没有完成任务;从集群语义看,只要另一个实例正在执行,这又可能是正常状态。
监控应该区分:
text
SKIPPED_LOCKED → 其他实例已执行,本实例正常跳过
FAILED → 本应执行但业务异常
如果把所有未抢锁都计入错误率,多实例越多,告警越严重;如果全部忽略,又可能掩盖所有实例都没有成功完成任务。
完整治理需要一个集群级任务运行记录,而不只是每实例日志。
六、自动续期能否保护整个任务
当前锁默认按 TTL 的 30% 周期续期,并通过 Lua 比较锁值后再延长。
但续期次数存在上限,不是任务不结束就无限续期的 watchdog。续期还使用秒级 EXPIRE,配置的毫秒会除以 1000。
因此:
text
autoRenewal=true
不能直接推导出"任意长任务都不会丢锁"。
任务执行时间必须有上限,锁保护窗口必须经过计算,并监控执行时长超过 TTL 或达到最大续期次数的情况。
对于持续数小时、需要断点恢复的任务,专业调度平台或带租约的任务表通常比一个短 TTL Redis 锁更合适。
七、实例在业务执行中崩溃会怎样
持锁实例崩溃后,finally 无法解锁,但 Redis TTL 最终会释放锁。
问题是:其他实例已经在本次触发时刻因为抢锁失败而跳过,不会自动等锁过期后补跑。
所以当前方案提供的是:
text
同一次调度触发尽量只有一个实例执行
它不提供任务认领、失败重试、故障转移和补偿调度。
若任务必须"最终至少成功一次",还需要运行记录与补偿机制,例如记录计划时间、状态、执行实例、开始结束时间,并由扫描任务重新认领超时记录。
八、为什么给 Scheduled 配独立线程池
Spring 默认调度器容易因单线程任务阻塞,让无关 Job 相互等待。
MetaLite 在 SchedulingConfigurer 中设置:
java
taskRegistrar.setTaskScheduler(
ThreadPoolManager.createScheduledThreadPool(
"springScheduledJobScheduler",
CPU_CORE_NUMBER));
这样不同任务可以并发触发,线程名也能标识调度来源。
但线程池扩大只解决本 JVM 内任务互相阻塞,不解决同一个 Job 多实例重复。并发调度与分布式互斥是两个不同层次。
另外,当前定时线程池工厂虽然接收拒绝策略参数,实际固定使用 CallerRunsPolicy;这项行为应与方法签名保持一致。
九、固定延迟、固定频率和 Cron 要考虑什么
不同调度语义对锁的影响不同:
- Cron:各实例在相近时刻竞争;
- fixedRate:任务执行时间超过周期时可能形成追赶压力;
- fixedDelay:下一次时间通常基于上一次完成;
- 不同实例时钟偏差:竞争时间可能错开。
锁 Key 还要决定是否包含计划批次。
固定 Key 能保证同一时刻只有一个任务,但前一次异常长运行可能阻止下一批。加入业务日期或计划时间能区分批次,却可能让两批任务并行。
Key 的设计必须结合任务是否允许跨批次并发。
十、哪些任务不应该只靠 @Scheduled + Redis 锁
以下场景更需要完整调度系统:
- 任务必须保证至少成功一次;
- 需要分片并行;
- 需要手工重跑与补数据;
- 需要 DAG 依赖;
- 任务执行数小时;
- 需要可视化运行记录和告警;
- 需要故障实例自动转移执行权。
@Scheduled + RedisLock 更适合执行时间较短、下一周期可自然补偿、调度关系简单的集群任务。
十一、判断任务治理是否完成的三个问题
每个定时任务上线前至少回答:
- 它应该每实例执行,还是集群一次?
- 执行节点崩溃后,本批任务需要补跑吗?
- 重复执行时,业务是否仍然幂等?
MetaLite 当前通过 JOB_RUN 切面、Redis 锁、统一日志和独立调度线程池,解决了轻量场景中的多实例抢占执行。
它没有把 Redis 锁包装成完整调度平台,也不应该这样宣传。真正可靠的任务,仍需要业务幂等、执行记录和明确的失败恢复目标。
框架简介
MetaLite 是面向企业生产环境的新一代 Java 微服务技术底座。系列文章重点分享代码背后的设计思路、技术取舍与工程实践。
源码基线
JDK 21、Spring Boot 3.2.9、Spring Cloud 2023.0.1、Spring Cloud Alibaba 2023.0.1.3,具体组件版本以项目 backend-bom 为准。
作者简介
15 年 Spring 体系企业级开发经验,专注于 Java 微服务架构、工程治理与生产实践。
持续更新
MetaLite 系列内容将持续更新,围绕核心设计、源码链路、技术取舍与生产实践展开。欢迎关注作者,及时获取后续内容。
在线演示
演示地址: https://admin.metalite.top/
演示账号: guess
演示密码: admin@2026