Spring-Scheduled多实例重复执行怎么办-Redis分布式锁如何只让一个节点运行

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 更适合执行时间较短、下一周期可自然补偿、调度关系简单的集群任务。

十一、判断任务治理是否完成的三个问题

每个定时任务上线前至少回答:

  1. 它应该每实例执行,还是集群一次?
  2. 执行节点崩溃后,本批任务需要补跑吗?
  3. 重复执行时,业务是否仍然幂等?

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

相关推荐
Code知行合壹15 分钟前
数据中台设计
大数据·分布式·spark
waillyer44 分钟前
企业知识库智能问答 Agent(React + Nest)
javascript·redis·agent
yandong6341 小时前
redis添加到win服务
数据库·redis·缓存
catino1 小时前
spring-IOC、DI
java·spring·rpc
攻城有术2 小时前
专项攻克——redis分布式锁-用LUA脚本解决setNX锁不释放问题
redis·分布式·lua
会博通·代码搬运工3 小时前
双层PDF技术实现与档案数字化系统的API集成实践
数据库·人工智能·分布式·python·计算机视觉·api集成
wear工程师3 小时前
Spring 循环依赖为什么要三级缓存:两级会把 AOP 代理弄丢
java·spring
AI人工智能+电脑小能手3 小时前
大白话说Java设计模式-42-访问者模式(业务实战篇)
java·spring·设计模式·访问者模式·asm·营销规则·数据结构与操作分离
观测云3 小时前
Spring AI + Spring AI Alibaba 接入观测云最佳实践
人工智能·spring