在 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;
}
// 执行实际的业务逻辑
}
这种方式简单粗暴,但缺陷也非常明显:存在单点故障风险。如果指定的那台机器(app-server-01)宕机了,那么这个定时任务就彻底无法执行了,系统的可靠性大打折扣。
方案二:基于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 是一个轻量级的库,它的设计目标非常单一且明确:确保你的定时任务在多实例部署时,同一时间最多只有一个实例在执行。
它的核心思想并不复杂:在某个共享的存储中(如数据库、Redis等)记录每个任务的“锁”状态。任务执行前,实例会尝试去获取这个锁,只有成功获取到锁的实例才能执行任务逻辑。
相比自己实现分布式锁,ShedLock 通过注解将锁逻辑透明化,让开发者可以更专注于业务代码。
集成步骤详解(以数据库存储为例)
1. 添加Maven依赖
首先,在项目的 pom.xml 中添加 ShedLock 的核心依赖以及你选用的锁提供者(这里以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
创建一个Spring配置类,用于声明 LockProvider Bean。@EnableSchedulerLock 注解用于启用 ShedLock 的支持。
@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 这个任务在每次触发时,都只会有一个实例成功执行,完美解决了重复执行的问题。
@SchedulerLock 注解参数详解
为了让 ShedLock 更好地工作,理解 @SchedulerLock 注解的几个关键参数至关重要。
name (必填):锁的唯一名称。所有同名任务的执行会互斥。建议使用能清晰标识任务功能的字符串。
lockAtMostFor:锁的最大持有时间。这是一个安全机制,用于防止任务执行过程中实例崩溃,导致锁永远无法释放(死锁)。例如设置为 “5m”,即使任务执行超时或实例宕机,锁也会在5分钟后自动失效,其他实例便可接手。通常建议设置为任务预估执行时间的2-3倍。
lockAtLeastFor:锁的最小持有时间。这个参数用于解决“执行过快”导致的问题。想象一个场景:你的任务每分钟执行一次,但实际逻辑5秒就完成了。如果没有最小持有时间,锁会立即释放,可能导致另一个实例在同一分钟周期内再次获取锁并执行。设置 lockAtLeastFor = “1m” 可以确保锁至少被持有到下一个执行周期开始。
ShedLock 是如何工作的?
ShedLock 的实现原理简洁而有效。当定时任务触发时,框架会尝试执行一条类似下面的SQL语句(以MySQL为例):
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 小于当前时间),则 UPDATE 成功,当前实例抢到锁,任务得以执行。
- 如果锁仍未过期(其他实例正持有锁),则
UPDATE 影响行数为0,抢锁失败,当前实例直接跳过任务执行。
任务执行完成后,不需要主动删除锁记录,只需等待其 lock_until 时间自然过期即可。这种“基于时间过期”的机制是其设计的一大亮点。
适用场景与局限性
ShedLock 的定位非常清晰,理解其边界能帮助你做出正确的技术选型。
它非常适合以下场景:
- 需要确保在多实例环境下,定时任务(如数据同步、报表生成、缓存清理)不会重复执行。
- 希望用声明式(注解)的方式管理锁,减少样板代码。
- 需要自动的锁超时释放机制来避免死锁。
它并不适合以下场景:
- 高并发抢锁的业务场景,例如秒杀、库存扣减。
ShedLock 是为调度设计的,而非高并发控制。
- 需要可重入锁、读写锁、公平锁等复杂特性的场景。
- 需要精细控制锁获取和释放时机的业务逻辑。
简而言之,ShedLock 与 Redisson 这类通用的分布式锁框架是互补关系。如果你的业务代码本身需要复杂的锁机制,请选择后者;如果只是想解决 @Scheduled 任务重复执行这个特定问题,ShedLock 是更简洁、更专注的选择。
实践中的注意事项
- 存储后端可选:本文演示了基于数据库(JDBC)的集成方式。
ShedLock 还支持 Redis、MongoDB、ZooKeeper 等多种存储,你可以根据项目现有的技术栈灵活选择。
- 自定义表名和机器标识:在使用JDBC提供者时,你可以通过配置自定义锁表名(非默认的
shedlock)。locked_by 字段默认记录主机名,你也可以通过 .withLockedByValue() 方法将其设置为IP地址或任何有意义的实例标识符。
- 注意主从数据源:如果你的项目配置了数据库主从复制,请确保
ShedLock 的 LockProvider 使用的是主库(Master) 数据源,以避免因主从延迟导致的锁状态感知错误。
总结
ShedLock 是一个为解决特定痛点而生的优秀工具,它让 Spring Boot 应用在多实例部署时管理定时任务变得轻而易举。
- 优点:注解驱动、集成简单、自动处理锁过期、支持多种存储后端,极大减少了开发负担。
- 局限:仅适用于定时任务调度场景,不能替代通用的业务分布式锁。
与手动编写 Redis 分布式锁代码相比,ShedLock 将定时任务特有的互斥逻辑抽象成了框架行为,使代码更加清晰、维护性更高。下次当你面临多实例定时任务的协调问题时,不妨考虑一下这个轻量而高效的解决方案。