蹲厕所的熊

benjaminwhx

Redis分布式锁的正确实现姿势

2018-08-26 作者: 吴海旭


  1. 获取锁
    1. 错误姿势1
    2. 错误姿势2
    3. 错误姿势3
    4. 正确姿势
  2. 释放锁
    1. 错误姿势1
    2. 错误姿势2
    3. 正确姿势

分布式锁可以解决在分布式环境下的多资源竞争问题,常见的分布式锁实现有以下3种:

  • 基于数据库的唯一索引方式或乐观锁方式。
  • 基于Redis单线程特性的原子操作。
  • 基于Zookeeper的临时有序节点。

本文主要介绍Redis如何实现分布式锁的获取和解除以及实现的正确姿势是什么。

获取锁

错误姿势1

在介绍获取锁的正确姿势之前先来个错误姿势。大家都知道Redis的分布式锁是利用了Redis单线程的特性加上 SETNX 命令来实现的。而为什么还会加上一个 EXPIRE 命令是为了防止 SETNX 后key一直存在的问题。

SETNX key value:将key设置值为value,如果key不存在,这种情况下等同SET命令。当key存在时,什么也不做。SETNX是 SET if Not Exists 的简写。

如果key设置成功返回1,否则返回0。

EXPIRE key seconds:设置 key 的过期时间,超过时间后,将会自动删除该 key

如果成功设置过期时间返回1,否则返回0。

很容易的就能写出这样的代码:

/**
 * 获取分布式锁
 * @param key
 * @param timeout
 * @param timeUnit
 * @return
 */
public static boolean tryLock(String key, int timeout, TimeUnit timeUnit) {
    Long result = jedis.setnx(key, "CONSTANT_VALUE");
    if (result == 1L) {
        Long seconds = timeUnit.toSeconds(timeout);
        return jedis.expire(key, seconds.intValue()) == 1L;
    }
    return false;
}

细心的朋友很快发现了有这么几个问题:

  1. 由于 setnxexpire 是分开两步进行的操作,不具有原子性。如果客户端在执行完 setnx 后崩溃了,那么就没有机会执行 expire 了,导致它一直持有该锁。
  2. setnx 的value这里写死了,到时候解锁的时候就不知道是谁设置的key了,很容易锁被其他请求误解了。

错误姿势2

很多同学知道redis中的 pipeline 可以作为一个管道批量执行命令,错误的以为它的执行是原子的,以至于用它来结合 setnxexpire ,这其实也是不对的。

/**
 * 获取分布式锁
 * @param key
 * @param timeout
 * @param timeUnit
 * @return
 */
public static boolean tryLock(String key, int timeout, TimeUnit timeUnit) {
    // 不存在key
    if (!jedis.exists(key)) {
        Long seconds = timeUnit.toSeconds(timeout);
        List<Object> result = setnx(key, UUID.randomUUID().toString(), seconds.intValue());
        return Boolean.valueOf(result.get(0).toString()) &&
                Boolean.valueOf(result.get(1).toString());
    }
    return false;
}

private static List<Object> setnx(String key, String value, int seconds) {
    List<Object> result = null;
    try {
        Pipeline pipelined = jedis.pipelined();
        // 问题:setNX成功了后redis服务挂了 导致expire失败,一直死锁
        pipelined.setnx(key.getBytes(), value.getBytes());
        pipelined.expire(key.getBytes(), seconds);
        result = pipelined.syncAndReturnAll();
        return result;
    } catch (Exception e) {
        e.printStackTrace();
    }
    return result;
}

错误姿势3

这个用法在我第一次看见的时候觉得特别精妙,一般比较难以发现问题,而且实现也比较复杂。

实现思路也是利用了 setnx 命令来设置key,不同的地方在于它没有使用 expire 命令来设置过期时间,而在 setnx 的时候把过期时间当做value设置进去,下一次获取的时候比较value和当前时间来决定是否进行覆盖。

/**
 * 获取分布式锁
 * @param key
 * @param timeout
 * @param timeUnit
 * @return
 */
public static boolean tryLock(String key, int timeout, TimeUnit timeUnit) {
    long timeoutSecond = timeUnit.toSeconds(timeout);
    // 过期时间
    long expireTime = System.currentTimeMillis() + timeoutSecond;

    // 如果当前锁不存在,返回加锁成功
    if (jedis.setnx(key, String.valueOf(expireTime)) == 1) {
        return true;
    }

    String lastValue = jedis.get(key);
    if (lastValue != null && Long.parseLong(lastValue) < System.currentTimeMillis()) {
        // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
        String oldValue = jedis.getSet(key, String.valueOf(expireTime));
        if (oldValue != null && oldValue.equals(lastValue)) {
            // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁
            return true;
        }
    }
    return false;
}

其实仔细看,这段代码还是存在很多问题的:

  1. 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步,这一点的问题可以忽略。
  2. 当锁过期的时候,如果多个客户端同时执行 jedis.getSet() 方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。
  3. 锁不具备拥有者标识,即任何客户端都可以解锁。

正确姿势

那么获取锁的正确姿势究竟是什么呢?Redis在 2.6.12 版本开始,为 SET 命令增加了一系列选项:

SET key value [EX seconds][PX milliseconds][NX|XX]

  • EX seconds:设置指定的过期时间,单位秒。
  • PX milliseconds:设置指定的过期时间,单位毫秒。
  • NX:仅当key不存在时设置值。
  • XX:仅当key存在时设置值。

可以看出来,SET 命令的天然原子性完全可以取代 SETNXEXPIRE 命令。

/**
 * 获取分布式锁
 * @param key
 * @param uniqueId 请求的唯一值
 * @param seconds
 * @return
 */
public static boolean tryLock(String key, String uniqueId, int seconds) {
    return "OK".equals(jedis.set(key, uniqueId, "NX", "EX", seconds));
}

还在使用 2.6.12 版本之前的同学只能使用另一法宝:Lua脚本来保证原子性了。

/**
 * 获取分布式锁
 * @param key
 * @param uniqueId 请求的唯一值
 * @param seconds
 * @return
 */
public static boolean tryLock(String key, String uniqueId, int seconds) {
    String luaScript = "if redis.call('setnx', KEYS[1], KEYS[2]) == 1 then " +
            "redis.call('expire', KEYS[1], KEYS[3]) return 1 else return 0 end";
    List<String> keys = new ArrayList<>();
    keys.add(key);
    keys.add(uniqueId);
    keys.add(String.valueOf(seconds));
    Object result = jedis.eval(luaScript, keys, new ArrayList<String>());
    return result.equals(1L);
}

释放锁

错误姿势1

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

/**
 * 释放分布式锁
 * @param key
 */
public static void releaseLock(String key) {
    jedis.del(key);
}

错误姿势2

上面已经说过这种写法的 getdel 没有在一个原子操作中。

/**
 * 释放分布式锁
 * @param key
 * @param uniqueId
 */
public static void releaseLock(String key, String uniqueId) {
    if (uniqueId.equals(jedis.get(key))) {
        jedis.del(key);
    }
}

正确姿势

同样的,释放锁时设计到多个命令要想保持原子性必须得使用Lua脚本。

/**
 * 释放分布式锁
 * @param key
 * @param uniqueId
 */
public static boolean releaseLock(String key, String uniqueId) {
    String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "return redis.call('del', KEYS[1]) else return 0 end";
    return jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(uniqueId)).equals(1L);
}


坚持原创技术分享,您的支持将鼓励我继续创作!



分享

评论