Spring ShedLock – A Quick‑Start & Deep‑Dive Guide
ShedLock is a tiny library that makes sure a scheduled job (or any piece of code) runs only once in a cluster of Spring Boot applications.
It does this by acquiring a distributed lock in a shared storage (DB, Redis, Mongo, …) before the job body is executed.
Below is a complete, production‑ready cheat‑sheet you can copy‑paste into a new Spring Boot project, plus the “why‑and‑how” you need to understand before you ship it.
1️⃣ Why you need ShedLock
| Problem | What you see today | What ShedLock does |
|---|---|---|
Multiple instances of a Spring Boot app running the same @Scheduled method |
The method fires N times (once per instance) → duplicate email, double payment, race conditions | Acquires a global lock → only the instance that wins the lock executes |
| Cron jobs need a guaranteed “once‑per‑minute” semantics even after a pod restarts or a rolling deployment | The scheduler restarts → job may run twice (once before restart, once after) | Lock persists across restarts; the second instance sees the lock and skips execution |
| You already have a relational DB, Redis, or Mongo – don’t want to spin up Zookeeper / etcd just for locking | You’d have to add new infra | ShedLock works on top of any storage you already own |
Bottom line: If you use Spring’s
@Scheduled(or any custom “run‑once” logic) in a clustered environment, ShedLock is the simplest, battle‑tested solution.
2️⃣ Adding the dependency
<!-- Maven -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>5.11.0</version>
</dependency>
<!-- Choose a lock provider (pick ONE) -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId> <!-- for any JDBC DB -->
<version>5.11.0</version>
</dependency>
<!-- OR -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-redis</artifactId>
<version>5.11.0</version>
</dependency>
<!-- OR -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-mongo</artifactId>
<version>5.11.0</version>
</dependency>
Gradle equivalent:
implementation "net.javacrumbs.shedlock:shedlock-spring:5.11.0"
implementation "net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.11.0"
Tip: If you already have a
DataSourcebean (most Spring Boot apps do), the jdbc‑template provider is the zero‑config path.
3️⃣ Configuring the lock provider
3.1 JDBC (PostgreSQL / MySQL / H2 / …)
package com.example.scheduling;
import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
@Configuration
public class ShedLockConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
// ShedLock will create the table automatically on first lock acquisition
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime() // <-- use DB clock, avoids clock‑drift issues
.build()
);
}
}
What table does ShedLock create?
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)
);
You can also create it manually (useful for migrations) – see the DDL in the official docs.
3.2 Redis (single‑node or clustered)
import net.javacrumbs.shedlock.provider.redis.RedisLockProvider;
import net.javacrumbs.shedlock.core.LockProvider;
import org.redisson.api.RedissonClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ShedLockConfig {
@Bean
public LockProvider lockProvider(RedissonClient redisson) {
return new RedisLockProvider(redisson);
}
}
Why Redisson? It’s the only Redis client that ships a ready‑made
RLockimplementation with TTL handling, which is exactly what ShedLock expects.
3.3 MongoDB
import net.javacrumbs.shedlock.provider.mongo.MongoLockProvider;
import com.mongodb.client.MongoClient;
import net.javacrumbs.shedlock.core.LockProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ShedLockConfig {
@Bean
public LockProvider lockProvider(MongoClient mongoClient) {
return new MongoLockProvider(mongoClient.getDatabase("mydb"));
}
}
Collection name:
shedlock(created automatically). Index onnameis added automatically.
4️⃣ Using the annotation on a scheduled method
package com.example.scheduling;
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class BillingJob {
/**
* Runs at the top of every hour, only once across the whole cluster.
* lockAtMostFor = 30 minutes (fallback safety net)
* lockAtLeastFor = 5 minutes (prevents rapid re‑execution if job finishes quickly)
*/
@Scheduled(cron = "0 0 * * * *") // every hour, at minute 0
@SchedulerLock(name = "billingJob", lockAtMostFor = "30m", lockAtLeastFor = "5m")
public void generateInvoices() {
// ... your business logic
System.out.println("Generating invoices at " + java.time.Instant.now());
}
}
What the lock parameters mean
| Parameter | Meaning | Typical values |
|---|---|---|
name |
Unique identifier of the lock (must be globally unique for the job) | "billingJob" |
lockAtMostFor |
Hard timeout – if the lock is not released (e.g., crash) it will be auto‑released after this duration. Prevents dead‑locks. |
30m, 1h
|
lockAtLeastFor |
Soft minimum – even if the job finishes early, the lock will be kept for at least this long. Guarantees a “quiet period” and prevents “flapping” when the job is triggered too often (e.g., when the cron expression fires more frequently than the job can finish). | 5m |
Best practice: Keep
lockAtLeastFor≤ your cron frequency. KeeplockAtMostFora little larger than the worst‑case execution time you expect.
5️⃣ Advanced Topics
5.1 Custom Lock Provider (e.g., AWS DynamoDB, Cassandra)
If none of the built‑in providers fits, you can implement the LockProvider interface:
public final class DynamoDbLockProvider implements LockProvider {
// inject DynamoDb client, table name, etc.
@Override
public Optional<SimpleLock> lock(LockConfiguration request) {
// 1️⃣ Try to put an item with a ConditionExpression "attribute_not_exists(name)"
// 2️⃣ If successful, return SimpleLock that will delete the item on unlock()
// 3️⃣ Respect request.getLockAtMostFor() for TTL (use DynamoDB's TTL attribute)
}
}
Register it as a @Bean just like the others.
5.2 Lock expiration and clock drift
| Issue | Why it matters | Recommended solution |
|---|---|---|
| Node clock is ahead |
lockAtMostFor might expire earlier than expected → another node acquires the lock while the first job still runs |
Use usingDbTime() for JDBC providers, or rely on the server‑side TTL (Redis, Mongo) which is independent of the client clock |
| Lock not released due to crash | Subsequent runs stuck forever |
lockAtMostFor acts as a safety net. Set it slightly larger than the maximum expected runtime. |
| Long-running jobs (> lockAtMostFor) | Job may be killed by the lock provider, causing partial work | Either increase lockAtMostFor or split the job into smaller chunks (each with its own lock). |
5.3 Testing locks (unit & integration)
@SpringBootTest
class BillingJobTest {
@Autowired
private BillingJob billingJob;
@MockBean
private LockProvider lockProvider; // mock from ShedLock
@Test
void testLockAcquired() {
// Simulate lock acquisition success
when(lockProvider.lock(any())).thenReturn(Optional.of(() -> {}));
billingJob.generateInvoices();
// verify that the real business logic was executed
// (e.g., verify a service call)
}
@Test
void testLockNotAcquired() {
// Simulate another node already holding the lock
when(lockProvider.lock(any())).thenReturn(Optional.empty());
billingJob.generateInvoices();
// make sure the business method is NOT called
}
}
Integration test: Spin up an in‑memory H2 DB (or Testcontainers Postgres) and let the real
JdbcTemplateLockProviderwork. Verify that two beans scheduled simultaneously only execute once.
5.4 Combining with Spring Cloud Scheduler / Kubernetes CronJobs
If you’re already using Kubernetes CronJobs (which guarantee a single pod per schedule), you don’t need ShedLock.
But if you run regular Pods with @Scheduled inside a Deployment (replicas > 1) or you have an autoscaling group, ShedLock is the way to go.
6️⃣ Common Pitfalls & How to Fix Them
| Symptom | Root Cause | Fix |
|---|---|---|
| Job runs twice after a quick pod restart |
lockAtMostFor is too short; the lock expires while the job is still running. |
Increase lockAtMostFor to comfortably exceed worst‑case runtime. |
LockAcquisitionException: LockProvider could not acquire lock even though DB is empty |
Spring’s transaction isolation (e.g., READ_COMMITTED) may cause a race condition on the first insert. |
Use JdbcTemplateLockProvider (which uses plain JDBC) or set @Transactional(propagation = Propagation.NOT_SUPPORTED) on the scheduled method. |
Lock table not found |
Auto‑creation disabled (e.g., you turned off spring.jpa.hibernate.ddl-auto). |
Create the shedlock table yourself (DDL above) or enable spring.jpa.hibernate.ddl-auto=update just for dev. |
| Lock never released after crash |
lockAtMostFor set to null (infinite) → lock stays forever. |
Always specify a finite lockAtMostFor. |
| Duplicate executions in a multi‑DB‑replica setup | Each replica uses its own different DB (no shared storage). | Choose a single shared storage (Redis, external PostgreSQL, etc.) for the lock provider. |
7️⃣ Full Minimal Example (Spring Boot 3.x + PostgreSQL)
pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>5.11.0</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>5.11.0</version>
</dependency>
application.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: app
password: secret
jpa:
hibernate:
ddl-auto: update # creates 'shedlock' table automatically
task:
scheduling:
pool:
size: 2
ShedLockConfig.java – same as in section 3.1
BillingJob.java – same as in section 4
Run 2 instances (java -jar app.jar twice) → you’ll see ONLY ONE instance printing the “Generating invoices…” line each hour.
8️⃣ Alternatives (when not to use ShedLock)
| Solution | When it shines |
|---|---|
| Kubernetes CronJob | You want exactly one pod per schedule, no background @Scheduled inside a long‑lived Deployment. |
| Quartz Scheduler + Clustered JobStore (JDBC) | Need complex triggers (calendar intervals, misfire handling, job pause/resume) – Quartz already ships its own DB‑based lock. |
| Spring Cloud Task + Scheduler | One‑off, short‑lived tasks launched by a scheduler. |
| External workflow engines (Temporal, Camunda) | You need full BPMN, saga patterns, or retries with compensation. |
If you only need “run‑once‑per‑cron‑tick”, ShedLock remains the simplest answer.
9️⃣ TL;DR Checklist (Copy‑Paste)
- [ ] Add `shedlock-spring` + a lock‑provider dependency.
- [ ] Create a `LockProvider` bean (JDBC, Redis, Mongo …).
- [ ] Annotate each `@Scheduled` method with `@SchedulerLock(name = "...")`.
- [ ] Pick sensible `lockAtMostFor` (>= max runtime) and `lockAtLeastFor` (<= cron period).
- [ ] Verify that the lock table/collection exists (or let ShedLock create it).
- [ ] Run multiple instances → only one should execute the job.
- [ ] Add unit/integration tests that mock the `LockProvider`.
- [ ] Monitor the `shedlock` table (optional: add a health check that the lock can be acquired).
10️⃣ Further Reading & Resources
| Resource | Why read it |
|---|---|
| GitHub – ShedLock – https://github.com/lukas-krecan/ShedLock | Source code, all provider docs, migration guide |
| Spring Boot Reference – Scheduling – https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#boot-features-task-execution-and-scheduling | Baseline @Scheduled mechanics |
| Redis Locking Best Practices – Redisson docs | If you pick Redis, understand TTL & renewal semantics |
| “Handling Distributed Cron Jobs in Kubernetes” (Medium) | When you need to decide between CronJobs vs. ShedLock |
| ShedLock Spring Boot Starter (Starter Project) – https://start.spring.io (add “ShedLock” as a dependency) | Quick start for a new project |
Enjoy a clean, single‑execution guarantee for all your background jobs! 🎉 If you run into a specific error or need a custom lock (e.g., DynamoDB, etcd), just drop a follow‑up and we’ll dive deeper. Happy coding!
Top comments (0)