FEATURED · 精选文章

Spring Boot分布式定时任务锁SchedulerLock原理与实战

发布时间 / 2026/8/15 5:55:23
来源 / 创域科博编辑部
栏目 / 资讯中心
Spring Boot分布式定时任务锁SchedulerLock原理与实战 1. 项目概述为什么我们需要一把“分布式锁”在微服务架构和分布式系统成为主流的今天定时任务Scheduled Task的管理变得既常见又棘手。想象一下你部署了一个每天凌晨2点执行数据统计的Scheduled任务为了高可用你启动了三个服务实例。结果就是在凌晨2点整三个实例上的同一个任务会同时启动导致数据被重复计算三次甚至可能因为资源竞争引发数据错乱。这就是典型的“任务幂等性”问题。SchedulerLock正是为了解决这个问题而生的。它不是一个独立的框架而是ShedLock库提供的一个核心注解。ShedLock的设计哲学非常清晰确保一个定时任务在同一时刻在分布式环境下的多个节点中有且仅有一个节点能够成功执行。它通过在外部共享存储如数据库、Redis、ZooKeeper等中创建并持有一把“锁”来实现这一目标。SchedulerLock注解则是我们将这个锁机制与Spring的Scheduled任务优雅结合的关键。简单来说它给你的定时任务加了一个“分布式开关”。任务执行前所有节点都会去抢这个开关谁抢到谁干活没抢到的节点会自动跳过本次执行从而保证了任务的全局唯一性。这对于财务对账、报表生成、缓存预热、数据同步等要求精确一次执行的场景至关重要。接下来我们就深入拆解它的工作原理、核心配置以及那些官方文档里不会写的“踩坑”经验。2. SchedulerLock核心机制深度解析要真正用好SchedulerLock不能停留在“加个注解就能用”的层面必须理解其背后的锁机制和生命周期。这能帮助你在出现问题时快速定位并做出合理的架构选型。2.1 锁的生命周期与状态流转ShedLock中的锁不是一个永久存在的实体它的生命周期紧密围绕着任务执行周期。理解下面这个流程就理解了ShedLock的核心锁创建与获取Lock Acquisition在配置的定时任务触发时刻每个节点上的ShedLock组件会尝试去配置的共享存储如数据库表shedlock中插入或更新一条对应此任务名称name的记录。这个插入/更新操作本质上是原子的“获取锁”操作。锁持有与任务执行Lock AtLeastFor成功获取锁的节点会在记录中设置一个lock_until时间戳。这个时间戳 当前时间 SchedulerLock注解中配置的lockAtLeastFor。在这段时间内即使任务执行完毕锁也不会立即释放而是会保留直到lock_until。这是为了防止任务执行时间过短在下一个节点尝试获取锁的间隙内原执行节点已经释放锁导致任务被重复执行。锁释放或续期Lock AtMostFor任务执行完成后ShedLock会更新数据库将lock_until时间戳设置为当前时间 SchedulerLock注解中配置的lockAtMostFor。注意这里不是直接删除记录。lockAtMostFor是一个“安全网”它定义了锁的最大持有时间。如果任务执行节点崩溃比如JVM进程意外退出导致锁无法正常释放那么最晚在lockAtMostFor时间过后其他节点就可以来获取这个锁从而避免了“死锁”问题。正常执行的任务会在完成后立即将lock_until设置为一个很近的过去时间等效于释放。锁竞争与跳过Skip Execution其他未抢到锁的节点在尝试获取锁时会发现数据库中对应name的记录的lock_until时间还未到期即大于当前时间于是获取锁失败直接跳过本次任务的执行并记录一条DEBUG级别的日志。这个机制的精妙之处在于它利用外部存储的原子性保证了互斥又通过lockAtLeastFor和lockAtMostFor两个参数优雅地平衡了任务执行的效率防短时重复与系统的健壮性防死锁。2.2 关键参数lockAtLeastFor与lockAtMostFor这是SchedulerLock注解中最容易混淆但也最重要的两个参数。它们的单位可以是String如“PT30S”或long毫秒数。lockAtLeastFor(至少锁定时长)这是任务执行时间的“下限”保护。假设你的任务平均执行需要5秒但偶尔可能快至1秒。如果你不设置这个参数一个1秒完成的任务会立刻释放锁。在分布式环境下由于时钟偏差或调度触发微小的时间差另一个节点可能在1.1秒时就成功获取了锁导致任务在极短时间内被执行了两次。设置了lockAtLeastFor “10s”后即使任务1秒完成锁也会被持有至少10秒其他节点在这10秒内无法获取从而保证了执行的唯一性。实操心得lockAtLeastFor的值应该略大于你预想的最短任务执行时间。例如任务通常耗时1-5分钟你可以设置为“PT2M”2分钟。这为任务提供了最低限度的“执行窗口”保障。lockAtMostFor(最多锁定时长)这是系统健壮性的“上限”保护。它定义了锁的最大生命周期。如果持有锁的节点崩溃了任务卡死这个锁就成了“僵尸锁”。没有这个参数其他节点将永远无法执行这个任务。设置了lockAtMostFor “1h”后即使原节点崩溃一小时后锁会自动“失效”因为lock_until时间已过其他节点可以重新竞争获取。实操心得lockAtMostFor的值必须远大于你预想的最长任务执行时间并留出充足余量。例如任务最长可能执行30分钟你可以设置为“PT2H”2小时。切记这个值必须大于lockAtLeastFor。官方推荐设置为任务通常执行时间的数倍。一个经典的配置示例SchedulerLock(name “weeklyReportGenerator”, lockAtLeastFor “5m”, lockAtMostFor “60m”) Scheduled(cron “0 0 2 ? * MON”) // 每周一凌晨2点 public void generateWeeklyReport() { // 生成周报通常耗时3-10分钟 }这个配置解读为任务“weeklyReportGenerator”每周一触发。一旦某个节点抢到锁它会至少持有5分钟防止短时重复最多持有60分钟防止节点崩溃导致死锁。正常执行完毕后假设用了8分钟锁会立即释放。3. 集成与配置实战指南理解了原理我们来动手集成。ShedLock支持多种锁提供者Lock Provider这里以最常用的JDBC数据库和Redis为例。3.1 基于Spring Boot与JDBC的集成这是最稳定、兼容性最好的方案利用现有关系型数据库MySQL, PostgreSQL, Oracle等即可。第一步添加依赖在你的pom.xml中添加ShedLock的Spring Boot Starter依赖。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第二步创建锁表在你的业务数据库中执行以下SQL创建锁表。表名默认是shedlock你也可以自定义。CREATE TABLE shedlock ( name VARCHAR(64) NOT NULL, lock_until TIMESTAMP(3) NOT NULL, locked_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name) );字段解释name: 锁的唯一标识对应SchedulerLock(name “...” )。lock_until: 锁的释放时间点。其他节点判断能否获取锁就看当前时间是否大于此值。locked_at: 锁的获取时间。locked_by: 获取锁的节点标识通常为主机名:进程ID。第三步配置Bean在Spring配置类中如Configuration类配置LockProvider和SchedulerLock的AOP拦截器。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; Configuration EnableSchedulerLock(defaultLockAtMostFor “PT30M”) // 全局默认最大锁定时长 public class ShedLockConfig { Bean public LockProvider lockProvider(DataSource dataSource) { // 使用JdbcTemplateLockProvider并指定表名 return new JdbcTemplateLockProvider( JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(new JdbcTemplate(dataSource)) .withTableName(“my_shedlock”) // 如果表名不是默认的shedlock .usingDbTime() // 强烈建议使用数据库时间避免服务器间时钟不同步 .build() ); } }关键配置项usingDbTime()在分布式环境中各服务器系统时钟可能存在偏差。使用数据库服务器时间作为锁超时的判断基准可以避免因时钟不同步导致锁提前失效或延迟失效的问题。这是生产环境必须开启的选项。第四步应用注解在原有的Scheduled方法上叠加SchedulerLock注解。import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; Component public class MyScheduledTasks { // 结合Cron表达式 SchedulerLock(name “syncUserDataTask”, lockAtLeastFor “30s”, lockAtMostFor “10m”) Scheduled(cron “0 */5 * * * *”) // 每5分钟执行一次 public void syncUserData() { // 执行数据同步逻辑 } // 结合固定延迟 SchedulerLock(name “healthCheckTask”) Scheduled(fixedDelay 60000) // 上次执行结束后60秒再执行 public void healthCheck() { // 执行健康检查 } }3.2 基于Redis的集成高性能场景如果你的系统对性能更敏感或者已经重度使用Redis可以选择Redis作为锁提供者。添加依赖dependency groupIdnet.javacrumbs.shedlock/groupId artifactIdshedlock-provider-redis-spring/artifactId version5.10.2/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency配置Beanimport net.javacrumbs.shedlock.core.LockProvider; import net.javacrumbs.shedlock.provider.redis.spring.RedisLockProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; Configuration EnableSchedulerLock(defaultLockAtMostFor “PT30M”) public class ShedLockRedisConfig { Bean public LockProvider lockProvider(RedisConnectionFactory connectionFactory) { // 环境变量用于区分不同环境如dev, test, prod防止环境间锁冲突 String env System.getenv(“SPRING_PROFILES_ACTIVE”); return new RedisLockProvider.Builder(connectionFactory) .environment(env) // 可选但建议设置 .build(); } }Redis提供者会在Redis中创建Key格式类似“job:${name}”并利用Redis的SET key value NX PX milliseconds命令实现原子性的锁获取和超时设置。4. 高级特性与最佳实践掌握了基础集成后一些高级特性和实践技巧能让你用得更顺手、更稳健。4.1 锁名称name的管理策略name属性是锁的唯一标识管理不当会导致锁冲突或失效。唯一性与描述性name必须在整个应用集群内全局唯一。建议使用有明确业务含义的名称如“orderStatusSync_${shardId}”而不是简单的“task1”。动态名称name支持SpEL表达式可以实现动态锁名。这在处理分片任务时极其有用。SchedulerLock(name “syncDataForRegion_#{regionService.getCurrentRegionId()}”) Scheduled(fixedRate 300000) public void syncRegionData() { // 不同区域Region的服务实例会获取不同的锁并行处理不同区域的数据 }避免名称冲突如果你在同一个应用内定义了多个内容不同但name相同的任务ShedLock会将其视为同一个锁这可能导致意料之外的任务阻塞。务必确保每个独立任务的name不同。4.2 与Spring Scheduling的协作细节SchedulerLock是通过Spring AOP在Scheduled方法外围加了一层代理。执行顺序是AOP拦截器先尝试获取锁获取成功则执行方法体失败则跳过。执行跳过与日志默认情况下未获取到锁的任务执行会被跳过并记录一条DEBUG级别日志“Task ‘xxx’ is already being executed by another node”。如果你需要更显眼的提示可以配置日志级别或实现自定义的LockingTaskExecutor。错误处理如果任务方法体内部抛出了异常ShedLock会先释放锁然后再抛出异常。这意味着本次任务执行被视为失败但锁已被释放下一个调度周期其他节点可以继续尝试。你需要确保任务自身的异常处理和数据一致性。4.3 监控与维护锁表shedlock本身就是一个监控窗口。检查僵尸锁定期执行SQL查询SELECT * FROM shedlock WHERE lock_until NOW()。理论上正常运行时这条SQL应该返回空结果集。如果返回了记录说明有节点的锁因为lockAtMostFor超时而被强制释放了这可能意味着有节点任务执行时间异常长或节点已崩溃需要排查。清理旧锁对于已经不再使用的任务代码已删除其对应的锁记录会永久留在表中。虽然无害但定期清理可以保持表整洁。可以写一个简单的清理任务删除locked_at非常久远如30天前的记录。集成ActuatorShedLock提供了与Spring Boot Actuator的集成可以通过/actuator/shedlock端点查看当前所有锁的状态非常便于运维。5. 生产环境避坑指南与疑难排查这部分是真正从“踩坑”中积累的经验能帮你节省大量排查时间。5.1 常见问题与解决方案速查表问题现象可能原因排查步骤与解决方案任务完全不执行1. 锁未正确释放所有节点都认为锁被占用。2.lockAtMostFor设置过短任务未执行完锁已超时下次触发时因lockAtLeastFor未到期又无法获取。3. 数据库连接失败锁提供者初始化异常。1. 检查数据库shedlock表确认lock_until时间是否合理。手动将过期的lock_until改为过去时间。2.重新评估lockAtMostFor和lockAtLeastFor值确保lockAtMostFor 任务最大执行时间 lockAtLeastFor。3. 检查应用日志确认LockProviderBean是否成功创建数据库连接是否正常。任务在多个节点重复执行1.lockAtLeastFor设置过短短任务执行后锁立即释放被其他节点获取。2.未使用usingDbTime()且服务器间时钟不同步。3. 锁表主键冲突或插入失败但任务继续执行了需检查锁提供者日志。1. 增加lockAtLeastFor值使其大于任务最短执行时间。2.在JDBC配置中务必启用usingDbTime()。3. 检查数据库是否有唯一约束错误确保锁表结构正确特别是name字段长度是否足够。日志中大量“Task is already being executed”这是正常现象说明分布式锁正在起作用。只有一个节点执行其他节点跳过。如果你觉得DEBUG日志太多可以将net.javacrumbs.shedlock的日志级别调整为INFO。但建议保留便于监控锁竞争情况。任务执行时间远超过lockAtMostFor任务逻辑存在性能问题或死循环或者依赖的外部服务超时。1. 优化任务逻辑增加超时控制。2. 考虑将大任务拆分为多个子任务每个子任务单独加锁。3. 监控任务执行时间设置告警。切换环境后锁失效不同环境开发、测试、生产共用了同一个数据库/Redis锁名冲突。在LockProvider配置中通过.environment(“prod”)等方式为不同环境设置不同的命名空间使锁Key区分开。5.2 性能与一致性权衡锁提供者选型JDBC可靠通用但性能受数据库影响。Redis性能极高但需要保证Redis集群的高可用否则Redis宕机会导致所有分布式锁失效任务可能在多个节点同时执行。对于强一致性要求的核心任务如扣款建议使用基于数据库的锁对于高并发、允许极低概率重复的普通任务如发送通知可以使用Redis锁。锁粒度锁的粒度越细并发度越高但管理也越复杂。不要盲目地为所有任务加锁只对那些真正需要全局唯一执行的任务使用SchedulerLock。lockAtMostFor的设置艺术这个值是一把双刃剑。设置得太短可能无法有效防止死锁设置得太长一旦任务失败恢复时间需要等待锁超时会变长。一个经验法则是设置为平均任务执行时间的3-5倍并监控异常情况下的实际最大执行时间进行调整。5.3 一个真实的排查案例时钟漂移引发的“灵异”重复执行我们曾遇到一个线上问题一个每小时执行的数据归档任务在日志中偶尔大约每周一次会出现两个节点在相差1-2秒内都打印了执行开始的日志。检查数据库锁表发现锁记录正常lock_until时间也正确。排查过程首先排除了代码逻辑问题和锁未生效的问题。检查两个服务器的系统时间发现它们与NTP服务器同步但彼此之间存在约300毫秒的偏差。这在分布式系统中是允许的。深入分析ShedLock源码和日志发现问题出在锁获取时刻的判断逻辑上。虽然使用了usingDbTime()但任务触发是由每个节点的Spring调度器基于本地时钟触发的。假设节点A的时钟比节点B快500ms。当数据库时间到达整点时节点A时钟快先触发成功获取锁lock_until设为整点5分钟。500ms后节点B时钟慢的本地时钟也到达整点触发任务。它去查数据库发现lock_until整点5分确实大于当前数据库时间于是它也成功获取了锁不对这里应该是获取失败。关键在于节点B在“获取锁”这个动作发生时使用的也是数据库时间所以它会失败。那么重复执行是如何发生的最终根因是在极少数情况下由于GC暂停或应用线程阻塞节点A获取锁并设置lock_until后在即将执行**任务方法体的前一刻被挂起了几秒钟。而节点B此时触发并尝试获取锁由于节点A的任务还未真正开始执行数据库锁状态正常但节点A的锁记录已经存在。然而由于某种原因可能是连接池延迟节点B的“检查-获取”操作看到了一个稍旧的数据视图实际上在数据库可重复读隔离级别下这很难发生。这个案例最终被证明是一个非常边缘的场景与数据库事务隔离级别、连接池配置和一次罕见的Full GC有关。解决方案是加固了应用节点的时钟同步使用更严格的NTP配置并适当增加了lockAtLeastFor的值从1分钟增加到2分钟为时钟偏差和GC暂停留出了更大的安全余量。这个案例告诉我们分布式锁能解决大部分问题但在极端复杂的生产环境中需要对“时间”这个因素保持最高的警惕。lockAtLeastFor不仅是防短任务也是应对分布式系统各种“不确定性”的一道缓冲。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻