redis 分布式锁

转自 https://mp.weixin.qq.com/s/eHsuEc8Dq3h1Kz1uqBWsjA

分布式锁可以解决在分布式环境下的多资源竞争问题,常见的分布式锁实现有以下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。

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

  1. /**

  2. * 获取分布式锁

  3. * @param key

  4. * @param timeout

  5. * @param timeUnit

  6. * @return

  7. */

  8. public static boolean tryLock(String key, int timeout, TimeUnit timeUnit) {

  9.    Long result = jedis.setnx(key, "CONSTANT_VALUE");

  10.    if (result == 1L) {

  11.        Long seconds = timeUnit.toSeconds(timeout);

  12.        return jedis.expire(key, seconds.intValue()) == 1L;

  13.    }

  14.    return false;

  15. }

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

  1. 由于 setnx 和 expire 是分开两步进行的操作,不具有原子性。如果客户端在执行完 setnx 后崩溃了,那么就没有机会执行 expire 了,导致它一直持有该锁。

  2. setnx 的value这里写死了,到时候解锁的时候就不知道是谁设置的key了,很容易锁被其他请求误解了。

错误姿势2

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

  1. /**

  2. * 获取分布式锁

  3. * @param key

  4. * @param timeout

  5. * @param timeUnit

  6. * @return

  7. */

  8. public static boolean tryLock(String key, int timeout, TimeUnit timeUnit) {

  9.    // 不存在key

  10.    if (!jedis.exists(key)) {

  11.        Long seconds = timeUnit.toSeconds(timeout);

  12.        List<Object> result = setnx(key, UUID.randomUUID().toString(), seconds.intValue());

  13.        return Boolean.valueOf(result.get(0).toString()) &&

  14.                Boolean.valueOf(result.get(1).toString());

  15.    }

  16.    return false;

  17. }

  18.  

  19. private static List<Object> setnx(String key, String value, int seconds) {

  20.    List<Object> result = null;

  21.    try {

  22.        Pipeline pipelined = jedis.pipelined();

  23.        // 问题:setNX成功了后redis服务挂了 导致expire失败,一直死锁

  24.        pipelined.setnx(key.getBytes(), value.getBytes());

  25.        pipelined.expire(key.getBytes(), seconds);

  26.        result = pipelined.syncAndReturnAll();

  27.        return result;

  28.    } catch (Exception e) {

  29.        e.printStackTrace();

  30.    }

  31.    return result;

  32. }

错误姿势3

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

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

  1. /**

  2. * 获取分布式锁

  3. * @param key

  4. * @param timeout

  5. * @param timeUnit

  6. * @return

  7. */

  8. public static boolean tryLock(String key, int timeout, TimeUnit timeUnit) {

  9.    long timeoutSecond = timeUnit.toSeconds(timeout);

  10.    // 过期时间

  11.    long expireTime = System.currentTimeMillis() + timeoutSecond;

  12.  

  13.    // 如果当前锁不存在,返回加锁成功

  14.    if (jedis.setnx(key, String.valueOf(expireTime)) == 1) {

  15.        return true;

  16.    }

  17.  

  18.    String lastValue = jedis.get(key);

  19.    if (lastValue != null && Long.parseLong(lastValue) < System.currentTimeMillis()) {

  20.        // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间

  21.        String oldValue = jedis.getSet(key, String.valueOf(expireTime));

  22.        if (oldValue != null && oldValue.equals(lastValue)) {

  23.            // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁

  24.            return true;

  25.        }

  26.    }

  27.    return false;

  28. }

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

  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 命令。

  1. /**

  2. * 获取分布式锁

  3. * @param key

  4. * @param uniqueId 请求的唯一值

  5. * @param seconds

  6. * @return

  7. */

  8. public static boolean tryLock(String key, String uniqueId, int seconds) {

  9.    return "OK".equals(jedis.set(key, uniqueId, "NX", "EX", seconds));

  10. }

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

  1. /**

  2. * 获取分布式锁

  3. * @param key

  4. * @param uniqueId 请求的唯一值

  5. * @param seconds

  6. * @return

  7. */

  8. public static boolean tryLock(String key, String uniqueId, int seconds) {

  9.    String luaScript = "if redis.call('setnx', KEYS[1], KEYS[2]) == 1 then " +

  10.            "redis.call('expire', KEYS[1], KEYS[3]) return 1 else return 0 end";

  11.    List<String> keys = new ArrayList<>();

  12.    keys.add(key);

  13.    keys.add(uniqueId);

  14.    keys.add(String.valueOf(seconds));

  15.    Object result = jedis.eval(luaScript, keys, new ArrayList<String>());

  16.    return result.equals(1L);

  17. }

释放锁

错误姿势1

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

  1. /**

  2. * 释放分布式锁

  3. * @param key

  4. */

  5. public static void releaseLock(String key) {

  6.    jedis.del(key);

  7. }

错误姿势2

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

  1. /**

  2. * 释放分布式锁

  3. * @param key

  4. * @param uniqueId

  5. */

  6. public static void releaseLock(String key, String uniqueId) {

  7.    if (uniqueId.equals(jedis.get(key))) {

  8.        jedis.del(key);

  9.    }

  10. }

正确姿势

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

  1. /**

  2. * 释放分布式锁

  3. * @param key

  4. * @param uniqueId

  5. */

  6. public static boolean releaseLock(String key, String uniqueId) {

  7.    String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +

  8.            "return redis.call('del', KEYS[1]) else return 0 end";

  9.    return jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(uniqueId)).equals(1L);

  10. }

 

posted @ 2019-06-26 11:26  jiataoqin  阅读(151)  评论(0编辑  收藏  举报