在集群模式下,由于 TaskScheduler 是单机本地调度器,每个服务实例都会独立运行并触发定时任务,从而导致任务重复执行。要解决这个问题,核心思路是引入**分布式锁(Distributed Lock)**机制,确保同一时刻只有一个节点能成功获取到执行权限。
【一】,两种分布式锁
以下是几种主流且实用的解决方案,你可以根据项目现有的技术栈进行选择:
🛡️ 【方案一】:引入轻量级分布式锁框架 ShedLock(强烈推荐)
ShedLock 是一个专门为解决此类问题设计的轻量级框架。它通过与共享存储(如数据库、Redis、MongoDB等)交互,在任务执行前获取锁,执行完毕后释放锁,从而保证任务在集群中只被执行一次。
实现步骤:
- 引入依赖 :在
pom.xml中添加shedlock-spring以及对应的存储依赖(例如shedlock-provider-jdbc-template)。 - 配置 LockProvider :配置一个基于共享存储的
LockProvider(例如基于数据库的JdbcTemplateLockProvider)。 - 开启调度锁 :在 Spring Boot 启动类上添加
@EnableSchedulerLock(defaultLockAtMostFor = "PT30S")注解。 - 在任务方法上加锁 :在需要单实例执行的定时任务方法上,同时加上
@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 的配置类中,注入上面的业务任务类,并在 addTriggerTask 的 Runnable 中直接调用带锁的方法。
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 代理对象。
- 代理对象拦截到
executeTask()的调用。 - 在真正执行业务代码前,ShedLock 会先去 Redis(或数据库)尝试获取名为
dynamicTask_lock的分布式锁。 - 获取成功 :执行真正的
executeTask()业务逻辑,执行完毕后自动释放锁(或等到过期时间)。 - 获取失败:说明集群中其他节点正在执行该任务,当前节点直接跳过,不执行任何逻辑。
⚠️ 避坑指南
- 必须用代理模式 :如果你不配置
proxyMode = SchedulerLock.ProxyMode.PROXY_METHOD,直接把@SchedulerLock加在Runnable的run()方法上是完全无效的。 - 方法必须为 public :
@SchedulerLock修饰的方法必须是public,否则 Spring AOP 无法拦截。 - 锁的时间设置 :
lockAtMostFor(最长持有时间)一定要大于你的业务逻辑正常执行的时间,否则任务还没跑完锁就失效了,会导致其他节点重复执行。
🔑 【方案二】:手动使用 Redis 分布式锁
如果你的项目中已经集成了 Redis,可以手动在 TaskScheduler 的任务逻辑(Runnable)中加入 Redis 锁的控制。利用 Redis 的 SETNX(set if not exists)原子命令来实现互斥。
实现思路:
在动态添加的任务逻辑中,先尝试获取 Redis 锁,获取成功则执行具体业务,执行完毕后释放锁;如果获取失败,则直接跳过。
实现步骤:
- 引入依赖 :在
pom.xml中添加 redis 依赖。 - 配置 redis:配置redis
- 在业务代码中加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 等专业调度平台,一劳永逸地解决集群调度与运维监控问题。