Implementing Distributed Locks with Redis

Single-System Locks

In single-system architectures, locks like ReentrantLock are effective for controlling concurrent access within a single JVM. However, when multiple instances of an application are running behind a load balancer, these locks become ineffective as each instance operates independently.

Testing with JMeter to simulate high concurrency through Nginx reveals the problem: even with 100 concurrent requests, inventory may show 12 items remaining instead of zero. This indicates that locks are only effective within their own JVM instance.

Distributed System Locks

Version 1: Basic Redis Locking

The initial approach uses Redis' set and get operations for locking. When a thread needs to access a shared resource, it attempts to set a unique key-value pair. If successful, it proceeds with the critical section; otherwise, it retries.

@RequestMapping("/sale")
public String sale(){
    String retMessage = "";
    Object key = "RedisLock";
    Object value = UUID.randomUUID() + ":" + Thread.currentThread().getId();
    
    Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value);
    
    if (!flag){
        try {
            TimeUnit.MILLISECONDS.sleep(20);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        sale();
    } else {
        try {
            String result = redisTemplate.opsForValue().get("inventory001").toString();
            Integer inventoryNum = result == null ? 0 : Integer.parseInt(result);
            
            if (inventoryNum != 0){
                redisTemplate.opsForValue().set("inventory001", String.valueOf(--inventoryNum));
                retMessage = "Successfully sold item, remaining inventory: " + inventoryNum;
                System.out.println(inventoryNum);
            } else {
                retMessage = "Item sold out!";
            }
        } finally {
            redisTemplate.delete(key);
        }
    }
    
    return retMessage;
}

However, this recursive approach fails in high-concurrency scenarios due to potential stack overflow issues.

Version 2: Spin-Waiting Approach

Replacing recursion with a spin-waiting mechanism solves the stack overflow problem:

@RequestMapping("/sale")
public String sale(){
    String retMessage = "";
    Object key = "RedisLock";
    Object value = UUID.randomUUID() + ":" + Thread.currentThread().getId();
    
    while(!redisTemplate.opsForValue().setIfAbsent(key, value)){
        try {
            TimeUnit.MILLISECONDS.sleep(20);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
    
    try {
        String result = redisTemplate.opsForValue().get("inventory001").toString();
        Integer inventoryNum = result == null ? 0 : Integer.parseInt(result);
        
        if (inventoryNum != 0){
            redisTemplate.opsForValue().set("inventory001", String.valueOf(--inventoryNum));
            retMessage = "Successfully sold item, remaining inventory: " + inventoryNum;
            System.out.println(inventoryNum);
        } else {
            retMessage = "Item sold out!";
        }
    } finally {
        redisTemplate.delete(key);
    }
    
    return retMessage;
}

While this prevents stack overflow, it introduces a new problem: if an exception occurs before the finally block, the lock remains indefinitely.

Version 3: Adding Expiration

To prevent indefinite lock retention, we add expiration times to locks:

@RequestMapping("/sale")
public String sale(){
    String retMessage = "";
    Object key = "RedisLock";
    Object value = UUID.randomUUID() + ":" + Thread.currentThread().getId();
    
    while(!redisTemplate.opsForValue().setIfAbsent(key, value)){
        try {
            TimeUnit.MILLISECONDS.sleep(20);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
    
    redisTemplate.expire(key, 30L, TimeUnit.SECONDS);
    
    try {
        String result = redisTemplate.opsForValue().get("inventory001").toString();
        Integer inventoryNum = result == null ? 0 : Integer.parseInt(result);
        
        if (inventoryNum != 0){
            redisTemplate.opsForValue().set("inventory001", String.valueOf(--inventoryNum));
            retMessage = "Successfully sold item, remaining inventory: " + inventoryNum;
            System.out.println(inventoryNum);
        } else {
            retMessage = "Item sold out!";
        }
    } finally {
        redisTemplate.delete(key);
    }
    
    return retMessage;
}

However, this approach has a race condition: if an exception occurs after setting the lock but before setting the expiration, we still face the same problem.

Version 4: Atomic Operations

Making the lock acquisition and expiration atomic solves the race condition:

@RequestMapping("/sale")
public String sale(){
    String retMessage = "";
    Object key = "RedisLock";
    Object value = UUID.randomUUID() + ":" + Thread.currentThread().getId();
    
    while(!redisTemplate.opsForValue().setIfAbsent(key, value, 30L, TimeUnit.SECONDS)){
        try {
            TimeUnit.MILLISECONDS.sleep(20);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
    
    try {
        String result = redisTemplate.opsForValue().get("inventory001").toString();
        Integer inventoryNum = result == null ? 0 : Integer.parseInt(result);
        
        if (inventoryNum != 0){
            redisTemplate.opsForValue().set("inventory001", String.valueOf(--inventoryNum));
            retMessage = "Successfully sold item, remaining inventory: " + inventoryNum;
            System.out.println(inventoryNum);
        } else {
            retMessage = "Item sold out!";
        }
    } finally {
        if (redisTemplate.opsForValue().get(key).toString().equalsIgnoreCase(value.toString())){
            redisTemplate.delete(key);
        }
    }
    
    return retMessage;
}

This ensures only the lock owner can delete their lock, but the delete operation itself isn't atomic.

Version 5: Atomic Unlock with Lua

Using Lua scripts makes the unlock operation atomic:

@RequestMapping("/sale")
public String sale(){
    String retMessage = "";
    Object key = "RedisLock";
    Object value = UUID.randomUUID() + ":" + Thread.currentThread().getId();
    
    while(!redisTemplate.opsForValue().setIfAbsent(key, value, 30L, TimeUnit.SECONDS)){
        try {
            TimeUnit.MILLISECONDS.sleep(20);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
    
    try {
        String result = redisTemplate.opsForValue().get("inventory001").toString();
        Integer inventoryNum = result == null ? 0 : Integer.parseInt(result);
        
        if (inventoryNum != 0){
            redisTemplate.opsForValue().set("inventory001", String.valueOf(--inventoryNum));
            retMessage = "Successfully sold item, remaining inventory: " + inventoryNum;
            System.out.println(inventoryNum);
        } else {
            retMessage = "Item sold out!";
        }
    } finally {
        String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then " +
                "return redis.call('del', KEYS[1]) " +
                "else " +
                "return 0 " +
                "end";
        
        redisTemplate.execute(new DefaultRedisScript<>(luaScript, Boolean.class), 
                Arrays.asList(key), value);
    }
    
    return retMessage;
}

Version 6: Reentrant Locks

To support reentrancy, we use Redis hash structures to track lock ownership and reentrancy counts:

// Lock acquisition script
if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 then
    redis.call('hincrby', KEYS[1], ARGV[1], 1)
    redis.call('expire', KEYS[1], ARGV[2])
    return 1
else
    return 0
end
// Lock release script
if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then
    return nil
elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 then
    return redis.call('del', KEYS[1])
else
    return 0
end

Implementing these scripts in Java:

public class RedisDistributedLock implements Lock {
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private String lockName;
    private String uuidValue;
    private Long expireTime;
    
    public RedisDistributedLock(StringRedisTemplate redisTemplate, String lockName) {
        this.redisTemplate = redisTemplate;
        this.lockName = lockName;
        this.uuidValue = UUID.randomUUID() + ":" + Thread.currentThread().getId();
        this.expireTime = 50L;
    }
    
    @Override
    public void lock() {
        try {
            tryLock();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
    
    @Override
    public boolean tryLock() {
        try {
            return tryLock(-1L, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
    
    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        if (time == -1L) {
            String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 then " +
                    "redis.call('hincrby', KEYS[1], ARGV[1], 1) redis.call('expire', KEYS[1], ARGV[2]) " +
                    "return 1 " +
                    "else " +
                    "return 0 " +
                    "end";
            
            while (!redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), 
                    Arrays.asList(lockName), uuidValue, String.valueOf(expireTime))) {
                TimeUnit.MILLISECONDS.sleep(20);
            }
            return true;
        }
        return false;
    }
    
    @Override
    public void unlock() {
        String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then " +
                "return nil " +
                "elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 then " +
                "return redis.call('del', KEYS[1]) " +
                "else return 0 " +
                "end";
        
        Long flag = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), 
                Arrays.asList(lockName), uuidValue, String.valueOf(expireTime));
        
        if (null == flag) {
            throw new RuntimeException("This lock doesn't exist!");
        }
    }
    
    // Other Lock interface methods...
}

Version 7: Automatic Renewal

To handle long-running operations, we add automatic renewal functionality:

private void renewExpire() {
    String script = "if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 1 then " +
            "return redis.call('expire', KEYS[1], ARGV[2]) " +
            "else " +
            "return 0 " +
            "end";
    
    new Timer().schedule(new TimerTask() {
        @Override
        public void run() {
            if (redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), 
                    Arrays.asList(lockName), uuidValue, String.valueOf(expireTime))) {
                renewExpire();
            }
        }
    }, (this.expireTime * 1000) / 3);
}

Redisson Implementation

For production use, consider using Redisson, a mature Redis client with built-in distributed lock support:

@Configuration
public class RedisConfig {
    @Bean
    public RedissonClient redisson() {
        Config config = new Config();
        config.useSingleServer()
                .setAddress("redis://127.0.0.1:6379")
                .setDatabase(0);
        return Redisson.create(config);
    }
}
@Autowired
private RedissonClient redissonClient;

@RequestMapping("/sale")
public String sale() {
    String retMessage = "";
    RLock lock = redissonClient.getLock("RedisLock");
    lock.lock();
    
    try {
        String result = redisTemplate.opsForValue().get("inventory001").toString();
        Integer inventoryNum = result == null ? 0 : Integer.parseInt(result);
        
        if (inventoryNum != 0) {
            redisTemplate.opsForValue().set("inventory001", String.valueOf(--inventoryNum));
            retMessage = "Successfully sold item, remaining inventory: " + inventoryNum;
            System.out.println(inventoryNum);
        } else {
            retMessage = "Item sold out!";
        }
    } finally {
        if (lock.isLocked() && lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
    
    return retMessage;
}

Redisson provides robust distributed lock implementations with features like fair locking, lock watching, and automatic renewal, making it suitable for production environments.

Tags: Redis Distributed Systems Concurrency Control Locking Mechanisms Redisson

Posted on Tue, 07 Jul 2026 16:32:24 +0000 by Stingus