Scheduling and ShedLock
|
This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against. This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases. This section’s bibliography lists the reference material consulted while preparing these pages. |
Spring’s @Scheduled runs a method on a timer inside a single JVM; it has no concept of other instances of the
same service, which becomes a correctness problem the moment a service is scaled to more than one instance.
ShedLock closes that gap with a distributed lock that lets only one instance run a given scheduled job per
tick.
Enabling in-process scheduling
@EnableScheduling activates Spring’s scheduling infrastructure on a configuration class; @Scheduled then
marks any void, no-argument method as a scheduled task:
@Configuration
@EnableScheduling
public class SchedulingConfig {
}
@Component
public class ReportingJobs {
private static final Logger log = LoggerFactory.getLogger(ReportingJobs.class);
// runs again 30s after the PREVIOUS execution finished
@Scheduled(fixedDelay = 30_000)
public void rebuildDashboardCache() {
log.info("Rebuilding dashboard cache");
}
// runs every 15s measured from the START of the previous execution
@Scheduled(fixedRate = 15_000, initialDelay = 5_000)
public void pollUpstreamStatus() {
log.info("Polling upstream status");
}
// cron: second minute hour day-of-month month day-of-week
@Scheduled(cron = "0 0 2 * * *", zone = "Europe/Madrid")
public void nightlyCleanup() {
log.info("Running nightly cleanup at 02:00 Europe/Madrid");
}
}
fixedRate schedules the next execution relative to the start of the previous one (executions can overlap if
a run takes longer than the rate, unless the method itself is synchronized or single-threaded); fixedDelay
waits for the previous execution to finish before counting down to the next one; cron gives full calendar
control, including an explicit zone. See
Task Execution and Scheduling in
the Spring Framework reference.
The TaskScheduler abstraction
@Scheduled methods run on a TaskScheduler bean. Spring Boot auto-configures a ThreadPoolTaskScheduler when
Micrometer or Reactor context propagation isn’t overriding it; the pool size is worth tuning explicitly once
more than a couple of jobs are registered, since by default a single thread serializes every @Scheduled
method:
@Configuration
public class TaskSchedulerConfig {
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(10);
scheduler.setThreadNamePrefix("scheduled-task-");
scheduler.setErrorHandler(t -> log.error("Scheduled task failed", t));
return scheduler;
}
private static final Logger log = LoggerFactory.getLogger(TaskSchedulerConfig.class);
}
TaskScheduler can also be injected and used programmatically (schedule, scheduleAtFixedRate,
scheduleWithFixedDelay) to schedule work that isn’t known at startup, such as a one-off task computed from a
runtime value. Property spring.task.scheduling.pool.size configures the auto-configured pool without writing
a TaskScheduler bean at all. See
Task Execution and
Scheduling in the Spring Boot reference.
The multi-instance problem
None of the above is aware of horizontal scaling. If ReportingJobs is deployed as three replicas behind a load
balancer, all three run nightlyCleanup() at 02:00 Europe/Madrid, pollUpstreamStatus() every 15 seconds, and
so on — each replica has its own TaskScheduler, and Spring provides no built-in mechanism to coordinate
across JVMs. Depending on what the job does, this causes duplicated emails, double-charged invoices, race
conditions on shared rows, or simply wasted work. The fix is not inside Spring itself: it requires an external,
shared lock that every instance checks before running the job body.
ShedLock
ShedLock solves exactly this: it makes sure a scheduled task runs at most once at the same time, across all instances of an application sharing a lock store. Its GitHub README is the only documentation source for the project — there is no separate documentation site, so the README (and the Javadoc it links to) is the reference to consult for anything not covered here.
Dependencies and enabling
ShedLock ships a core module plus one provider module per lock store. For Maven, add the core starter and the provider matching the datastore already used for persistence (see Spring Data Overview for the same JDBC/MongoDB split):
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>6.2.0</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>6.2.0</version>
</dependency>
Enable it alongside @EnableScheduling, declaring the default lock duration bounds:
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT30M")
public class SchedulingConfig {
}
@SchedulerLock
Annotate each scheduled method that must not run concurrently across instances:
@Component
public class ReportingJobs {
@Scheduled(cron = "0 0 2 * * *", zone = "Europe/Madrid")
@SchedulerLock(
name = "nightlyCleanup",
lockAtMostFor = "PT15M",
lockAtLeastFor = "PT1M")
public void nightlyCleanup() {
// only one instance executes this per tick; the others skip it entirely
}
}
name identifies the lock row/document/key and must be unique per job. lockAtMostFor is a safety net: if the
holding instance crashes or is killed without releasing the lock, other instances may acquire it again after
this duration elapses — set it comfortably longer than the job is ever expected to take. lockAtLeastFor
prevents a second run in the same tick when the job finishes very quickly and clocks are slightly skewed
across instances — the lock is held for at least this long even if the method returns immediately. An instance
that finds the lock already held simply skips that execution; it does not queue, retry, or wait.
The LockProvider abstraction
Every provider module supplies a LockProvider bean, which is the single extension point ShedLock’s core
depends on — @SchedulerLock and @EnableSchedulerLock never change based on which store backs the lock.
JDBC
Requires a shedlock table (DDL provided in the README) in the same relational database already used by the
application:
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime()
.build());
}
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>6.2.0</version>
</dependency>
MongoDB
Stores locks as documents in a dedicated collection, reusing the application’s MongoTemplate:
@Bean
public LockProvider lockProvider(MongoTemplate mongoTemplate) {
return new MongoLockProvider(mongoTemplate.getMongoDatabaseFactory().getMongoDatabase());
}
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-mongo</artifactId>
<version>6.2.0</version>
</dependency>
Redis
Uses SETNX-style atomic keys with a TTL, via Spring Data Redis’s RedisTemplate (or RedisConnectionFactory
directly):
@Bean
public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
return new RedisLockProvider(connectionFactory, "reporting-service");
}
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-redis-spring</artifactId>
<version>6.2.0</version>
</dependency>
Only one LockProvider bean is needed per application, regardless of how many @SchedulerLock-annotated
methods exist — all of them share the same lock store through the same bean.
Two instances, one tick
The diagram below shows the failure mode ShedLock prevents: two instances of the same service both fire the
same @Scheduled method at the same cron tick, but only the one that wins the lock actually runs the job body.
Choosing lockAtMostFor and lockAtLeastFor
Both durations are set per job, not globally, because different jobs have very different expected runtimes:
@Scheduled(fixedRate = 15_000)
@SchedulerLock(name = "pollUpstreamStatus", lockAtMostFor = "PT1M", lockAtLeastFor = "PT10S")
public void pollUpstreamStatus() {
// short, frequent job: tight lockAtMostFor limits how long a crashed
// instance blocks the next attempt; small lockAtLeastFor avoids a
// near-instant second run when the poll is a no-op
}
@Scheduled(cron = "0 0 3 * * SUN")
@SchedulerLock(name = "weeklyArchive", lockAtMostFor = "PT4H")
public void weeklyArchive() {
// long-running, infrequent job: generous lockAtMostFor so a slow
// but healthy run is never pre-empted by another instance
}
As a rule of thumb, set lockAtMostFor to a multiple of the job’s worst observed runtime (never shorter than
the actual maximum, or a still-running instance can lose its lock while another one starts a duplicate run), and
set lockAtLeastFor only when a job can legitimately finish fast enough that clock drift between instances
could otherwise let it fire twice in the same logical tick.