Distributed Locks with Redis in Spring Boot

1. Overview

When your application runs on multiple instances, synchronized and ReentrantLock are no longer enough. These locks only work within a single JVM. This article guides you through building distributed locks with Redis in two ways: implementing from scratch with Spring Data Redis, and using the Redisson library. After reading this, you’ll know how to safely acquire and release locks, how to write tests for locks, and when Redis locks might not be secure enough.

2. Why you need distributed locks

Suppose you have an OrderService running on 3 pods. Two customers order the last item in stock. The two requests hit different pods. Both read stock = 1, both decrement the stock, and the result is overselling.

Another example: the @Scheduled job creates a report at 2 AM. If there are 3 instances, the job runs 3 times, and customers receive 3 identical emails.

In both cases, you need a lock visible to every instance. Redis is well-suited for this role for three reasons:

  • Redis processes commands sequentially on a single thread, so each command is atomic.
  • Keys can have TTL, so locks automatically expire if the process holding them crashes.
  • It has low latency and most systems already have Redis.

3. Principle: the SET NX PX command

The entire mechanism is based on a single command:

redis-cli SET lock:product:42 "c1f9-token" NX PX 10000

Meaning of each part:

  • NX: write only if the key doesn’t exist. This is how we “acquire” the lock.
  • PX 10000: the key expires automatically after 10 seconds. This way, if the process dies midway, the lock won’t be stuck forever.
  • "c1f9-token": a random value, unique for each lock acquisition. This value identifies who owns the lock.

Call the command twice in a row and you’ll see:

OK
(nil)

The first time it returns OK, meaning you acquired the lock. The second time it returns (nil) because the key already exists.

Note: Don’t use SETNX and then call EXPIRE in a separate command. If the process dies between the two commands, the key never expires and you get deadlocked.

4. Self-implementing with Spring Data Redis

4.1. Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Configure the connection in application.properties:

spring.data.redis.host=localhost
spring.data.redis.port=6379

4.2. Acquire and release locks

@Component
public class RedisLock {

    private static final DefaultRedisScript<Long> RELEASE_SCRIPT = new DefaultRedisScript<>("""
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            else
                return 0
            end
            """, Long.class);

    private final StringRedisTemplate redis;

    public RedisLock(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public Optional<String> tryLock(String key, Duration ttl) {
        String token = UUID.randomUUID().toString();
        Boolean acquired = redis.opsForValue().setIfAbsent(key, token, ttl);
        return Boolean.TRUE.equals(acquired) ? Optional.of(token) : Optional.empty();
    }

    public boolean unlock(String key, String token) {
        Long deleted = redis.execute(RELEASE_SCRIPT, List.of(key), token);
        return deleted != null && deleted == 1L;
    }
}

Explanation:

  • setIfAbsent(key, token, ttl) generates exactly the command SET key token NX PX ttl. Result true means you acquired the lock.
  • tryLock returns the token if successful. You must keep this token to release the lock later.
  • unlock runs a Lua script. The script checks whether the key’s value matches your token, and only deletes the key if it does.

Why not just call DEL? Consider this scenario: Instance A acquires a lock with a 10-second TTL but takes 12 seconds to process. At the 10-second mark, the lock expires and instance B acquires it. At the 12-second mark, A calls DEL and deletes B’s lock. Now instance C can enter, and B and C run in parallel.

If you separate it into GET then DEL, there’s still a window between the two commands. A Lua script runs atomically on Redis, so that window doesn’t exist.

4.3. Using in a service

public record Order(long productId, int quantity) {}

@Service
public class OrderService {

    private final RedisLock redisLock;
    private final InventoryRepository inventory;

    public OrderService(RedisLock redisLock, InventoryRepository inventory) {
        this.redisLock = redisLock;
        this.inventory = inventory;
    }

    public void placeOrder(Order order) {
        String key = "lock:product:" + order.productId();
        String token = redisLock.tryLock(key, Duration.ofSeconds(10))
                .orElseThrow(() -> new IllegalStateException("The product is being processed, please try again"));
        try {
            inventory.decrease(order.productId(), order.quantity());
        } finally {
            redisLock.unlock(key, token);
        }
    }
}

Three points to watch:

  • The lock key should be specific to each resource. Here it’s lock:product:{id}. If you use a single lock for all products, all orders will be serialized.
  • unlock always goes in finally so the lock is released even if an exception occurs.
  • This example fails fast if the lock can’t be acquired. If you want to wait, you can retry a few times with short backoff. Don’t use an unbounded while(true) loop.

InventoryRepository is any repository in your project, for example a Spring Data JPA repository.

5. Using Redisson

Implementing from scratch helps you understand the mechanism, but it’s missing a few features: extending the lock when processing takes longer, reentrancy, and waiting for a lock using pub/sub instead of polling. Redisson provides these out of the box.

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.45.1</version>
</dependency>

You should check the latest version compatible with the Spring Boot version your project uses. This starter reads the spring.data.redis.* configuration and creates a RedissonClient bean.

@Service
public class DailyReportJob {

    private static final Logger log = LoggerFactory.getLogger(DailyReportJob.class);
    private final RedissonClient redisson;

    public DailyReportJob(RedissonClient redisson) {
        this.redisson = redisson;
    }

    @Scheduled(cron = "0 0 2 * * *")
    public void generate() throws InterruptedException {
        RLock lock = redisson.getLock("lock:daily-report");
        if (!lock.tryLock(0, TimeUnit.SECONDS)) {
            log.info("Another instance is running the report, skipping");
            return;
        }
        try {
            log.info("Starting daily report generation");
            // query data, export file, send email...
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

When the job runs on 3 instances, the log will look like:

[pod-1] Starting daily report generation
[pod-2] Another instance is running the report, skipping
[pod-3] Another instance is running the report, skipping

Important points:

  • tryLock(0, TimeUnit.SECONDS) doesn’t wait. If the lock is held, the method returns false immediately.
  • When you don’t provide leaseTime, Redisson enables the watchdog. The lock has a default TTL of 30 seconds (lockWatchdogTimeout) and is renewed roughly every 10 seconds as long as the thread holds it. If the JVM dies, the renewal stops and the lock expires on its own.
  • When you call tryLock(wait, lease, unit) with a specific leaseTime, the watchdog is disabled. The lock will expire exactly after lease, regardless of whether the work is done.
  • RLock is tied to a thread and is reentrant. If you call unlock() from a different thread, you’ll get IllegalMonitorStateException. This is especially important when using CompletableFuture or reactive code.

Note: Since RLock is tied to a thread, be careful when using it with virtual threads and frameworks that switch threads mid-execution. The lock must be acquired and released on the same thread.

6. Writing tests with Testcontainers

You should test locks on real Redis, not with mocks. Add these test dependencies (versions managed by Spring Boot):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@DataRedisTest
@Import(RedisLock.class)
@Testcontainers
class RedisLockTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    @Autowired
    RedisLock redisLock;

    @Autowired
    StringRedisTemplate template;

    @Test
    void givenConcurrentRequests_whenTryLock_thenOnlyOneSucceeds() throws Exception {
        List<Callable<Optional<String>>> tasks = IntStream.range(0, 20)
                .<Callable<Optional<String>>>mapToObj(i -> () -> redisLock.tryLock("lock:concurrent", Duration.ofSeconds(5)))
                .toList();

        try (ExecutorService executor = Executors.newFixedThreadPool(20)) {
            long acquired = executor.invokeAll(tasks).stream()
                    .map(Future::resultNow)
                    .filter(Optional::isPresent)
                    .count();

            assertThat(acquired).isEqualTo(1);
        }
    }

    @Test
    void givenWrongToken_whenUnlock_thenLockIsKept() {
        String token = redisLock.tryLock("lock:owner", Duration.ofSeconds(5)).orElseThrow();

        assertThat(redisLock.unlock("lock:owner", "not-my-token")).isFalse();
        assertThat(template.hasKey("lock:owner")).isTrue();

        assertThat(redisLock.unlock("lock:owner", token)).isTrue();
        assertThat(template.hasKey("lock:owner")).isFalse();
    }
}

Explanation:

  • @DataRedisTest loads only the Redis configuration part, so tests run fast. @Import(RedisLock.class) adds the bean you’re testing to the context.
  • @ServiceConnection(name = "redis") automatically points StringRedisTemplate to the container you just started. You don’t need to manually specify host and port.
  • The first test has 20 threads compete for a lock and asserts that exactly 1 wins.
  • The second test verifies the Lua script works correctly: a wrong token can’t delete the lock, the right token can.

7. Common mistakes and limitations

7.1. TTL shorter than processing time

This is the most common mistake. The lock expires while the work is still running, and another instance enters. The cause isn’t just slow code. GC pause, slow network, or waiting for a connection pool can also make you exceed the TTL.

There are two ways to handle this. First is to use Redisson’s watchdog. Second is to set a generous TTL, like several times the p99 processing time.

7.2. Lock doesn’t guarantee absolute safety

Redis replication is asynchronous. Suppose the master receives a lock but crashes before replicating it to the replica. The replica gets promoted to the new master, and the lock key isn’t there. Another client can now acquire the same lock.

The Redlock algorithm (acquiring locks on a majority of N independent nodes) reduces this risk. However, Redlock is still debated because it depends on assumptions about clocks and latency. In Redisson, RedissonRedLock is now marked deprecated.

If data corruption has serious consequences, like charging twice, don’t rely only on locks. Combine with a fencing token: each time you acquire a lock, you get an incrementing number (e.g., from INCR lock:product:42:fence). The storage layer rejects writes with older tokens:

UPDATE inventory
SET stock = stock - :qty, last_fence = :fence
WHERE product_id = :id AND last_fence < :fence;

If the update affects 0 rows, a process with a newer token wrote first. The old request is rejected.

7.3. Other mistakes

  • Using the same key for all resources, causing throughput to plummet.
  • Forgetting finally, so locks only release when TTL expires.
  • Calling unlock() on Redisson when you no longer hold the lock (because it expired). Check isHeldByCurrentThread() first.
  • Retrying lock acquisition continuously without backoff, flooding Redis with requests.

7.4. When NOT to use locks

  • Single-instance applications: ReentrantLock is sufficient and much simpler.
  • Data is in a relational database: optimistic locking (@Version), SELECT ... FOR UPDATE, or unique constraints are usually safer. They live in the same transaction as the data.
  • Need absolute guarantees: consider consensus systems like ZooKeeper or etcd, combined with fencing tokens.
  • Just need to process each message once: idempotency keys or message broker consumer groups are often better than locks.

8. Conclusion

Distributed locks with Redis are based on three principles: acquire locks with a single atomic command SET NX PX, bind a unique token to each acquisition, and release locks with a Lua script that checks the token. You can implement it yourself in a few dozen lines with StringRedisTemplate, or use Redisson for added watchdog, reentrancy, and efficient wait mechanisms.

Remember that Redis locks help avoid duplicate processing in most cases, but aren’t an absolute guarantee. For critical operations, combine locks with fencing tokens or constraints at the database layer. Also, always test locks on real Redis using Testcontainers.

redisdistributed-lockspring-bootredissonconcurrency