Distributed Locks (ShedLock)
A @Scheduled method runs on every instance of your application. On a single node that’s exactly what you want — but the moment you scale to a cluster, a nightly billing job, a cleanup task, or a report generator fires N times in parallel, once per instance. The fix is a distributed lock: a shared mutex that lets only one instance execute the task while the others skip it. The simplest, most popular solution for scheduled jobs is ShedLock.
The problem
@Scheduled(cron = "0 0 2 * * *") // 2 AM daily
public void sendInvoices() {
invoiceService.generateAndEmailAll();
}
Deploy this on three instances and at 2 AM all three run sendInvoices(). Customers get three invoices, three emails, triple the database load. ShedLock ensures that across the whole cluster the task runs at most once per scheduled fire.
Note: ShedLock is a lock, not a scheduler. It does not guarantee a job runs somewhere if every node is down at the trigger time — it only guarantees that at most one node runs it. Pair it with the normal Scheduling support.
How it works
ShedLock keeps a single row (or key) in a shared store keyed by the lock name. Before a locked method runs, the instance tries to acquire the lock by writing that row with a locked_until timestamp. If another instance already holds an unexpired lock, the attempt fails and the method is skipped. The lock auto-expires after lockAtMostFor, so a crashed instance can’t deadlock the job forever.
Adding ShedLock
ShedLock needs the core module plus a LockProvider for your shared store. Here we use JDBC (any database you already run works).
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>6.3.0</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>6.3.0</version>
</dependency>
Create the lock table (this is the default schema; a Flyway migration is ideal):
CREATE TABLE shedlock (
name VARCHAR(64) NOT NULL,
lock_until TIMESTAMP NOT NULL,
locked_at TIMESTAMP NOT NULL,
locked_by VARCHAR(255) NOT NULL,
PRIMARY KEY (name)
);
Configuration
Enable scheduling and schedule-locking, and declare the LockProvider bean.
import javax.sql.DataSource;
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.*;
import org.springframework.scheduling.annotation.EnableScheduling;
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulerConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new org.springframework.jdbc.core.JdbcTemplate(dataSource))
.usingDbTime() // use the DB clock, avoids node clock skew
.build());
}
}
Locking a scheduled method
Add @SchedulerLock next to @Scheduled. The name must be unique per task.
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class InvoiceJob {
@Scheduled(cron = "0 0 2 * * *")
@SchedulerLock(name = "sendInvoices",
lockAtLeastFor = "1m",
lockAtMostFor = "9m")
public void sendInvoices() {
// runs on exactly ONE instance per fire
invoiceService.generateAndEmailAll();
}
}
Output (cluster of three nodes at 2 AM):
node-a acquired lock 'sendInvoices' (lock_until=02:09) -> running job
node-b could not acquire 'sendInvoices' -> skipped
node-c could not acquire 'sendInvoices' -> skipped
The two timeouts
| Attribute | Meaning | Why it matters |
|---|---|---|
lockAtMostFor | Maximum lock hold time | Releases the lock if the holder crashes mid-job; set comfortably longer than the worst-case runtime |
lockAtLeastFor | Minimum lock hold time | Prevents a fast job from running twice when node clocks differ slightly |
Warning: If a job ever runs longer than
lockAtMostFor, the lock expires and another instance can start a second copy. Always setlockAtMostForlarger than the longest plausible execution, but not so large that a crashed node blocks the job for hours.
Other lock providers
ShedLock supports many backends — use whichever shared store you already operate:
<!-- Redis instead of JDBC -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-redis-spring</artifactId>
<version>6.3.0</version>
</dependency>
JDBC, Redis, and MongoDB providers all expose the same LockProvider interface, so swapping is a one-bean change.
Alternatives
| Approach | Granularity | Best for |
|---|---|---|
| ShedLock | Per scheduled task | The common case: cron jobs in a cluster |
Leader election (e.g. Spring Integration LeaderInitiator, k8s lease) | One elected leader runs all leader work | When one node should own a whole set of responsibilities |
Redisson RLock | General distributed lock | Locking arbitrary business operations, not just schedules |
Leader election designates a single instance as leader and runs all scheduled work there; if it dies, another is elected. Redisson offers full-featured distributed locks (fair locks, read/write locks) for locking any operation, not just @Scheduled methods.
Best Practices
- Add
@SchedulerLockto every@Scheduledtask that must run once per cluster. - Give each lock a unique, stable
name; reusing a name lets unrelated jobs block each other. - Set
lockAtMostForsafely above the worst-case runtime to survive crashes without deadlock. - Use
usingDbTime()(or a single source of time) to avoid clock-skew bugs across nodes. - Reuse a store you already run (your database or Redis) rather than adding new infrastructure.