DEV Community

Gaurav
Gaurav

Posted on

Spring ShedLock

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>
Enter fullscreen mode Exit fullscreen mode

Gradle equivalent:

implementation "net.javacrumbs.shedlock:shedlock-spring:5.11.0"
implementation "net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.11.0"
Enter fullscreen mode Exit fullscreen mode

Tip: If you already have a DataSource bean (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()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Why Redisson? It’s the only Redis client that ships a ready‑made RLock implementation 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"));
    }
}
Enter fullscreen mode Exit fullscreen mode

Collection name: shedlock (created automatically). Index on name is 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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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. Keep lockAtMostFor a 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

Integration test: Spin up an in‑memory H2 DB (or Testcontainers Postgres) and let the real JdbcTemplateLockProvider work. 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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).
Enter fullscreen mode Exit fullscreen mode

10️⃣ Further Reading & Resources

Resource Why read it
GitHub – ShedLockhttps://github.com/lukas-krecan/ShedLock Source code, all provider docs, migration guide
Spring Boot Reference – Schedulinghttps://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)