Redis distributed lock implementation

foreword

      There are generally three ways to implement distributed locks: 1. Database optimistic locks; 2. Redis-based distributed locks; 3. ZooKeeper-based distributed locks. This blog will introduce the second way to implement distributed locks based on Redis. Although there are various blogs on the Internet that introduce the implementation of Redis distributed locks, their implementations have various problems. In order to avoid misunderstandings, this blog will introduce in detail how to correctly implement Redis distributed locks.


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:   

    1. Mutual exclusion. At any time, only one client can hold the lock.
    2. No deadlock occurs. Even if a client crashes while holding the lock and does not actively unlock it, it is guaranteed that other clients can lock in the future.
    3. It is fault-tolerant. Clients can lock and unlock as long as most of the Redis nodes are up and running.
    4. The person who unlocks the bell must also be tied to the bell. Locking and unlocking must be done by the same client, and the client cannot unlock the locks added by others.


Code

First, we need to introduce Jedisopen source components through Maven, and pom.xmladd the following code to the file:

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

lock code

Talk is cheap, show me the code. Show the code first, and then take you to slowly explain why it is implemented like this:

publicclass RedisTool {
    privatestaticfinal String LOCK_SUCCESS = "OK";
    privatestaticfinal String SET_IF_NOT_EXIST = "NX";
    privatestaticfinal 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 whether the acquisition is 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. 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, do nothing; the fourth is expx, 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, which can ensure that if there is an existing key, the function will not be called successfully, that is, only one client can hold the lock, which satisfies mutual exclusion. Second, 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) due to the expiration time, and no deadlock will occur. Finally, because we assign the value to requestId, which represents the locked client request identifier, 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 jedis.setnx()and jedis.expire()combine to achieve 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) {
        // 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
        jedis.expire(lockKey, expireTime);
    }
}

      setnx()方法作用就是SET IF NOT EXIST,expire()方法就是给锁加一个过期时间。乍一看好像和前面的set()方法结果一样,然而由于这是两条Redis命令,不具有原子性,如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间。那么将会发生死锁。网上之所以有人这样实现,是因为低版本的jedis并不支持多参数的set()方法。

错误示例2

public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) {
    long expires = System.currentTimeMillis() + expireTime;
    String expiresStr = String.valueOf(expires);
    // 如果当前锁不存在,返回加锁成功
    if (jedis.setnx(lockKey, expiresStr) == 1) {
        return true;
    }
    // 如果锁存在,获取锁的过期时间
    String currentValueStr = jedis.get(lockKey);
    if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
        // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
        String oldValueStr = jedis.getSet(lockKey, expiresStr);
        if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
            // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁
            return true;
        }
    }        
    // 其他情况,一律返回加锁失败
    return false;
}

      这一种错误示例就比较难以发现问题,而且实现也比较复杂。实现思路:使用jedis.setnx()命令实现加锁,其中key是锁,value是锁的过期时间。执行过程:1. 通过setnx()方法尝试加锁,如果当前锁不存在,返回加锁成功。2. 如果锁已经存在则获取锁的过期时间,和当前时间比较,如果锁已经过期,则设置新的过期时间,返回加锁成功。那么这段代码问题在哪里?1. 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。 2. 当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。3. 锁不具备拥有者标识,即任何客户端都可以解锁。

解锁代码

还是先展示代码,再带大家慢慢解释为什么这样实现:

public class RedisTool {
    private static final Long RELEASE_SUCCESS = 1L;
    /**
     * 释放分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @return 是否释放成功
     */
    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;
    }
}

      可以看到,我们解锁只需要两行代码就搞定了!第一行代码,我们写了一个简单的Lua脚本代码,上一次见到这个编程语言还是在《黑客与画家》里,没想到这次居然用上了。第二行代码,我们将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。那么这段Lua代码的功能是什么呢?其实很简单,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。那么为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的。关于非原子性会带来什么问题,可以阅读【解锁代码-错误示例2】 。那么为什么执行eval()方法可以确保原子性,源于Redis的特性,简单来说,就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。

错误示例1

      最常见的解锁代码就是直接使用jedis.del()方法删除锁,这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它的。

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

错误示例2

这种解锁代码乍一看也是没问题,甚至我之前也差点这样实现,与正确姿势差不多,唯一区别的是分成两条命令去执行,代码如下:

public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {   
    // 判断加锁与解锁是不是同一个客户端
    if (requestId.equals(jedis.get(lockKey))) {
        // 若在此时,这把锁突然不是这个客户端的,则会误解锁
        jedis.del(lockKey);
    }
}

      如代码注释,问题在于如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。那么是否真的有这种场景?答案是肯定的,比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。


总结

      本文主要介绍了如何使用Java代码正确实现Redis分布式锁,对于加锁和解锁也分别给出了两个比较经典的错误示例。其实想要通过Redis实现分布式锁并不难,只要保证能满足可靠性里的四个条件。互联网虽然给我们带来了方便,只要有问题就可以google,然而网上的答案一定是对的吗?其实不然,所以我们更应该时刻保持着质疑精神,多想多验证。

原文地址:http://www.cnblogs.com/linjiqin/p/8003838.html

Guess you like

Origin http://43.154.161.224:23101/article/api/json?id=325723035&siteId=291194637