SpringBoot自带TaskScheduler 接口使用详解:(02)微服务多实例模式下,爆发任务重复执行问题

在集群模式下,由于 TaskScheduler 是单机本地调度器,每个服务实例都会独立运行并触发定时任务,从而导致任务重复执行。要解决这个问题,核心思路是引入**分布式锁(Distributed Lock)**机制,确保同一时刻只有一个节点能成功获取到执行权限。

【一】,两种分布式锁

以下是几种主流且实用的解决方案,你可以根据项目现有的技术栈进行选择:

🛡️ 【方案一】:引入轻量级分布式锁框架 ShedLock(强烈推荐)

ShedLock 是一个专门为解决此类问题设计的轻量级框架。它通过与共享存储(如数据库、Redis、MongoDB等)交互,在任务执行前获取锁,执行完毕后释放锁,从而保证任务在集群中只被执行一次。

实现步骤:

  1. 引入依赖 :在 pom.xml 中添加 shedlock-spring 以及对应的存储依赖(例如 shedlock-provider-jdbc-template)。
  2. 配置 LockProvider :配置一个基于共享存储的 LockProvider(例如基于数据库的 JdbcTemplateLockProvider)。
  3. 开启调度锁 :在 Spring Boot 启动类上添加 @EnableSchedulerLock(defaultLockAtMostFor = "PT30S") 注解。
  4. 在任务方法上加锁 :在需要单实例执行的定时任务方法上,同时加上 @Scheduled@SchedulerLock 注解。

🛠️ 第一步:引入依赖并开启代理模式

pom.xml 中引入 ShedLock 核心依赖(确保版本在 4.0.0 以上)以及你选择的存储依赖(这里以 Redis 为例,数据库同理)。

XML 复制代码
<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-spring</artifactId>
    <version>4.42.0</version>
</dependency>
<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-provider-redis-spring</artifactId>
    <version>4.42.0</version>
</dependency>

在 Spring Boot 启动类或配置类上,开启 ShedLock 并指定代理模式为 PROXY_METHOD

java 复制代码
@SpringBootApplication
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT30S",
         proxyMode = SchedulerLock.ProxyMode.PROXY_METHOD)
public class DynamicTaskApplication {
    public static void main(String[] args) {
        SpringApplication.run(DynamicTaskApplication.class, args);
    }
}

🔑 第二步:配置 LockProvider

配置 ShedLock 的底层存储(以 Redis 为例):

java 复制代码
@Configuration
public class ShedLockConfig {
    @Bean
    public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
        return new RedisLockProvider(connectionFactory);
    }
}

📝 第三步:编写带锁的业务任务类

将具体的业务逻辑抽取出来,并在**需要加锁的方法上**添加 `@SchedulerLock` 注解。**注意:该方法必须是 `public` 的**,否则代理无法生效

java 复制代码
@Component
public class MyDynamicTasks {

    // 这里的 name 必须保证全局唯一
    @SchedulerLock(name = "dynamicTask_lock", 
        lockAtMostFor = "PT10M", lockAtLeastFor = "PT1M")
    public void executeTask() {
        System.out.println("【集群单点执行】动态定时任务正在运行业务逻辑..."
             + LocalDateTime.now());
        // 模拟业务执行耗时
        try { 

            Thread.sleep(5000); 

        } catch (InterruptedException e) {


        }
    }
}

⚙️ 第四步-01:在动态调度中注入并调用任务

在实现 SchedulingConfigurer 的配置类中,注入上面的业务任务类,并在 addTriggerTaskRunnable 中直接调用带锁的方法。

java 复制代码
@Configuration
public class DynamicScheduleConfig implements SchedulingConfigurer {

    @Autowired
    private MyDynamicTasks myDynamicTasks;

    // 假设这是你从数据库动态获取的 Cron 表达式
    private String getCronFromDB() {
        return "0/10 * * * * ?"; // 每10秒执行一次
    }

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.addTriggerTask(
            // 1. 任务的具体执行逻辑(Runnable)
            () -> myDynamicTasks.executeTask(), 
            
            // 2. 触发器(动态获取执行周期)
            triggerContext -> {

                String cron = getCronFromDB();
                return new CronTrigger(cron).nextExecutionTime(triggerContext);

            }
        );
    }
}

⚙️ 第四步-02:在@Scheduled静态调度中注入并调用任务

java 复制代码
@Scheduled(cron = "0 0/15 * * * ?")
@SchedulerLock(name = "myClusterTask", 
            lockAtLeastFor = "PT5M", lockAtMostFor = "PT14M")
public void myScheduledTask() {
    // 只有获取到锁的节点才会执行这里的业务逻辑
}

注:lockAtMostFor 表示如果执行节点宕机,锁最多保留多久;lockAtLeastFor 表示锁的最短持有时间,防止任务执行过快导致多个节点重复触发。

💡 核心原理解析

当你调用 myDynamicTasks.executeTask() 时,由于开启了 PROXY_METHOD 模式,Spring 会生成一个 MyDynamicTasks 的 AOP 代理对象。

  1. 代理对象拦截到 executeTask() 的调用。
  2. 在真正执行业务代码前,ShedLock 会先去 Redis(或数据库)尝试获取名为 dynamicTask_lock 的分布式锁。
  3. 获取成功 :执行真正的 executeTask() 业务逻辑,执行完毕后自动释放锁(或等到过期时间)。
  4. 获取失败:说明集群中其他节点正在执行该任务,当前节点直接跳过,不执行任何逻辑。

⚠️ 避坑指南

  1. 必须用代理模式 :如果你不配置 proxyMode = SchedulerLock.ProxyMode.PROXY_METHOD,直接把 @SchedulerLock 加在 Runnablerun() 方法上是完全无效的。
  2. 方法必须为 public@SchedulerLock 修饰的方法必须是 public,否则 Spring AOP 无法拦截。
  3. 锁的时间设置lockAtMostFor(最长持有时间)一定要大于你的业务逻辑正常执行的时间,否则任务还没跑完锁就失效了,会导致其他节点重复执行。

🔑 【方案二】:手动使用 Redis 分布式锁

如果你的项目中已经集成了 Redis,可以手动在 TaskScheduler 的任务逻辑(Runnable)中加入 Redis 锁的控制。利用 Redis 的 SETNX(set if not exists)原子命令来实现互斥。

实现思路:

在动态添加的任务逻辑中,先尝试获取 Redis 锁,获取成功则执行具体业务,执行完毕后释放锁;如果获取失败,则直接跳过。

实现步骤:

  1. 引入依赖 :在 pom.xml 中添加 redis 依赖。
  2. 配置 redis:配置redis
  3. 在业务代码中加redis锁
java 复制代码
public void startTask(String taskId, String cronExpression) {
    Runnable taskLogic = () -> {
        // 尝试获取分布式锁,设置过期时间防止死锁
        Boolean locked = redisTemplate.opsForValue()
                .setIfAbsent("task:lock:" + taskId, "locked", 10, TimeUnit.MINUTES);
        if (Boolean.TRUE.equals(locked)) {
            try {
                System.out.println("节点获取锁成功,开始执行任务:" + taskId);
                // 执行具体的业务逻辑
            } finally {
                // 任务执行完毕,释放锁
                redisTemplate.delete("task:lock:" + taskId);
            }
        } else {
            System.out.println("任务已被其他节点执行,当前节点跳过。");
        }
    };
    taskScheduler.schedule(taskLogic, new CronTrigger(cronExpression));
}

【二】,ShedLock和Redis锁方案有什么区别?

ShedLock 和 Redis 锁并不是非此即彼的对立关系,而是**"业务场景"** 与**"底层技术"**的区别。

简单来说,ShedLock 是一个专门为定时任务设计的分布式锁框架,而 Redis 锁是一种通用的分布式锁底层实现技术。 事实上,ShedLock 的底层完全可以使用 Redis 来实现。

为了让你更清晰地理解两者的差异,我们可以从以下几个维度进行对比:

表格

维度 ShedLock 原生/通用 Redis 锁 (如 Redisson)
定位与场景 专为定时任务设计。解决集群下定时任务重复执行的问题。 通用业务锁。适用于秒杀扣库存、防重复下单、资源抢占等高并发业务场景。
使用方式 声明式(极简) 。通过 @SchedulerLock 注解即可无侵入地加锁。 编程式(手动) 。需要在代码中手动编写加锁、释放锁的逻辑(如 lock()unlock())。
锁的释放 自动过期。任务执行完不主动释放,依赖预设的过期时间自动失效。 主动释放 。业务执行完必须在 finally 块中主动释放,否则会导致死锁。
防抖动 自带防抖 。提供 lockAtLeastFor 参数,防止任务执行过快导致多节点重复触发。 无此概念。通常需要业务层自行处理幂等性或防重逻辑。
底层存储 支持多种后端。底层可以用 Redis,也可以用数据库(JDBC)、MongoDB、ZooKeeper 等。 仅限 Redis。完全依赖 Redis 服务器的原子命令(如 SETNX)和 Lua 脚本。

💡 核心区别深度解析

1. 解决的核心痛点不同

  • ShedLock:它的目标是"省事"。在分布式定时任务中,你只需要告诉它"这个任务同一时间只能有一个节点跑",ShedLock 就会帮你处理好抢锁、过期、防死锁等所有细节。它非常适合 cron 表达式触发的周期性任务。
  • Redis 锁:它的目标是"精准控制"。在秒杀、抢券等瞬间高并发的业务中,你需要精确控制锁的获取、等待时间、自动续期(看门狗机制)以及细粒度的释放,ShedLock 无法满足这种复杂的业务流控制。

2. 容错与防死锁机制不同

  • ShedLock :采用的是**"租约模型"** 。它假设任务会在指定时间内跑完,通过设置 lockAtMostFor(最长持有时间)来兜底。即使执行任务的节点直接宕机,锁也会在时间到了之后自动在存储层(如 Redis)过期,防止死锁。
  • Redis 锁 :通常需要配合**"看门狗(Watchdog)"**机制(如 Redisson 框架提供)。如果业务执行时间不确定,看门狗会在后台自动为锁续期,直到业务执行完毕主动释放。

🚀 选型建议

  • 如果你只是想解决 Spring Boot 定时任务在集群下重复执行的问题 :请直接使用 ShedLock ,并且将它的底层存储配置为 Redis。这样既享受了 ShedLock 注解的便利,又拥有了 Redis 的高性能。
  • 如果你是在写具体的业务接口(如用户下单、支付回调、库存扣减) :请使用通用的 Redis 锁框架(如 Redisson),在业务代码中手动控制加锁和解锁。

总结建议:

  • 如果项目较简单,且已有 Redis 或数据库,推荐使用 ShedLock手动 Redis 锁,改动最小,侵入性低。
  • 如果业务复杂、任务量庞大,强烈建议直接引入 XXL-JOB 等专业调度平台,一劳永逸地解决集群调度与运维监控问题。
相关推荐
考虑考虑8 小时前
JDK26中的LazyConstant
java·后端·java ee
Devin~Y8 小时前
互联网大厂 Java 面试实录:JVM、Spring Boot、MyBatis、Redis、Kafka、Spring AI、K8s 全链路追问小Y
java·jvm·spring boot·redis·kafka·mybatis·spring security
Gauss松鼠会8 小时前
【GaussDB】基于SpringBoot实现操作GaussDB(DWS)的项目实战
java·数据库·经验分享·spring boot·后端·sql·gaussdb
weixin_408318048 小时前
企业级直播平台技术选型与成本分析:三种方案架构对比
微服务·云原生·架构
用户4099322502128 小时前
Composable的命名规矩和参数约定,别再瞎写了
前端·javascript·后端
时间长河里你我皆过客8 小时前
linux ssh链接断断续续排查
后端
敖正炀8 小时前
Spring 设计哲学再探:约定优于配置、误用与反模式
spring boot·spring
传说之后8 小时前
以Hadoop为例,解读分布式计算设计
后端·架构
枫叶V8 小时前
Scrapling 入门:一个现代 Python 网页采集框架
后端·爬虫