'# Spring Cloud Alibaba -- 分布式定时任务解决方案(轻量级、快速构建)(ShedLock 、@SchedulerLock )
一、背景与问题
在微服务架构中,定时任务是业务系统中常见的需求。传统的 @Scheduled 注解在单体应用中可以很好地工作,但到了分布式系统中就会暴露明显缺陷:多个实例同时执行同一任务,导致数据不一致、资源竞争、重复计算等问题。
例如,在电商系统中,每天凌晨需要清理过期的缓存数据。如果使用单体应用的 @Scheduled,只需一个实例即可完成。但如果是微服务架构,多个服务实例可能同时运行,导致缓存数据被重复清理,甚至引发数据不一致。
为解决这一问题,Spring Cloud Alibaba 提供了多种分布式锁解决方案,其中 ShedLock 和 @SchedulerLock 是两个轻量级且快速构建的方案。它们通过分布式锁机制确保同一任务在任意时刻只被一个实例执行。
二、基本原理
1. ShedLock 的工作原理
ShedLock 是一个基于数据库的分布式锁库,其核心思想是通过数据库记录锁信息,确保同一任务在任意时刻只被一个实例执行。具体流程如下:
- 锁获取:在执行任务前,尝试在数据库中插入一条锁记录(例如
lock表),并设置一个过期时间(TTL)。 - 锁持有:如果成功插入锁记录,则说明当前实例获得了锁,可以继续执行任务。
- 锁释放:任务执行完成后,删除锁记录。
- 锁失效:如果锁记录超时未被删除,其他实例可以尝试获取锁。
关键点在于,ShedLock 会通过数据库的行锁机制确保同一任务在任意时刻只有一个实例执行。
2. @SchedulerLock 的工作原理
@SchedulerLock 是 Spring Cloud 的轻量级定时任务锁机制,其底层基于 Redis 的分布式锁实现。其核心逻辑如下:
- 锁获取:通过 Redis 的
SETNX命令尝试获取锁,若成功则继续执行任务。 - 锁持有:设置锁的过期时间(TTL),防止锁因未及时释放而失效。
- 锁释放:任务执行完成后,通过
DEL命令删除锁。 - 锁失效:若锁过期未被删除,其他实例可以尝试获取锁。
@SchedulerLock 的优势在于其轻量级特性,无需引入额外的数据库,适合对 Redis 高可用性有保障的场景。
三、环境准备
1. 依赖配置
在 pom.xml 中添加以下依赖:
<!-- ShedLock 依赖 -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>5.1.0</version>
</dependency>
<!-- @SchedulerLock 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>2. 数据库配置(ShedLock)
若使用 ShedLock,需配置数据库连接:
spring:
datasource:
url: jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=UTC
username: root
password: root3. Redis 配置(@SchedulerLock)
若使用 @SchedulerLock,需配置 Redis:
spring:
redis:
host: localhost
port: 6379四、核心实现
1. 使用 ShedLock 的定时任务
import net.javacrumbs.shedlock.core.SchedulerLock;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class ShedLockTask {
@Scheduled(cron = "0 0 1 * * ?")
@SchedulerLock(name = "shedlock-task", lockAtMostFor = "10m")
public void runShedLockTask() {
// 任务逻辑
System.out.println("ShedLock 任务执行中...");
try {
Thread.sleep(5000); // 模拟耗时操作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}关键代码解释:
@SchedulerLock注解用于声明分布式锁,name参数指定锁的标识,lockAtMostFor设置锁的过期时间。- 任务执行过程中,ShedLock 会自动在数据库中记录锁信息,并在任务完成后删除。
2. 使用 @SchedulerLock 的定时任务
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class SchedulerLockTask {
@Scheduled(cron = "0 0 1 * * ?")
@SchedulerLock(name = "schedulerlock-task", lockAtMostFor = "10m")
public void runSchedulerLockTask() {
// 任务逻辑
System.out.println("SchedulerLock 任务执行中...");
try {
Thread.sleep(5000); // 模拟耗时操作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}关键代码解释:
@SchedulerLock通过 Redis 实现分布式锁,name参数指定锁的标识,lockAtMostFor设置锁的过期时间。- 任务执行过程中,@SchedulerLock 会自动通过 Redis 管理锁的获取与释放。
3. 锁的重试机制
ShedLock 支持任务重试,可以通过 lockAtMostFor 设置锁的持有时间,若任务在锁失效前未完成,锁将被释放,其他实例可以重新获取锁。
@Scheduled(cron = "0 0 1 * * ?")
@SchedulerLock(name = "retry-task", lockAtMostFor = "5m")
public void runRetryTask() {
// 模拟任务失败
if (Math.random() < 0.5) {
throw new RuntimeException("任务执行失败");
}
System.out.println("任务执行成功");
}关键代码解释:
- 若任务抛出异常,锁将被自动释放,其他实例有机会重新获取锁并执行任务。
五、完整案例
1. 电商系统缓存清理任务
场景:每天凌晨清理过期的缓存数据。
步骤:
- 配置数据库和 Redis。
- 编写定时任务代码,使用 ShedLock 或 @SchedulerLock。
- 部署多个服务实例,验证任务是否只执行一次。
代码实现:
import net.javacrumbs.shedlock.core.SchedulerLock;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class CacheCleanupTask {
@Scheduled(cron = "0 0 1 * * ?")
@SchedulerLock(name = "cache-cleanup", lockAtMostFor = "10m")
public void cleanupCache() {
// 清理缓存逻辑
System.out.println("清理缓存任务执行中...");
try {
Thread.sleep(5000); // 模拟耗时操作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}验证方法:
- 启动两个服务实例,观察日志输出,确认任务只执行一次。
六、源码解析
1. ShedLock 的锁获取逻辑
ShedLock 的核心逻辑在 LockManager 类中,通过数据库的 INSERT 语句获取锁:
INSERT INTO lock (name, lock_until) VALUES (?, ?) ON DUPLICATE KEY UPDATE lock_until = ?- 如果插入成功,说明当前实例获得了锁。
- 如果插入失败(因锁已存在),则等待或放弃。
2. @SchedulerLock 的锁获取逻辑
@SchedulerLock 的锁获取逻辑基于 Redis 的 SETNX 命令:
boolean isLocked = redisTemplate.opsForValue().setIfAbsent("lock:task", "1", lockAtMostFor);- 如果返回
true,说明当前实例获得了锁。 - 否则,等待或放弃。
七、进阶使用
1. 结合 Sentinel 实现限流
在高并发场景下,可以结合 Sentinel 实现任务限流:
import com.alibaba.csp.sentinel.annotation.SentinelResource;
import com.alibaba.csp.sentinel.slots.block.BlockException;
@SentinelResource(value = "cache-cleanup", blockHandler = "handleBlock")
public void cleanupCache() {
// 任务逻辑
}
public void handleBlock(BlockException ex) {
// 处理限流逻辑
}2. 动态配置锁的 TTT
可以通过配置文件动态调整锁的过期时间:
shedlock:
lock:
at-most-for: 10m八、性能与工程实践
1. 性能优化
- 锁粒度:避免使用过于宽泛的锁名,如
"all-tasks",应细化为"cache-cleanup"。 - 锁超时:合理设置
lockAtMostFor,避免锁过期导致任务重复执行。 - 数据库连接池:在使用 ShedLock 时,配置数据库连接池(如 HikariCP)以避免资源耗尽。
2. 安全风险
- 锁信息篡改:若数据库或 Redis 配置不当,可能导致锁信息被恶意修改。
- 锁泄露:未正确释放锁可能导致资源占用,需确保异常处理中释放锁。
九、常见问题与踩坑
1. 锁未释放导致资源占用
错误示例:
@SchedulerLock(name = "task", lockAtMostFor = "10m")
public void runTask() {
// 未处理异常,导致锁未释放
}解决办法:在 catch 块中显式释放锁:
try {
// 任务逻辑
} catch (Exception e) {
// 异常处理
} finally {
// 释放锁
}2. 锁失效导致任务重复执行
错误示例:设置 lockAtMostFor 为 5m,但任务执行时间超过 5 分钟。
解决办法:增加锁的超时时间,或拆分任务为多个小任务。
十、最佳实践
1. 使用场景
- 高并发场景:需要确保同一任务在任意时刻只执行一次。
- 数据一致性要求高:如缓存清理、日志归档等任务。
- 轻量级需求:无需引入复杂框架,仅需 Redis 或数据库支持。
2. 避免使用场景
- 对性能要求极高:ShedLock 和 @SchedulerLock 可能引入额外的开销。
- 无数据库或 Redis 支持:需考虑其他解决方案,如 ZooKeeper 分布式锁。
十一、总结
Spring Cloud Alibaba 提供了 ShedLock 和 @SchedulerLock 两种轻量级分布式定时任务解决方案。ShedLock 基于数据库锁,适合需要持久化锁信息的场景;@SchedulerLock 基于 Redis,适合对 Redis 高可用性有保障的场景。两者的共同点是通过分布式锁机制确保任务的唯一性执行,但各有适用场景。
在实际开发中,需根据业务需求选择合适的方案,并注意锁的粒度、超时时间和异常处理。对于高并发、数据一致性要求高的场景,建议优先使用这两种方案。同时,需警惕锁未释放、锁失效等潜在问题,确保系统的稳定性和可靠性。