分布式锁之基于Redis的分布式锁

一、简述

       分布式锁一般有三种实现方式:第一,数据库乐观锁;第二,基于Redis的分布式锁;第三,基于Zookeeper的分布式锁。目前,在项目中有需要用到分布式锁的场景,因此学习并总结了。今天,咱们先来聊聊基于Redis的分布式锁。

       要保证基于Redis的分布式锁可用,必须同时满足以下四个条件:1、互斥性:在任何时刻只能有一个客户端持有锁;2、避免死锁:即使有一个客户端在持锁阶段出现崩溃而没有主动释放锁,也要保证后续其他客户端能加锁;3、具有容错性:只要大部分Redis的节点能正常运行,客户端就可以加锁和解锁;4、唯一性:客户端在加锁和解锁的过程中,必须只能是同一个客户端,客户端自己不能把别的客户端的锁解了。以上这四点,称之为可靠性。

二、简单示例

1、maven依赖

<properties>
    <jedis.version>2.9.0</jedis.version>
</properties>
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>${jedis.version}</version>
</dependency>

 2、RedisTool工具类

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";
    private static final Long RELEASE_SUCCESS = 1L;
    /**
     * 尝试获取分布式锁
     *
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @param expireTime 超时时间
     * @return
     */
    public static boolean tryGetLock(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;
    }

    /**
     * 释放分布式锁
     *
     * @param jedis Jedis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @return
     */
    public static boolean releaseLock(Jedis jedis, String lockKey, String requestId){
         String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
         Object object = jedis.eval(luaScript);
         if (RELEASE_SUCCESS.equals(object)) {
            return true;
         }
         return false;
    }
}

       从加锁的方法中可以看出,加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time),这个set方法一共五个参数:

  • key:使用key来作为锁,因为key是唯一的。
  • value:我们请求的是requestId,之所以有这个参数,就是根据可靠性的第四点,换言之,解铃还须系铃人。一般来说,我们会通过UUID.randomUUID().toString()的方式生成requestId。
  • nxxx:这个参数请求的是NX,即SET IF NOT EXIST。当key不存在时,进行set操作;若key已经存在,则不做任何操作。
  • expx:请求的是PX,就是给key设置一个过期的时间,具体时间由参数time决定。

上面的代码执行结果只有两种:1.当前没有锁,就进行加锁操作,并设置锁的过期时间,同时value设置为加锁的客户端;2.锁已经存在,不做任何操作。

三、加锁示例分析

        如果你认真阅读了前面的内容,你会发现,上面的示例并没有满足可靠性中的容错性。这是因为,容错性是在redis集群的环境下需要考虑的因素。而单机部署redis,容错性的优先级是最低的。

        在阅读团队中其他小伙伴写的关于redis实现分布式锁的代码中,发现了以下两种错误的实现方式,分别如下:

  • 错误示例一
public static void tryGetLockWithWrong(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()方法就是给锁加一个过期时间。然而,由于这两条redis命令不具备原子性,如果jedis.expire(String key, int expireTime)发生异常或出现崩溃,此方法设置的锁过期时间就不会生效,就会导致死锁的出现。expire源码片段如下:

//Jedis.java
public Long expire(final String key, final int seconds) {
    checkIsInMultiOrPipeline();
    client.expire(key, seconds);
    return client.getIntegerReply();
}

//Client.java
public void expire(final String key, final int seconds) {
    expire(SafeEncoder.encode(key), seconds);
}

//BinaryClient.java
public void expire(final byte[] key, final int seconds) {
    sendCommand(EXPIRE, key, toByteArray(seconds));
}

//Connection.java
protected Connection sendCommand(final Command cmd, final byte[]... args) {
    try {
      connect();
      Protocol.sendCommand(outputStream, cmd, args);
      pipelinedCommands++;
      return this;
    } catch (JedisConnectionException ex) {
      try {
        String errorMessage = Protocol.readErrorLineIfPossible(inputStream);
        if (errorMessage != null && errorMessage.length() > 0) {
          ex = new JedisConnectionException(errorMessage, ex.getCause());
        }
      } catch (Exception e) {
      }
      // Any other exceptions related to connection?
      broken = true;
      throw ex;
    }
  }

 之所以出现这种写法,是因为低版本的jedis不支持多参数的set方法。

  • 错误示例二
public static boolean getLockWithWrong(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. 锁不具备拥有者标识,即任何客户端都可以解锁。

四、解锁示例分析

       从解锁的方法中可以看出,解锁只需要两行代码:第一行代码,一个简单的Lua脚本代码;第二行代码,将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。

       引入lua脚本只是为了保证解锁操作的原子性(在eval命令执行Lua代码的时候,lua代码将被当成一个命令去执行,并且直到eval命令执行完成,redis才会执行其他命令)。逻辑很简单,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。

       在阅读团队中小伙伴解锁方法时,也存在两种错误的实现方式,分别如下:

  • 错误示例一
public static void releaseLockWithWrong(Jedis jedis, String lockKey) {
      jedis.del(lockKey);
}

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

  • 错误示例二
public static void wrongReleaseLock(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的锁给解除了。

五、总结

       基于Redis实现的分布式锁在实现的时候应该要多看多思考,而不能一味地盲目在网上找一找。

相关推荐