How to prevent duplicate execution of scheduled tasks across multiple Spring Boot instances using ShedLock — covering setup, lock providers, lock duration tuning, and observability.
@Scheduled works perfectly for a single-instance Spring Boot application. The moment you scale to two instances, every scheduled task runs twice — once on each node. For a job that sends emails, places orders, or generates reports, this is a serious bug. ShedLock solves it by acquiring a distributed lock before executing the task, ensuring only one node runs it at a time.
@Scheduled(cron = "0 0 8 * * MON-FRI") // runs on every instance
public void sendDailyReport() {
reportService.generate(); // generates and sends the report twice
}
With two pods, this sends the report twice. With auto-scaling, it could send it ten times.
ShedLock creates a shedlock table (or equivalent) in your datastore. Before executing a task, it inserts or updates a lock row with:
If a second instance tries to acquire the same lock before it expires, it finds the row already claimed and skips execution. After the lock-until time, it becomes available again.
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>5.13.0</version>
</dependency>
<!-- Choose your lock provider -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>5.13.0</version>
</dependency>
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)
);
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulerConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime() // use DB clock, not application server clock
.build()
);
}
}
usingDbTime() is important for multi-region deployments — application server clocks may drift, causing split-brain lock behaviour. The database clock is the single source of truth.
defaultLockAtMostFor sets the maximum lock duration globally. If a task takes longer than this, the lock is forcibly released — useful recovery from crashed or hung instances.
@Component
public class ReportScheduler {
@Scheduled(cron = "0 0 8 * * MON-FRI")
@SchedulerLock(
name = "sendDailyReport",
lockAtMostFor = "5m", // release if task takes longer than 5min
lockAtLeastFor = "4m" // hold lock for at least 4min after completion
)
public void sendDailyReport() {
reportService.generate();
}
}
lockAtMostFor: Maximum time to hold the lock. Set to slightly longer than the expected task duration. If the node crashes mid-execution, other nodes can take over after this duration.
lockAtLeastFor: Minimum time to hold the lock after the task completes. Prevents a second node from running the same task seconds after the first finished — useful for tasks that should only run once per window, even on fast completion.
Rule of thumb: lockAtLeastFor ≈ cron interval × 0.9, lockAtMostFor ≈ expected duration × 1.5.
For services without a relational database, use Redis:
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-redis-spring</artifactId>
<version>5.13.0</version>
</dependency>
@Bean
public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
return new RedisLockProvider(connectionFactory, "production");
}
The environment name ("production") prefixes lock keys — useful if you share a Redis instance across environments.
ShedLock logs lock acquisition and release at DEBUG level. Promote to INFO for production:
logging:
level:
net.javacrumbs.shedlock: INFO
For Micrometer metrics, wrap the task:
@Scheduled(cron = "0 0 8 * * MON-FRI")
@SchedulerLock(name = "sendDailyReport", lockAtMostFor = "5m", lockAtLeastFor = "4m")
public void sendDailyReport() {
Timer.Sample sample = Timer.start(meterRegistry);
try {
reportService.generate();
} finally {
sample.stop(meterRegistry.timer("scheduler.task",
"name", "sendDailyReport", "status", "completed"));
}
}
Test the task logic independently of the lock. Extract the task body to a service:
// In your test
@Test
void generateReport_sendsEmailOnSuccess() {
reportService.generate(); // test the logic, not the scheduling
verify(emailClient).send(any(ReportEmail.class));
}
For integration tests verifying lock behaviour, use SimpleLockProvider in-memory:
@TestConfiguration
public class TestSchedulerConfig {
@Bean
@Primary
public LockProvider lockProvider() {
return new SimpleLockProvider();
}
}
Cron interval shorter than lockAtMostFor: If lockAtMostFor is 10 minutes but the cron fires every 5 minutes, the second invocation will always skip because the lock from the first invocation hasn’t expired. Keep lockAtMostFor well below the cron interval.
Clock skew without usingDbTime: Two nodes with 30-second clock drift can both acquire the lock if the lock-until comparison uses application server time. Always use usingDbTime().
No monitoring of skipped locks: A task that is always skipped (because the lock is always held) produces no errors. Monitor the shedlock table — if locked_at is always from the same node, that node may be hung.
With ShedLock in place, horizontal scaling adds no concurrency risk to your scheduled jobs — each task runs exactly once per scheduled window, regardless of how many instances are running.
If you’re working on distributed Spring Boot services and want a review of scheduling and coordination patterns, get in touch.