Redis distributed lock: redis command

foreword
There are generally three ways to implement distributed locks:

  1. Database optimistic locking;
  2. Redis-based distributed lock;
  3. ZooKeeper-based distributed lock.

This blog will introduce the second way to implement distributed locks based on Redis.

What is a distributed lock?
To introduce distributed locks, we must first mention that the corresponding distributed locks are thread locks and process locks.

Thread lock: mainly used to lock methods and code blocks. When a method or code uses a lock, only one thread executes the method or code segment at a time. Thread locks are only effective in the same JVM, because the implementation of thread locks is fundamentally achieved by sharing memory between threads. For example, synchronized is a shared object header, indicating that locks share a certain variable (state).

Process lock: In order to control multiple processes in the same operating system to access a shared resource, because processes are independent, each process cannot access the resources of other processes, so it is impossible to achieve process locks through thread locks such as synchronized.

Distributed locks: When multiple processes are not in the same system, distributed locks are used to control the access of multiple processes to resources.

Distributed lock usage scenarios.
Both the inter-thread concurrency problem and the inter-process concurrency problem can be solved by distributed locks, but this is strongly not recommended! Because using distributed locks to solve these small problems is very resource-intensive! Distributed locks should be used to solve multi-process concurrency problems in distributed situations.

There is a situation where thread A and thread B both share some variable X.

If it is a single machine (single JVM), the memory is shared between threads, and the concurrency problem can be solved by using thread locks.

If it is a distributed situation (multiple JVMs), thread A and thread B are likely not in the same JVM, so thread locks will not work, and distributed locks must be used to solve this problem.

reliability
First, in order to ensure that distributed locks are available, we must at least ensure that the implementation of the lock meets the following four conditions:

  • mutual exclusivity. At any time, only one client can hold the lock.
  • No deadlock will occur. Even if a client crashes while holding the lock and does not actively unlock it, other clients can be guaranteed to lock in the future.
  • Fault tolerant. Clients can lock and unlock as long as most of the Redis nodes are up and running.
  • The trouble should end it. Locking and unlocking must be done by the same client, and the client cannot unlock the locks added by others.

Code
component dependencies
First, we need to introduce Jedis open source components through Maven, and add the following code to the pom.xml file:

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>

Implementation of distributed locks (Redis)
The key to the implementation of distributed locks is to build a storage server outside the distributed application server to store lock information. At this time, we can easily think of Redis. First, we need to build a Redis server and use the Redis server to store lock information.

A few key points to note when implementing:

1. The lock information must expire and time out, and a thread cannot be allowed to hold a lock for a long time and cause deadlock;

2. Only one thread can acquire the lock at the same time.

Several redis commands to use:

setnx(key, value): "set if not exits", if the key-value does not exist, it is successfully added to the cache and returns 1, otherwise it returns 0.

get(key): Get the value corresponding to the key, or return nil if it does not exist.

getset(key, value): First get the value corresponding to the key, if it does not exist, return nil, and then update the old value to the new value.

expire(key, seconds): Set the validity period of key-value to seconds.

Take a look at the flowchart:

Under this process, no deadlock will result.

lock code

public class RedisTool {
 
    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";
 
    /**
     * Attempt to acquire a distributed lock
     * @param jedis Redis client
     * @param lockKey Lock
     * @param requestId Request ID
     * @param expireTime Expiration time
     * @return Is it successful
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
 
        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
 
        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;
 
    }
 
}

As you can see, we lock with one line of code: jedis.set(String key, String value, String nxxx, String expx, int time), this set() method has a total of five formal parameters:

  • The first one is the key, we use the key as the lock because the key is unique.
  • The second is value, we pass requestId, many children's shoes may not understand, is it not enough to have key as a lock, why use value? The reason is that when we talked about reliability above, the distributed lock must meet the fourth condition to unlock the ringer, and by assigning the value as requestId, we know which request the lock was added. can have a basis. The requestId can be generated using the UUID.randomUUID().toString() method.
  • The third is nxxx, we fill in NX for this parameter, which means SET IF NOT
    EXIST, that is, when the key does not exist, we perform the set operation; if the key already exists, no operation is performed;
  • The fourth one is expx, and this parameter we pass is PX, which means that we need to add an expired setting to this key, and the specific time is determined by the fifth parameter.
  • The fifth is time, which corresponds to the fourth parameter and represents the expiration time of the key.

In general, executing the above set() method will only lead to two results: 1. There is no lock at present (the key does not exist), then the locking operation is performed, and a validity period is set for the lock, and the value indicates the lock client. 2. There is a lock, do nothing.

Careful children's shoes will find that our lock code meets the three conditions described in our reliability. First of all, set() adds the NX parameter to ensure that if the key already exists, the function will not be called successfully, that is, only one client can hold the lock, which satisfies mutual exclusion. Secondly, since we set an expiration time for the lock, even if the lock holder crashes and fails to unlock it, the lock will be automatically unlocked (that is, the key will be deleted) because of the expiration time, and no deadlock will occur. Finally, because we assign the value to requestId, which represents the locked client request ID, we can check whether it is the same client when the client is unlocked. Since we only consider the scenario of single-machine deployment of Redis, we do not consider fault tolerance for the time being.

Error example 1
A common error example is to use the combination of jedis.setnx() and jedis.expire() to implement locking. The code is as follows:

public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {
 
    Long result = jedis.setnx(lockKey, requestId);
    if (result == 1) {
        // If the program suddenly crashes here, the expiration time cannot be set, and a deadlock will occur
        jedis.expire(lockKey, expireTime);
    }
 
}

The function of the setnx() method is SET IF NOT EXIST, and the expire() method is to add an expiration time to the lock. At first glance, it seems to be the same as the result of the previous set() method. However, since these are two Redis commands, they are not atomic. If the program crashes suddenly after executing setnx(), the lock does not have an expiration time set. Then a deadlock will occur. The reason why someone implements this on the Internet is because the lower version of jedis does not support the multi-parameter set() method.

Error example 2
This kind of error example is more difficult to find, and the implementation is more complicated. Implementation idea: use the jedis.setnx() command to implement locking, where the key is the lock and the value is the expiration time of the lock. Execution process: 1. Try to lock through the setnx() method. If the current lock does not exist, it will return the lock successfully. 2. If the lock already exists, get the expiration time of the lock and compare it with the current time. If the lock has expired, set a new expiration time and return the lock success. code show as below:

public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) {
 
    long expires = System.currentTimeMillis() + expireTime;
    String expiresStr = String.valueOf(expires);
 
    // If the current lock does not exist, return the lock success
    if (jedis.setnx(lockKey, expiresStr) == 1) {
        return true;
    }
 
    // If the lock exists, the expiration time of the acquired lock
    String currentValueStr = jedis.get(lockKey);
    if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
        // The lock has expired, get the expiration time of the previous lock, and set the expiration time of the current lock
        String oldValueStr = jedis.getSet(lockKey, expiresStr);
        if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
            // Considering the case of multi-threaded concurrency, only one thread has the right to lock if the set value is the same as the current value
            return true;
        }
    }
        
    // In other cases, it will always return the lock failure
    return false;
 
}

So where is the problem with this code?

  1. Since the client generates the expiration time by itself, it is necessary to enforce that the time of each client in the distributed environment must be synchronized.
  2. When the lock expires, if multiple clients execute the jedis.getSet() method at the same time, then although only one client can lock, the expiration time of this client's lock may be overwritten by other clients.
  3. The lock does not have an owner identity, i.e. any client can unlock it.

unlock code

public class RedisTool {
 
    private static final Long RELEASE_SUCCESS = 1L;
 
    /**
     * release distributed lock
     * @param jedis Redis client
     * @param lockKey Lock
     * @param requestId Request ID
     * @return Whether the release is successful
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
 
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
 
        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;
 
    }
 
}

As you can see, we only need two lines of code to unlock it! The first line of code, we wrote a simple Lua script code, the last time I saw this programming language was in "Hackers and Painters", I didn't expect it to be used this time. In the second line of code, we pass the Lua code to the jedis.eval() method, and assign the parameter KEYS[1] to lockKey and ARGV[1] to requestId. The eval() method is to hand the Lua code to the Redis server for execution.

So what is the function of this Lua code? In fact, it is very simple. First, obtain the value corresponding to the lock, check whether it is equal to the requestId, and delete the lock (unlock) if it is equal. So why use Lua language to implement it? Because the above operation is guaranteed to be atomic. For what problems non-atomicity can bring, you can read [Unlock Code - Error Example 2]. So why the execution of the eval() method can ensure atomicity stems from the characteristics of Redis. The following is a partial explanation of the eval command on the official website:

Simply put, when the eval command executes the Lua code, the Lua code will be executed as a command, and Redis will not execute other commands until the eval command is executed.

Error example 1
The most common unlock code is to use the jedis.del() method to delete the lock directly. This method of unlocking the lock without first determining the owner of the lock will cause any client to unlock it at any time, even if the lock is not its own. .

public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
    jedis.del(lockKey);
}

Error example 2
At first glance, this unlocking code is no problem. Even I almost realized it before. It is similar to the correct posture. The only difference is that it is divided into two commands to execute. The code is as follows:

public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
        
    // Determine whether locking and unlocking are the same client
    if (requestId.equals(jedis.get(lockKey))) {
        // If at this time, the lock is suddenly not owned by this client, it will be unlocked by mistake.
        jedis.del(lockKey);
    }
 
}

As in the code comments, the problem is that if the jedis.del() method is called, the locks added by others will be released when the lock no longer belongs to the current client. So is there really such a scenario? The answer is yes. For example, client A locks, and after a period of time, client A unlocks. Before executing jedis.del(), the lock suddenly expires. At this time, client B tries to lock successfully, and then client A executes the The del() method releases the lock of client B.

Take a look at the code for the concrete implementation of the api using Jedis as the Redis client.

(1) First create a Redis connection pool.

public class RedisPool {
 
    private static JedisPool pool;//jedis connection pool
 
    private static int maxTotal = 20;//Maximum number of connections
 
    private static int maxIdle = 10;//Maximum number of idle connections
 
    private static int minIdle = 5;//Minimum number of idle connections
 
    private static boolean testOnBorrow = true;//Test connection availability when taking connections
 
    private static boolean testOnReturn = false;//Do not test connection availability when reconnecting
 
    static {
        initPool();//Initialize the connection pool
    }
 
    public static Jedis getJedis(){
        return pool.getResource();
    }
 
    public static void close(Jedis jedis){
        jedis.close();
    }
 
    private static void initPool(){
        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxTotal(maxTotal);
        config.setMaxIdle(maxIdle);
        config.setMinIdle(minIdle);
        config.setTestOnBorrow(testOnBorrow);
        config.setTestOnReturn(testOnReturn);
        config.setBlockWhenExhausted(true);
        pool = new JedisPool(config, "127.0.0.1", 6379, 5000, "liqiyao");
    }
}

(2) Encapsulate the api of Jedis, and encapsulate some operations needed to implement distributed locks.

public class RedisPoolUtil {
 
    private RedisPoolUtil(){}
 
    private static RedisPool redisPool;
 
    public static String get(String key){
        Jedis jedis = null;
        String result = null;
        try {
            jedis = RedisPool.getJedis();
            result = jedis.get(key);
        } catch (Exception e){
            e.printStackTrace();
        } finally {
            if (jedis != null) {
                jedis.close();
            }
            return result;
        }
    }
 
    public static Long setnx(String key, String value){
        Jedis jedis = null;
        Long result = null;
        try {
            jedis = RedisPool.getJedis();
            result = jedis.setnx(key, value);
        } catch (Exception e){
            e.printStackTrace();
        } finally {
            if (jedis != null) {
                jedis.close();
            }
            return result;
        }
    }
 
    public static String getSet(String key, String value){
        Jedis jedis = null;
        String result = null;
        try {
            jedis = RedisPool.getJedis();
            result = jedis.getSet(key, value);
        } catch (Exception e){
            e.printStackTrace();
        } finally {
            if (jedis != null) {
                jedis.close();
            }
            return result;
        }
    }
 
    public static Long expire(String key, int seconds){
        Jedis jedis = null;
        Long result = null;
        try {
            jedis = RedisPool.getJedis();
            result = jedis.expire(key, seconds);
        } catch (Exception e){
            e.printStackTrace();
        } finally {
            if (jedis != null) {
                jedis.close();
            }
            return result;
        }
    }
 
    public static Long del(String key){
        Jedis jedis = null;
        Long result = null;
        try {
            jedis = RedisPool.getJedis();
            result = jedis.del(key);
        } catch (Exception e){
            e.printStackTrace();
        } finally {
            if (jedis != null) {
                jedis.close();
            }
            return result;
        }
    }
}

(3) Distributed lock tool class

public class DistributedLockUtil {
 
    private DistributedLockUtil(){
    }
 
    public static boolean lock(String lockName){//lockName can be a shared variable name or a method name, mainly used to simulate lock information
        System.out.println(Thread.currentThread() + "Start trying to lock!");
        Long result = RedisPoolUtil.setnx(lockName, String.valueOf(System.currentTimeMillis() + 5000));
        if (result != null && result.intValue() == 1){
            System.out.println(Thread.currentThread() + "Locked successfully!");
            RedisPoolUtil.expire(lockName, 5);
            System.out.println(Thread.currentThread() + "Execute business logic!");
            RedisPoolUtil.del(lockName);
            return true;
        } else {
            String lockValueA = RedisPoolUtil.get(lockName);
            if (lockValueA != null && Long.parseLong(lockValueA) >= System.currentTimeMillis()){
                String lockValueB = RedisPoolUtil.getSet(lockName, String.valueOf(System.currentTimeMillis() + 5000));
                if (lockValueB == null || lockValueB.equals(lockValueA)){
                    System.out.println(Thread.currentThread() + "Locked successfully!");
                    RedisPoolUtil.expire(lockName, 5);
                    System.out.println(Thread.currentThread() + "Execute business logic!");
                    RedisPoolUtil.del(lockName);
                    return true;
                } else {
                    return false;
                }
            } else {
                return false;
            }
        }
    }
}

Summarize
This article mainly introduces how to use Java code to correctly implement Redis distributed locks, and also gives two classic error examples for locking and unlocking. In fact, it is not difficult to implement distributed locks through Redis, as long as the four conditions in reliability are guaranteed. Although the Internet has brought us convenience, as long as we have a question, we can google it, but must the answer on the Internet be correct? In fact, it is not, so we should always maintain the spirit of questioning, think more and verify more.

If Redis is deployed on multiple machines in your project, you can try to use Redisson to implement distributed locks, which is an official Java component provided by Redis.

Posted by psquillace on Thu, 05 May 2022 03:20:52 +0300