Spring Boot分布式定时任务防重复执行:@SchedulerLock原理与实战
1. 为什么我们需要一把“锁”分布式定时任务的困境如果你在一个单体应用里用Spring的Scheduled写定时任务那大概率是岁月静好。任务到点就执行执行完就结束简单直接。但一旦你的应用开始水平扩展部署了多个实例麻烦就来了。想象一下一个每天凌晨2点执行的“数据清理”任务在三个实例上同时被触发结果就是同一段清理逻辑被执行了三次。轻则浪费资源重则导致数据被重复处理引发业务逻辑错乱比如同一张优惠券被核销多次或者同一批对账数据被计算了多遍。这就是分布式环境下的定时任务“幽灵执行”问题。Scheduled注解本身不具备分布式协调能力它只是Spring在单个JVM内基于内存的时间调度器。当多个实例的时钟大致同步时它们就会像一群没有指挥的乐手在同一时刻各自演奏起来。为了解决这个问题我们需要引入一个所有实例都能看见并认可的“指挥”——一个中心化的协调器。这个协调器负责在多个竞争者应用实例中选出唯一一个获得执行任务资格的“领导者”。这个过程本质上就是分布式锁的应用。SchedulerLock注解正是基于这个理念为Spring定时任务披上的一件分布式“铠甲”。它不是一个独立的调度框架而是一个轻量级的锁声明其背后需要依赖一个可靠的分布式锁实现如基于数据库、Redis或ZooKeeper来提供实际的锁服务。2. SchedulerLock 核心机制拆解它到底做了什么SchedulerLock注解本身并不复杂它的魔力在于与一个名为“ShedLock”的库紧密结合。ShedLock不是一个完整的任务调度器它确保你的定时任务在同一时间最多在一个节点上执行。如果一个任务正在一个节点上执行它在其他节点上的执行将被跳过。它的工作原理可以概括为以下几个步骤声明锁你在定时任务方法上添加SchedulerLock(name “taskName”)注解。这个name就是锁的唯一标识全局必须唯一。抢占锁当任务触发时间到达时每个实例都会尝试去一个共享存储如数据库表中“抢占”这把名为taskName的锁。抢占的本质是执行一个“插入或更新”的原子操作。锁竞争ShedLock库会利用数据库的唯一键约束或类似机制确保只有一个实例能成功创建或更新这条锁记录。成功的实例获得了锁并记录下锁的持有者通常用实例标识和锁的最晚释放时间locked_atlockAtLeastFor。执行与释放获得锁的实例执行任务方法体。执行完毕后如果任务执行时间短于lockAtMostFor设置的时间则会立即释放锁删除或更新记录如果执行超时锁也会根据lockAtMostFor的设置自动过期防止死锁。其他实例行为其他未能抢到锁的实例在尝试时会发现锁已被占用且未过期于是直接跳过本次任务执行不会有任何报错只是在日志中留下一条跳过记录。这里最关键的是两个时间参数的理解它们是避免任务重复和保证任务健壮性的核心lockAtMostFor锁的最大持有时间。这是一个“安全网”。假设你设置lockAtMostFor “10m”意思是“这个锁我最多持有10分钟”。如果获得锁的节点在执行任务时崩溃了比如JVM宕机导致锁无法被正常释放那么这条锁记录在10分钟后也会被视为过期其他节点便可以重新竞争并获得锁。这个值必须大于你任务可能的最高执行时间否则任务还没执行完锁就过期了会导致其他实例提前介入再次引发重复执行。lockAtLeastFor锁的最短持有时间。这是一个“保护罩”。假设你设置lockAtLeastFor “5m”意思是“这个锁我至少会持有5分钟”。这有什么用呢考虑一种极端情况你的任务执行得非常快只用了100毫秒。执行完后锁立即释放。而此时另一个节点的时钟可能比当前节点快那么几秒它的调度器可能认为“哎时间又到了锁是空的那我抢过来执行吧”。这就导致了在极短时间间隔内的重复执行。设置一个合理的lockAtLeastFor可以强制锁在任务完成后保留一段时间屏蔽掉这种由于节点间时钟微小差异导致的“抖动”。注意lockAtMostFor是防止任务僵死节点崩溃的必须设置。lockAtLeastFor是防止任务执行过快导致锁过早释放的在任务执行时间很短且节点时钟不完全同步的场景下建议设置。3. 从零开始整合ShedLock与Spring Boot理论说完了我们来看实战。这里以最常用的MySQL数据库作为锁存储为例演示完整的集成步骤。3.1 环境准备与依赖引入首先在你的Spring Boot项目的pom.xml中添加必要的依赖。ShedLock提供了多种实现我们选择shedlock-provider-jdbc-template。dependency groupIdnet.javacrumbs.shedlock/groupId artifactIdshedlock-spring/artifactId version5.10.2/version !-- 请使用最新稳定版本 -- /dependency dependency groupIdnet.javacrumbs.shedlock/groupId artifactIdshedlock-provider-jdbc-template/artifactId version5.10.2/version /dependency !-- 数据库驱动以MySQL为例 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency3.2 创建锁存储表ShedLock需要一张表来记录锁信息。在你的数据库中执行以下SQL创建shedlock表。表名和字段名是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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这张表每个字段的含义至关重要name锁的唯一标识对应SchedulerLock(name “...” )。主键。lock_until这个锁最多持有到哪个时间点locked_atlockAtMostFor。当系统时间超过此时间锁被视为过期。locked_at锁被获取的时间点。locked_by哪个实例获取了锁通常包含主机名、进程ID等用于标识。3.3 配置ShedLock接下来我们需要创建一个配置类将ShedLock集成到Spring中。import net.javacrumbs.shedlock.core.LockProvider; import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider; import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.jdbc.core.JdbcTemplate; import javax.sql.DataSource; import java.util.TimeZone; Configuration // 这个注解是必须的它开启了ShedLock对Spring调度器的增强 // defaultLockAtMostFor 指定了默认的锁最大持有时间这里设为30分钟 EnableSchedulerLock(defaultLockAtMostFor PT30M) public class ShedLockConfig { Bean public LockProvider lockProvider(DataSource dataSource) { // 使用JdbcTemplateLockProvider它需要DataSource和数据库类型 // 这里指定使用我们创建的shedlock表 return new JdbcTemplateLockProvider( JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(new JdbcTemplate(dataSource)) .withTableName(shedlock) // 指定表名 .withTimeZone(TimeZone.getTimeZone(Asia/Shanghai)) // 设置时区避免时间错乱 .build() ); } }关键点解析EnableSchedulerLock这是启动ShedLock的开关。defaultLockAtMostFor是一个全局安全设置为所有SchedulerLock注解的任务提供一个默认的最大锁定时长。强烈建议设置这是一个重要的安全底线。LockProviderBean这是ShedLock的核心它定义了锁的存储和操作方式。我们这里配置为使用JDBC数据库。时区withTimeZone设置非常重要必须确保与应用服务器和数据库的时区一致否则锁的过期时间计算会出问题导致锁行为异常。3.4 编写受保护的定时任务现在我们可以编写一个受分布式锁保护的定时任务了。import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import lombok.extern.slf4j.Slf4j; Component Slf4j public class DistributedScheduledTask { /** * 每天凌晨2点执行数据清理任务。 * lockAtMostFor: 锁最多持有1小时防止任务卡死导致锁永不释放。 * lockAtLeastFor: 锁至少持有10秒防止任务执行过快几毫秒时因节点间时钟差导致其他实例立即抢锁执行。 */ Scheduled(cron 0 0 2 * * ?) // Spring的定时表达式 SchedulerLock( name dataCleanupTask, // 锁的唯一名称全局必须唯一 lockAtMostFor PT1H, // ISO-8601格式代表1小时 lockAtLeastFor PT10S // ISO-8601格式代表10秒 ) public void dataCleanup() { log.info(【数据清理任务】开始执行持有锁的节点将执行实际逻辑...); try { // 模拟一个耗时任务 Thread.sleep(5000); // 睡眠5秒 // 这里是你的实际业务逻辑例如 // 1. 清理过期临时数据 // 2. 生成昨日报表 // 3. 同步外部系统状态 log.info(【数据清理任务】业务逻辑执行完毕。); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error(任务执行被中断, e); } catch (Exception e) { // 务必捕获业务异常避免异常抛出导致ShedLock框架无法正常释放锁虽然lockAtMostFor能兜底 log.error(【数据清理任务】执行失败但锁会根据lockAtMostFor自动释放, e); } log.info(【数据清理任务】结束。); } /** * 每5分钟执行一次的状态检查任务。 * 使用了全局默认的lockAtMostForPT30M。 */ Scheduled(cron 0 */5 * * * ?) SchedulerLock(name statusCheckTask, lockAtLeastFor PT1M) public void statusCheck() { log.info(【状态检查任务】执行中...); // 快速检查逻辑 } }代码要点与避坑指南锁名称name必须全局唯一这是锁的标识。不同任务必须使用不同的name。通常用任务方法名或业务键作为名称。时间格式lockAtMostFor和lockAtLeastFor使用的是ISO-8601持续时间格式PTnHnMnS。P是周期标识T是时间标识。PT1H是1小时PT30S是30秒。Spring Boot 2.x 通常能很好地解析这个格式。异常处理在任务方法内务必进行完善的异常捕获。如果任务抛出未被捕获的异常虽然ShedLock的代理逻辑会处理并释放锁前提是框架能捕获到但为了保险起见最佳实践是在业务逻辑层就处理好异常避免不可预知的框架层行为。记住lockAtMostFor是你的最后一道安全防线。Scheduled与SchedulerLock的顺序SchedulerLock注解会通过Spring AOP创建一个代理包裹在Scheduled触发的逻辑之外。所以执行顺序是Spring调度器触发 - ShedLock代理尝试获取锁 - 获取成功则执行业务方法 - 业务方法执行完毕/异常 - ShedLock代理释放锁。4. 生产环境进阶配置与排查指南基础集成只是第一步要让SchedulerLock在生产环境中稳定运行还需要考虑更多。4.1 锁提供者LockProvider的选型与高可用我们用了JDBC但它有单点风险。生产环境更推荐使用Redis或Apache ZooKeeper/etcd等分布式协调服务。Redis性能极高吞吐量大。使用shedlock-provider-redis-spring或shedlock-provider-redis-jedis。Bean public LockProvider lockProvider(RedisConnectionFactory connectionFactory) { return new RedisLockProvider(connectionFactory, my-app:environment); }Redis锁利用SET key value NX PX milliseconds命令实现原子性的获取和超时设置。environment参数用于区分不同环境如dev, prod的锁键前缀。ZooKeeper/etcd强一致性保证适用于对锁可靠性要求极高的金融场景。但运维复杂度高。选型建议对于大多数互联网应用Redis是平衡了性能、可靠性和复杂度的最佳选择。如果已经是微服务架构且已有ZooKeeper也可以考虑。4.2 监控与告警如何知道锁是否健康锁机制本身也可能出问题。你需要监控锁表/锁键监控shedlock表的大小增长或Redis中相关锁键的数量。异常增长可能意味着有任务锁未正常释放虽然有过期机制。任务执行日志ShedLock默认会记录INFO级别的日志如“Lock ‘taskName’ is held by …” 和 “Task ‘taskName’ finished”。确保你的日志收集系统能捕获到这些信息。可以适当调高net.javacrumbs.shedlock包的日志级别到DEBUG来查看更详细的锁竞争过程。自定义健康检查可以编写一个简单的健康检查端点定期尝试获取一个测试锁如果长时间无法获取或出现异常则触发告警。数据库连接池如果使用JDBC确保数据库连接池如HikariCP配置合理避免因连接耗尽导致锁获取失败。4.3 常见问题排查链路当你发现定时任务不执行或者有重复执行嫌疑时可以按照以下链路排查问题现象任务在所有实例上都不执行了。检查锁记录直接查询数据库shedlock表找到对应name的记录。看lock_until字段的时间是否是一个未来的时间表示锁正被持有并且locked_by是否指向一个当前不存在的实例如已下线的机器。如果是说明锁被“僵尸”实例持有且未过期。解决手动删除这条记录DELETE FROM shedlock WHERE name‘taskName’或者等待lock_until时间过去。检查任务配置确认Scheduled的cron表达式是否正确是否因为配置错误导致根本未触发。检查锁提供者连接检查应用与数据库或Redis的网络连接是否正常。查看应用日志是否有数据库连接超时、Redis连接失败的异常。问题现象任务似乎被重复执行了。检查时钟同步这是最隐蔽的坑确保集群内所有服务器应用实例和数据库服务器的系统时钟保持同步使用NTP服务。如果A实例的时钟比B实例慢5分钟那么当A实例认为“到点了我去抢锁”时在B实例和数据库看来A的“现在”可能是5分钟前这会导致锁时间计算混乱。务必确保所有节点时间同步。检查lockAtLeastFor如果任务执行极快1秒且未设置lockAtLeastFor在时钟有微小差异几百毫秒的节点间可能发生“抢锁-释放-再抢锁”的抖动。为快速任务设置一个合理的lockAtLeastFor如PT2S即可解决。检查锁名称唯一性确认两个不同的任务是否错误地使用了相同的name。查看详细日志将ShedLock日志级别调至DEBUG观察每个实例在任务触发时的具体行为看它是成功获取锁还是被跳过。问题现象任务执行变慢有堆积。检查任务执行时间分析任务方法本身的性能是否因为数据量增长而变慢。确保lockAtMostFor设置的值远大于任务的实际最大执行时间。检查锁竞争如果多个不同的任务频繁竞争且使用同一个数据库连接可能会造成轻微瓶颈。考虑使用独立的数据库连接池给ShedLock或者切换到Redis这类高性能存储。4.4 与Spring Cloud及Kubernetes的协作在云原生环境下实例动态伸缩locked_by字段的标识尤为重要。ShedLock默认使用“主机名进程ID”来标识。在K8s中Pod的主机名是随机的这可能导致锁标识不稳定但问题不大因为锁主要靠name和过期时间机制。更佳实践是在K8s中部署时可以通过环境变量显式地设置一个更稳定的实例标识并传递给ShedLock。# Kubernetes Deployment.yaml 部分配置 spec: template: spec: containers: - name: app env: - name: SHEDLOCK_LOCKED_BY_OVERRIDE # 自定义环境变量 valueFrom: fieldRef: fieldPath: metadata.name # 使用Pod名称作为标识然后在Java配置中读取Bean public LockProvider lockProvider(DataSource dataSource, Value(${SHEDLOCK_LOCKED_BY_OVERRIDE:}) String lockedByOverride) { JdbcTemplateLockProvider.Configuration.Builder builder JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(new JdbcTemplate(dataSource)) .withTableName(shedlock); if (StringUtils.hasText(lockedByOverride)) { builder.withLockedByValue(lockedByOverride); // 覆盖默认的locked_by生成逻辑 } return new JdbcTemplateLockProvider(builder.build()); }5. 设计思考什么场景下不用 SchedulerLockSchedulerLock是一个轻量级的解决方案但它并非银弹。在以下场景你可能需要考虑更专业的任务调度中间件需要动态管理任务Scheduled的cron表达式是硬编码在注解里的。如果你需要动态地创建、修改、暂停或触发一个任务SchedulerLock结合Scheduled就力不从心了。这时需要像Quartz支持数据库存储任务定义或XXL-Job、Elastic-Job这类分布式任务调度平台。需要任务分片如果一个任务要处理海量数据你希望将它自动拆分成多个子任务分发到不同节点上并行执行SchedulerLock做不到。这是Elastic-Job的强项。需要复杂的失败处理与工作流如果任务执行失败后需要根据不同的错误类型进行重试、告警、跳转到其他任务等复杂流程简单的SchedulerLock加try-catch会显得非常臃肿。Apache DolphinScheduler或Airflow这类工作流调度器更适合。需要可视化的监控和管理SchedulerLock的监控主要靠日志和查表。如果你需要一个集中式的控制台来查看所有任务的历史执行记录、成功率、耗时图表并进行手动干预那么开源的任务调度平台是更好的选择。所以SchedulerLock的定位非常清晰它完美解决了Spring Boot应用在分布式部署时Scheduled定时任务重复执行这一核心痛点。它架构简单、侵入性低、与Spring生态无缝集成。对于“固定周期执行后台作业”这类场景它是性价比最高的选择。但当你的定时任务系统演进到需要动态调度、分片、复杂工作流时就该考虑将其迁移到更强大的专用调度中间件了。在我经历过的多个项目中初期几乎都采用了SchedulerLock来快速解决分布式任务问题它足够稳定且易于理解。随着业务复杂化其中一部分任务会逐渐剥离到独立的调度平台而另一部分简单的清理、同步任务则继续留在应用内由SchedulerLock守护。这种混合架构在实践中非常普遍且有效。