redis — Redis分布式锁之Redlock(五)

1、引言

说到Redis分布式锁大部分人都会想到:setnx+lua,或者知道set key value px milliseconds nx。这种实现方式有3大要点(也是面试概率非常高的地方):

  • set命令要用set key value px milliseconds nx;
  • value要具有唯一性;
  • 释放锁时要验证value值,不能误解锁;

事实上这类琐最大的缺点就是它加锁时只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:

  • 在Redis的master节点上拿到了锁;
  • 但是这个加锁的key还没有同步到slave节点;
  • master故障,发生故障转移,slave节点升级为master节点;
  • 导致锁丢失。

正因为如此,Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock。

2、Redlock算法

在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。

为了取到锁,客户端应该执行以下操作:

  • 获取当前Unix时间,以毫秒为单位。
  • 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
  • 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
  • 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
  • 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。

3、Redisson

Redisson 是用于在 Java 程序中操作 Redis 的库,它使得我们可以在程序中轻松地使用 Redis。Redisson 在 java.util 中常用接口的基础上,为我们提供了一系列具有分布式特性的工具类。下文主要对其分布式锁进行介绍,其他特性暂时未涉及。

redisson已经有对redlock算法封装,接下来对其用法进行简单介绍
添加POM依赖:

1
2
3
4
5
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.3.2</version>
</dependency>

首先,我们来看一下redission封装的redlock算法实现的分布式锁用法,非常简单,跟重入锁(ReentrantLock)有点类似:
在这里插入图片描述
在这里插入图片描述

  • 唯一ID
    实现分布式锁的一个非常重要的点就是set的value要具有唯一性,redisson的value是怎样保证value的唯一性呢?答案是UUID+threadId。入口在redissonClient.getLock(“REDLOCK_KEY”),源码在Redisson.java和RedissonLock.java中:
    在这里插入图片描述
  • 获取锁
    获取锁的代码为redLock.tryLock()或者redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS),两者的最终核心源码都是下面这段代码,只不过前者获取锁的默认租约时间(leaseTime)是LOCK_EXPIRATION_INTERVAL_SECONDS,即30s:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
      internalLockLeaseTime = unit.toMillis(leaseTime);
      return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                 "if (redis.call('exists', KEYS[1]) == 0) then " + //如果锁名称不存在
                     "redis.call('hset', KEYS[1], ARGV[2], 1); " +//则向redis中添加一个key为test_lock的set,并且向set中添加一个field为线程id,值=1的键值对,表示此线程的重入次数为1
                     "redis.call('pexpire', KEYS[1], ARGV[1]); " +//设置set的过期时间,防止当前服务器出问题后导致死锁,return nil; end;返回nil 结束
                     "return nil; " +
                 "end; " +
                 "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +//如果锁是存在的,检测是否是当前线程持有锁,如果是当前线程持有锁
                     "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +//则将该线程重入的次数++
                     "redis.call('pexpire', KEYS[1], ARGV[1]); " +//并且重新设置该锁的有效时间
                     "return nil; " + //返回nil,结束
                 "end; " +
                 "return redis.call('pttl', KEYS[1]);", //锁存在, 但不是当前线程加的锁,则返回锁的过期时间
         Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
    }
  • 释放锁
    释放锁的代码为redLock.unlock(),核心源码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Override
public void unlock() {
    Boolean opStatus = commandExecutor.evalWrite(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                    "if (redis.call('exists', KEYS[1]) == 0) then " +//如果锁已经不存在(可能是因为过期导致不存在,也可能是因为已经解锁)
                        "redis.call('publish', KEYS[2], ARGV[1]); " +//则发布锁解除的消息
                        "return 1; " + //返回1结束
                    "end;" +
                    "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " + //如果锁存在,但是若果当前线程不是加锁的线
                        "return nil;" + //则直接返回nil 结束
                    "end; " +
                    "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " + //如果是锁是当前线程所添加,定义变量counter,表示当前线程的重入次数-1,即直接将重入次数-1
                    "if (counter > 0) then " + //如果重入次数大于0,表示该线程还有其他任务需要执行
                        "redis.call('pexpire', KEYS[1], ARGV[2]); " + //则重新设置该锁的有效时间
                        "return 0; " + //返回0结束
                    "else " +
                        "redis.call('del', KEYS[1]); " + //否则表示该线程执行结束,删除该锁
                        "redis.call('publish', KEYS[2], ARGV[1]); " + //并且发布该锁解除的消息
                        "return 1; "+ //返回1结束
                    "end; " +
                    "return nil;", //其他情况返回nil并结束
                    Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(Thread.currentThread().getId()));
    if (opStatus == null) {
        throw new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
                + id + " thread-id: " + Thread.currentThread().getId());
    }
    if (opStatus) {
        cancelExpirationRenewal();
    }
}

RedLock 这么牛逼算法,还是不完善的,使用 DDIA 作者提出的问题分析下:
在这里插入图片描述
案例1

  • ClientA 获取锁,发生了 GC,超过超时时间,Redis 释放锁
  • ClientB 获取到锁,此时 ClientA 唤醒,两个客户端都获取到锁
  • ClientB 执行了 Read-Write-Update 模型之后 ClientA 再次覆盖了 ClientB 的数据,造成了数据错误

案例2

  • clientA 获取到 A,B,C 三个节点,由于网络故障,无法访问 D,E 节点
  • 由于 C 节点时钟向前偏移,导致锁过期
  • clientB 获取到 C,D,E,由于网络故障,无法访问 A,B 节点
  • 在此时,clientA 和 clientB 都获取到了锁

保险起见,必须由数据库进行兜底。