Implementing Production-Grade Redis Distributed Locks in Java

Prerequisites

Ensure your Spring Boot project includes the necessary dependencies for Redis integration. Configure connection parameters in application.yml and inject StringRedisTemplate or RedissonClient.

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

<!-- application.yml Configuration -->
spring:
  redis:
    host: localhost
    port: 6379
    password: secret_password
    database: 0
    lettuce:
      pool:
        max-active: 16
        max-idle: 8
        min-idle: 4
        max-wait: 1000ms

  1. Basic Mutex Implementation (Single Node)

Core Concept

The fundamental approach leverages the atomic nature of the Redis command SET key value NX EX seconds. This ensures that a lock is only acquired if the key does not exist, simultaneously setting an expiration time to prevent permanent deadlocks. To avoid deleting another client's lock, a unique identifier (UUID) is stored as the value and verified before release.

Code Structure

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Component
public class SimpleRedisLock {

    private final StringRedisTemplate redisTemplate;

    public SimpleRedisLock(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * Attempts to acquire a lock.
     * @param resourceKey Unique identifier for the resource being locked
     * @param ttlSeconds Time-to-live for the lock
     * @return A unique token if successful, null otherwise
     */
    public String tryAcquire(String resourceKey, long ttlSeconds) {
        String token = UUID.randomUUID().toString();
        // Atomic operation: Set if Not Exists with Expiration
        Boolean acquired = redisTemplate.opsForValue()
                .setIfAbsent(resourceKey, token, ttlSeconds, TimeUnit.SECONDS);
        
        return Boolean.TRUE.equals(acquired) ? token : null;
    }

    /**
     * Releases the lock safely by verifying ownership.
     * Note: In high-concurrency production environments, consider Lua scripts 
     * for atomic check-and-delete operations to eliminate race conditions.
     */
    public void release(String resourceKey, String token) {
        if (token == null) return;
        
        String currentToken = redisTemplate.opsForValue().get(resourceKey);
        if (token.equals(currentToken)) {
            redisTemplate.delete(resourceKey);
        }
    }
}

Usage Guidelines

  • Key Naming: Use hierarchical keys like lock:inventory:item_5001 to isolate resources.
  • Expiration Strategy: Set TTL significantly higher than the maximum expected execution time to account for network latency and GC pauses.
  • Finally Block: Always release locks in a finally block to guarantee cleanup even if exceptions occur during business logic execution.
  1. Advanced Lock with Automatic Renewal (Watchdog Pattern)

The Problem

A static TTL fails when business logic duration is unpredictable. If the process exceeds the TTL, the lock expires prematurely, allowing other threads to enter the critical section, leading to data inconsistency.

The Solution

Implement a background daemon thread that periodically extends the lock's expiration time while the main thread is still executing. The renewal interval is typically set to one-third of the initial TTL.

Implementation Details

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

@Component
public class RenewableRedisLock {

    private final StringRedisTemplate redisTemplate;

    public RenewableRedisLock(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public static class LockHandle {
        final String key;
        final String token;
        final long ttl;
        final AtomicBoolean stopRenewal = new AtomicBoolean(false);

        public LockHandle(String key, String token, long ttl) {
            this.key = key;
            this.token = token;
            this.ttl = ttl;
        }
    }

    public LockHandle acquire(String key, long ttlSeconds) {
        String token = UUID.randomUUID().toString();
        Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(key, token, ttlSeconds, TimeUnit.SECONDS);
        
        if (!Boolean.TRUE.equals(success)) {
            return null;
        }

        LockHandle handle = new LockHandle(key, token, ttlSeconds);
        startRenewalThread(handle);
        return handle;
    }

    private void startRenewalThread(LockHandle handle) {
        Thread renewer = new Thread(() -> {
            long intervalMs = (handle.ttl * 1000) / 3;
            
            while (!handle.stopRenewal.get()) {
                try {
                    Thread.sleep(intervalMs);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }

                // Check if we still own the lock
                String currentValue = redisTemplate.opsForValue().get(handle.key);
                if (handle.token.equals(currentValue)) {
                    // Extend expiration
                    redisTemplate.expire(handle.key, handle.ttl, TimeUnit.SECONDS);
                } else {
                    // Lock lost or expired externally
                    handle.stopRenewal.set(true);
                    break;
                }
            }
        });
        
        renewer.setDaemon(true); // Ensure it doesn't prevent JVM shutdown
        renewer.start();
    }

    public void release(LockHandle handle) {
        if (handle == null) return;
        
        handle.stopRenewal.set(true); // Signal renewal thread to stop
        
        String currentValue = redisTemplate.opsForValue().get(handle.key);
        if (handle.token.equals(currentValue)) {
            redisTemplate.delete(handle.key);
        }
    }
}

  1. Distributed Locks with Redisson (Cluster Environment)

Why Redisson?

In Redis Cluster or Sentinel architectures, single-node locks are vulnerable to master-slave failover issues (the lock might be lost if the master crashes before syncing to slaves). Redisson implements the Redlock algorithm, providing reliable distributed locking mechanisms out-of-the-box, including automatic watchdog renewal, reentrancy, and fairness policies.

Maven Dependency

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

Configuration Example

spring:
  redis:
    cluster:
      nodes:
        - redis-node-1:6379
        - redis-node-2:6379
        - redis-node-3:6379
    password: cluster_secret

# Optional Redisson specific tuning
redisson:
  threads: 16
  netty-threads: 32

Java Integration

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;

@Service
public class InventoryService {

    private final RedissonClient redissonClient;

    public InventoryService(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    public boolean deductStock(Long itemId) {
        String lockName = "distributed_lock:stock:" + itemId;
        RLock lock = redissonClient.getLock(lockName);
        
        try {
            // Try to acquire lock: wait up to 3 seconds, lease time -1 enables auto-renewal (watchdog)
            if (lock.tryLock(3, -1, TimeUnit.SECONDS)) {
                try {
                    // Execute critical business logic
                    performDeduction(itemId);
                    return true;
                } finally {
                    // Release only if held by current thread
                    if (lock.isHeldByCurrentThread()) {
                        lock.unlock();
                    }
                }
            } else {
                System.out.println("Failed to acquire distributed lock for item: " + itemId);
                return false;
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        }
    }
    
    private void performDeduction(Long itemId) {
        // Simulate DB update
    }
}

Key Advantages

  • Auto-Renewal: The built-in watchdog automaticaly extends the lock validity every 10 seconds if the thread is still active, eliminating manual renewal code.
  • Safety: Ensures locks are released correctly across multiple nodes, preventing stale locks after node failures.
  • Versatility: Supports Fair Locks, Read/Write Locks, and Semaphore patterns beyond simple mutexes.
  1. Cache Protection: Double-Lock Strategy

Scenario

When handling cache misses (Cache Penetration/Breakdown), simply querying the database under a distributed lock can cause severe contention. A hybrid approach uses a local JVM lock (synchronized) combined with a Redis distributed lock to optimize performance.

Logic Flow

  1. Check Redis Cache. If hit, return data.
  2. If miss, acquire a Local Lock (synchronized). Thiss prevents multiple threads within the same JVM instance from hammering Redis for the lock.
  3. Inside the local lock, check Redis again (Double-Check). If another thread just populated the cache, return immediately.
  4. If still missing, acquire a Redis Distributed Lock. This coordinates access across different server instances.
  5. Query Database, populate Redis, release both locks.

Implementation

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.concurrent.TimeUnit;

@Component
public class CachedDataLoader {

    private final StringRedisTemplate redisTemplate;
    private final RedissonClient redissonClient;
    // Global object monitor for local synchronization
    private final Object localMonitor = new Object();

    public CachedDataLoader(StringRedisTemplate redisTemplate, RedissonClient redissonClient) {
        this.redisTemplate = redisTemplate;
        this.redissonClient = redissonClient;
    }

    public String loadProductDetail(Long productId) {
        String cacheKey = "product:detail:" + productId;
        String lockKey = "lock:product:load:" + productId;

        // Step 1: Fast path - Check Cache
        String cached = redisTemplate.opsForValue().get(cacheKey);
        if (StringUtils.hasText(cached)) {
            return cached;
        }

        // Step 2: Local Lock to reduce Redis contention within the same app instance
        synchronized (localMonitor) {
            // Step 3: Double Check Cache
            cached = redisTemplate.opsForValue().get(cacheKey);
            if (StringUtils.hasText(cached)) {
                return cached;
            }

            // Step 4: Distributed Lock for cross-instance coordination
            RLock distLock = redissonClient.getLock(lockKey);
            try {
                // Wait 3s, Auto-renewal enabled (-1)
                if (distLock.tryLock(3, -1, TimeUnit.SECONDS)) {
                    try {
                        // Final check inside distributed lock (in case another node updated it)
                        cached = redisTemplate.opsForValue().get(cacheKey);
                        if (StringUtils.hasText(cached)) {
                            return cached;
                        }

                        // Step 5: Load from DB
                        String dbData = fetchFromDatabase(productId);
                        
                        // Step 6: Populate Cache with TTL to prevent avalanche
                        if (dbData != null) {
                            redisTemplate.opsForValue().set(cacheKey, dbData, 300, TimeUnit.SECONDS);
                        }
                        return dbData;
                    } finally {
                        if (distLock.isHeldByCurrentThread()) {
                            distLock.unlock();
                        }
                    }
                } else {
                    // Handle lock acquisition failure gracefully
                    throw new RuntimeException("System busy, please retry");
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException("Interrupted while loading data", e);
            }
        }
    }

    private String fetchFromDatabase(Long id) {
        // DAO call simulation
        return "Product Info for ID: " + id;
    }
}

  1. Production Best Practices

  • Connection Pooling: Always configure Lettuce or Jedis connection pools appropriately. Default settings often lead to bottlenecks under high load.
  • Granularity: Avoid coarse-grained locks (e.g., locking all products). Lock specific resource IDs (e.g., lock:product:101) to maximize concurrency.
  • Error Handling: Wrap lock acquisition and release in try-catch blocks. Log failures explicitly to aid in debugging race conditions.
  • Testing: Conduct stress tests simulating high concurrency and node failures to verify lock reliability and recovery mechanisms.
  • Timeout Management: Balance waitTime (how long to wait for a lock) and leaseTime (how long the lock is held). Too short causes frequent failures; too long reduces throughput.

Tags: java Redis Distributed Systems Concurrency Control Spring Boot

Publicado em 10-2 09:46