Have you ever written a batch job in Spring Boot using @Scheduled, only to deploy it on Kubernetes with multiple Pods and find that the same job runs simultaneously on all Pods? For update operations, this can quietly cause data inconsistencies or duplicate requests to external APIs — a subtle but scary problem.

In this article, I’ll show how to use ShedLock to prevent duplicate execution of @Scheduled jobs with distributed locks, covering both JDBC and Redis patterns. The basics of @Scheduled syntax itself are covered in How to Write Scheduled Jobs with @Scheduled in Spring Boot, so refer to it as needed.

Why @Scheduled Runs Duplicately in Distributed Environments

@Scheduled runs on a scheduler thread inside the JVM process. That means if you have three Pods, three JVMs will each independently fire the job. Spring Boot itself has no awareness of other Pods, so it has no way to stop this.

For read-only jobs this is harmless, but for update operations like “daily billing batches” or “inventory adjustment processing,” duplicate execution causes real damage. It’s a classic pitfall that surfaces the moment you deploy to Kubernetes.

There are several solutions, but applying a distributed lock at the application layer is the lowest-cost option. You could introduce a dedicated scheduler like Quartz Cluster, but ShedLock — which lets you keep using your existing @Scheduled assets as-is — is the more practical choice.

How ShedLock Works

ShedLock has a simple design: just before a job runs, it writes a lock record to a shared store (DB or Redis), and only the instance that successfully writes the record executes the job.

  • Try to acquire the lock when the job fires
  • If a lock already exists, that instance skips and does nothing
  • Release the lock when the job finishes
  • Even if the process crashes, the lock is auto-released after lockAtMostFor elapses

This “timeout for auto-release” is the key, and it directly informs the lockAtMostFor design guidelines discussed later.

Adding Dependencies

Combine Spring Boot 3.x with ShedLock 5.x. Here’s what build.gradle looks like.

dependencies {
    implementation 'net.javacrumbs.shedlock:shedlock-spring:5.16.0'

    // When using JDBC
    implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.16.0'

    // When using Redis
    implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.16.0'
}

You don’t need both — only add the one you’ll use as the lock store. The same groupId/artifactId works for Maven as well.

Enabling with @EnableSchedulerLock

Adding @EnableSchedulerLock to a configuration class causes ShedLock to inject its aspect. Don’t forget to combine it with @EnableScheduling.

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT30M")
public class SchedulerConfig {
}

defaultLockAtMostFor is the default value used when @SchedulerLock on each job doesn’t specify it explicitly. Since it’s only a fallback, it’s safer in production to specify per-job values.

Setting Up the JDBC LockProvider

If your project already uses an RDB, JDBC is the easiest option. First, create a table to store lock information.

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)
);

Next, define the LockProvider as a Bean.

@Configuration
public class JdbcLockConfig {

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(
            JdbcTemplateLockProvider.Configuration.builder()
                .withJdbcTemplate(new JdbcTemplate(dataSource))
                .usingDbTime()
                .build()
        );
    }
}

Adding usingDbTime() makes lock decisions based on the DB server’s time, so you won’t be affected by clock drift between Pods. It’s a subtle but important setting.

For application.yml, your usual DataSource configuration is sufficient.

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/appdb
    username: app
    password: secret

Setting Up the Redis LockProvider

Redis is a good fit for high-frequency jobs or when you don’t want to put extra load on the DB. The Bean definition is just this.

@Configuration
public class RedisLockConfig {

    @Bean
    public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
        return new RedisLockProvider(connectionFactory, "my-app");
    }
}

The second argument "my-app" is a key prefix. If multiple applications share the same Redis, use this to separate namespaces.

spring:
  data:
    redis:
      host: localhost
      port: 6379

Redis manages locks as keys with TTL, so they’re automatically cleared even if the process dies. For Redis integration in general, How to Integrate Spring Boot with Redis to Implement Caching is also helpful.

Applying @SchedulerLock to Jobs

At this point, all that’s left is to add @SchedulerLock to the job method.

@Component
public class BillingJob {

    @Scheduled(cron = "0 0 2 * * *")
    @SchedulerLock(
        name = "BillingJob_dailySettlement",
        lockAtMostFor = "PT20M",
        lockAtLeastFor = "PT1M"
    )
    public void dailySettlement() {
        // Heavy daily batch processing
    }
}

Key points for each argument:

  • name is required and must be globally unique. It becomes the lock record’s key
  • Set lockAtMostFor longer than the job’s maximum execution time. For example, 20 minutes for a job that normally takes 10 minutes. The design assumes that if the process suddenly dies, another Pod can pick it up after this time elapses
  • lockAtLeastFor is the minimum hold time. Use it when you want to prevent a short-running job from being immediately re-executed on another Pod right after it finishes

It’s surprisingly easy to forget to apply it to the same public method as @Scheduled. If it’s on a private method, AOP doesn’t work and the lock won’t be applied.

Verifying Behavior on Lock Collision

Try starting two instances of the same app locally and wait for the job time. Only the one that acquires the lock first should execute the job, while the other skips.

If you set the log level to DEBUG, you’ll see logs like this.

DEBUG n.j.s.core.DefaultLockingTaskExecutor - Not executing 'BillingJob_dailySettlement'. It's locked.

When using a DB, you can directly peek at the shedlock table to see current lock status at a glance.

SELECT name, lock_until, locked_at, locked_by FROM shedlock;

For Redis, you can check similarly with KEYS my-app:*. Don’t use this in production, of course.

JDBC or Redis — Which to Choose?

The conclusion: if you already have a DB, JDBC is sufficient in most cases. Here are the decision criteria.

  • Already using an RDB, with jobs running on minute/hour intervals → JDBC
  • Sub-second high-frequency jobs, or Redis is already part of your operations → Redis
  • Is it acceptable for jobs to stop during a DB outage? → If yes, use JDBC to avoid added operational overhead
  • Which of DB/Redis has better monitoring/backup in place → Whichever you’re more familiar with

Adding a lock store is a pure increase in operational burden, so when in doubt, lean toward using existing assets.

Common Pitfalls

Finally, here’s a roundup of mines you might step on during implementation.

Duplicate or unspecified name. Identical name values cause unrelated jobs to mutually exclude each other. Conversely, you might end up using the same name across different applications. Establishing a naming convention like <app-name>_<job-name> keeps things safe.

lockAtMostFor that’s too short. If a job runs longer than expected and exceeds lockAtMostFor, the lock is released and another Pod starts the same job. Set the value with buffer time over your execution time.

Locks are outside transactions. ShedLock applies the lock outside the job method, so even if a transaction inside the job is rolled back, the lock itself is retained. Worth checking that this doesn’t break any consistency assumptions you rely on.

Summary

ShedLock is a lightweight library that brings distributed locking to your existing @Scheduled assets with just a dependency and a few lines of configuration. When you’re ready to step into multi-Pod operations on Kubernetes, definitely consider adopting it.

If you want to revisit how to use the scheduler itself, see the @Scheduled article. For multi-Pod operations in general, How to Deploy a Spring Boot App to Kubernetes is a good companion read.