Hire Me
← All Writing Spring Boot

Distributed Locking with Redis — Preventing Concurrent Execution

How to use Redis-backed distributed locks to prevent duplicate execution across multiple application instances — with Redisson and Spring Integration.

Run a single instance of your application and a @Scheduled method runs once. Deploy three instances behind a load balancer and it runs three times simultaneously, potentially creating duplicate records, double-processing payments, or corrupting shared state. This is a common failure mode in microservices — the application works perfectly on a developer machine and breaks in any production-scale deployment.

Distributed locking is the solution. The idea is simple: before executing critical work, acquire a lock from a shared store. If another instance holds the lock, skip or wait. When done, release it.

Why Not Database Locks?

Database-level pessimistic locks (SELECT FOR UPDATE) solve the problem but introduce coupling — every distributed lock acquisition hits your primary database. Under high contention, this creates a bottleneck at the worst possible moment. Redis, with its atomic operations and sub-millisecond response times, is a better fit for lock coordination.

The Naive Approach and Its Problems

The basic Redis lock pattern uses SET NX EX (set if not exists, with expiry):

Boolean acquired = redisTemplate.opsForValue()
    .setIfAbsent("lock:nightly-report", "locked", Duration.ofMinutes(5));

if (Boolean.TRUE.equals(acquired)) {
    try {
        runNightlyReport();
    } finally {
        redisTemplate.delete("lock:nightly-report");
    }
}

This works, but has two problems. First, if the process crashes between acquiring and releasing, the lock is held until the TTL expires — potentially 5 minutes of blocked execution. Second, if the task runs longer than the TTL, the lock expires while work is still in progress, allowing another instance to acquire it concurrently.

Redisson: The Right Tool

Redisson’s RLock solves both problems. It uses a watchdog that automatically extends the lock TTL while the holder is still running, and releases on process death:

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient(
            @Value("${spring.data.redis.host}") String host,
            @Value("${spring.data.redis.port}") int port) {
        var config = new Config();
        config.useSingleServer()
              .setAddress("redis://" + host + ":" + port);
        return Redisson.create(config);
    }
}
@Service
@RequiredArgsConstructor
public class NightlyReportService {

    private final RedissonClient redisson;

    public void runNightlyReport() {
        var lock = redisson.getLock("lock:nightly-report");
        boolean acquired = false;
        try {
            acquired = lock.tryLock(0, TimeUnit.SECONDS); // don't wait — skip if locked
            if (!acquired) {
                log.info("Nightly report already running on another instance, skipping");
                return;
            }
            executeReport();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            if (acquired && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

The watchdog (enabled by default with a 30-second TTL and renewal every 10 seconds) keeps the lock alive for the duration of executeReport(). If the JVM dies, the watchdog stops renewing and the lock expires naturally.

Spring Integration: RedisLockRegistry

If you’re already using Spring Integration, RedisLockRegistry gives you a java.util.concurrent.locks.Lock implementation backed by Redis:

@Configuration
public class LockConfig {

    @Bean
    public RedisLockRegistry redisLockRegistry(RedisConnectionFactory factory) {
        return new RedisLockRegistry(factory, "app-locks", 60_000L);
    }
}
@Service
@RequiredArgsConstructor
public class BatchJobService {

    private final RedisLockRegistry lockRegistry;

    public void runDailyBatch() {
        var lock = lockRegistry.obtain("daily-batch");
        try {
            if (lock.tryLock()) {
                try {
                    executeBatch();
                } finally {
                    lock.unlock();
                }
            }
        } catch (Exception e) {
            lock.unlock(); // ensure release on unexpected failure
        }
    }
}

RedisLockRegistry is lighter than Redisson for simple cases, but lacks the watchdog renewal — use it only when task duration is reliably bounded within the TTL.

Wiring It to @Scheduled

The typical use case is preventing duplicate scheduled jobs:

@Scheduled(cron = "0 0 2 * * *") // 2am daily
public void nightlyReconciliation() {
    var lock = redisson.getLock("lock:reconciliation");
    try {
        if (!lock.tryLock(0, TimeUnit.SECONDS)) {
            return;
        }
        try {
            reconciliationService.run();
        } finally {
            if (lock.isHeldByCurrentThread()) lock.unlock();
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

With three application instances all firing at 2am, one acquires the lock and runs the job; the other two exit immediately. Exactly one execution, no coordination overhead beyond a single Redis round-trip per instance.

Avoiding Deadlocks

Always release in a finally block. Always check isHeldByCurrentThread() before unlocking — if the lock expired and was re-acquired by another instance, unlocking from the wrong owner corrupts the distributed state. Redisson enforces this with an IllegalMonitorStateException if you try to unlock a lock you don’t hold, which is the right behaviour.

Set a TTL on every lock. A lock with no expiry that’s never released (due to a crash with no watchdog) will block forever.

Distributed locking solved a persistent duplicate-processing problem on a Spring Boot microservices deployment where three Kubernetes pods were all executing the same overnight batch simultaneously. One lock configuration, problem gone.

If you’re designing reliable scheduled processing or concurrent-safe operations across a distributed Spring Boot deployment, get in touch.