使用 Spring Boot 的 @Scheduled 注解来编写定时任务非常便捷,但在多实例部署的架构下,一个令人头疼的问题随之而来:同一个定时任务会在集群中的每台服务器实例上同时触发执行。
想象一下,如果你的应用部署在两台服务器上,那么原本设计在凌晨2点执行的数据统计任务就会“双倍执行”。这不仅导致数据重复计算,还可能引发文件重复生成、消息重复发送等一系列数据一致性问题。为了解决这个在多实例环境下保证定时任务“唯一执行”的需求,我们通常有几种思路。
常见的解决方案对比
方案一:指定单机执行
最直接的思路是让任务只在某一台特定的机器上运行。你可以在任务代码中判断当前服务器的主机名或IP,只让符合条件的那一台执行。
@Scheduled(cron = "0 0 2 * * ?")
public void scheduledTask() {
String hostname = InetAddress.getLocalHost().getHostName();
if (!"app-server-01".equals(hostname)) {
return;
}
// 真正的任务逻辑
}
这种方案的缺点显而易见:它严重依赖于单点。一旦指定的那台服务器宕机,整个定时任务就会彻底中断,系统的可靠性无法保证。
方案二:基于 Redis 的分布式锁
另一种常见做法是利用 Redis 的 SETNX 命令(或借助 Redisson 这样的客户端)来实现一个简单的分布式锁。在任务开始执行前尝试获取锁,只有获取成功的实例才能执行业务逻辑。
@Scheduled(cron = "0 0 2 * * ?")
public void scheduledTask() {
Boolean locked = stringRedisTemplate.opsForValue()
.setIfAbsent("task:data-sync", "1", 10, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(locked)) {
return;
}
try {
// 执行任务
} finally {
stringRedisTemplate.delete("task:data-sync");
}
}
这个方案是可行的,也是很多项目的选择。但它需要我们为每一个定时任务都手动编写加锁、释放锁的样板代码。如果你的项目里还没有引入 Redis ,仅仅为了这个功能而引入一个中间件,显得有些“重”。
那么,有没有一种更优雅、更专注于此场景的解决方案呢?答案是肯定的,它就是 ShedLock。
专注于定时任务的分布式锁:ShedLock
ShedLock 是一个轻量级的库,它的设计目标非常纯粹:确保你的定时任务在多实例部署时最多只执行一次。它支持多种存储后端(如关系型数据库、Redis、MongoDB 等)来记录锁的状态。
其核心机制简单而有效:在每个任务触发时,所有实例都会尝试去存储层“抢占”一个以任务名命名的锁。只有一个实例能成功抢到锁,只有抢到锁的实例才会执行任务逻辑,其他实例则自动跳过。
集成步骤详解(以JDBC为例)
1. 添加项目依赖
首先,在你的 pom.xml 中添加 ShedLock 的核心依赖以及你选用的 LockProvider 实现。这里我们以使用数据库(JDBC)为例。
<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-jdbc-template</artifactId>
<version>4.42.0</version>
</dependency>
2. 创建锁记录表
在数据库中创建一张表,用于持久化锁的状态。ShedLock 会通过这张表来协调各个实例。
CREATE TABLE shedlock (
name VARCHAR(64) NOT NULL,
lock_until TIMESTAMP(3) NOT NULL,
locked_at TIMESTAMP(3) NOT NULL,
locked_by VARCHAR(255) NOT NULL,
PRIMARY KEY (name)
);
3. 配置 LockProvider
创建一个配置类,声明一个 LockProvider Bean。这里配置了使用我们刚刚创建的数据表和默认的数据源。
@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class ShedLockConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withDataSource(dataSource)
.withTableName("shedlock")
.build()
);
}
}
4. 注解定时任务
最后一步也是最简单的一步:在你原有的 @Scheduled 定时任务方法上,额外添加一个 @SchedulerLock 注解。
@Scheduled(cron = "0 0 2 * * ?")
@SchedulerLock(name = "dataSyncTask", lockAtMostFor = "5m")
public void syncData() {
// 放心执行你的任务逻辑,集群中只有一个实例会运行到这里
}
集成完毕!现在,无论你将应用部署在多少台服务器上,syncData 这个任务在每天凌晨2点都只会被一个实例执行一次。
关键注解参数解析
@SchedulerLock 注解有几个非常重要的参数,理解它们有助于你更好地使用 ShedLock:
name:锁的唯一标识。所有同名任务会互斥执行。务必为每个任务起一个独特的名字。
lockAtMostFor:锁的最大持有时间。这是一个安全机制,用于防止任务执行过程中实例崩溃,导致锁永远不被释放(死锁)。例如设置为 "5m",即使任务执行超时或实例宕机,锁也会在5分钟后自动过期,允许其他实例接手。建议设置为任务预期执行时间的2-3倍。
lockAtLeastFor:锁的最小持有时间。这个参数用于处理执行速度非常快的定时任务。例如,一个每分钟执行一次的任务可能仅需5秒就完成了。如果没有最小持有时间,锁可能立即释放,导致在同一分钟周期内,另一个实例又抢到锁并再次执行。设置 lockAtLeastFor = "55s" 可以确保锁至少持有55秒,从而避免在同一分钟内重复执行。
背后的工作原理
ShedLock 的实现原理清晰且高效。它本质上是在存储层进行一种“乐观锁”式的抢占。以数据库为例,当一个任务触发时,它会执行类似下面的SQL:
INSERT INTO shedlock (name, lock_until, locked_at, locked_by)
VALUES ('dataSyncTask', '2025-01-25 02:05:00', NOW(), '192.168.1.10')
ON DUPLICATE KEY UPDATE
lock_until = '2025-01-25 02:05:00',
locked_at = NOW(),
locked_by = '192.168.1.10'
WHERE lock_until < NOW();
关键在于最后的 WHERE lock_until < NOW() 条件:
- 如果数据库中该任务名的锁记录已经过期(
lock_until 小于当前时间),则更新成功,当前实例抢到锁,可以执行任务。
- 如果锁记录尚未过期,则更新操作影响行数为0,当前实例抢锁失败,直接跳过任务执行。
任务完成后,实例无需主动删除锁记录,只需等待锁的 lock_until 时间自然过期即可。这种“自动过期”的设计,是其可靠性的重要保障。
适用场景与边界
清楚工具的定位,才能更好地使用它。ShedLock 是一个专门为定时任务设计的协调器。
它非常适合以下场景:
- 需要确保多实例环境下定时任务(如每日报表、数据同步、缓存刷新)的互斥执行。
- 希望以声明式(注解)方式管理锁,减少样板代码。
- 需要内置的锁超时机制来防止死锁。
它并不适合以下场景:
- 高并发业务场景下的锁竞争(如秒杀、库存扣减)。ShedLock 的锁粒度粗,且基于定时触发,并非为毫秒级高频竞争设计。
- 需要可重入锁、读写锁、公平锁等复杂特性的场景。
- 需要精细控制锁获取和释放逻辑的业务流程。
简单来说,ShedLock 和 Redisson 这类通用分布式锁是互补关系。如果你的业务代码中需要复杂的锁逻辑,请选择后者;如果你只想简单优雅地解决定时任务重复执行的问题,ShedLock 是更佳选择。
实践中的注意事项
- 存储后端的选择:本文演示了基于 JDBC 的用法。你也可以根据项目现有技术栈,轻松切换为 Redis、MongoDB 等 Provider。
- 自定义锁标识:数据库表中的
locked_by 字段默认记录主机名。你可以在配置时通过 .withLockedByValue() 方法将其自定义为 IP 或更易识别的实例ID。
- 主从数据库环境:如果使用数据库作为存储,请确保 ShedLock 的 LockProvider 连接的是主库(写库),以避免因主从延迟导致锁状态不一致。
总结
面对 Spring Boot 多实例部署时定时任务重复执行的经典问题,ShedLock 提供了一套专注且优雅的解决方案。它通过注解将分布式锁的逻辑对开发者透明化,集成了自动过期机制保障可靠性,并支持多种存储后端以适应不同技术栈。
其优势在于简单、专注、低侵入性;其局限在于仅适用于定时任务调度场景。相比于手动编写 Redis 分布式锁代码,ShedLock 让代码更加清晰简洁。下次当你的 Spring Boot 应用需要横向扩展时,不妨考虑用它来轻松管理你的定时任务。