目录

一、公平锁演示

二、公平锁实现原理

三、LUA脚本分析

1)、第一部分

2)、第二部分

3)、第三部分

4)、第四部分

5)、第五部分

6)、参数说明

四、公平锁加锁流程

1)、线程t1加锁

a、第一部分

b、第二部分

2)、线程t2加锁

a、第一部分

b、第二部分

c、第三部分

d、第四部分

e、第五部分

3)、线程t3加锁

a、第一部分

b、第二部分

c、第三部分

d、第四部分

e、第五部分

4)、线程t1释放锁,线程t2获取锁

a、第一部分

b、第二部分


【本篇文章基于redisson-3.17.6版本源码进行分析】

一、公平锁演示

基于Redis的Redisson分布式可重入公平锁也是实现了java.util.concurrent.locks.Lock接口的一种RLock对象。它保证了当多个Redisson客户端线程同时请求加锁时,优先分配给先发出请求的线程。所有请求线程会在一个队列中排队,当某个线程出现宕机时,Redisson会等待5秒后继续下一个线程,也就是说如果前面有5个线程都处于等待状态,那么后面的线程会等待至少25秒。

/**
 * 测试公平锁
 */
@Test
public void testFairLock() {
    Config config = new Config();
    config.useSingleServer().setAddress("redis://127.0.0.1:6379");
    RedissonClient redissonClient = Redisson.create(config);

    RLock fairLock = redissonClient.getFairLock("fairLock");

    // t1线程获取公平锁
    fairLock.lock();

    // 2s后,t2线程获取公平锁
    try {
        TimeUnit.SECONDS.sleep(2);

        new Thread(() -> {
            RLock fairLock2 = redissonClient.getFairLock("fairLock");
            fairLock2.lock();
            try {
                TimeUnit.SECONDS.sleep(30);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            fairLock2.unlock();
        }, "t2").start();

    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }

    // 4s后,t3线程获取公平锁
    try {
        TimeUnit.SECONDS.sleep(4);

        new Thread(() -> {
            RLock fairLock3 = redissonClient.getFairLock("fairLock");
            fairLock3.lock();
            try {
                TimeUnit.SECONDS.sleep(30);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            fairLock3.unlock();
        }, "t3").start();

    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }

    try {
        TimeUnit.SECONDS.sleep(60000);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
    fairLock.unlock();
}

下面一起看下Redisson中的公平锁是如何实现的?
 

二、公平锁实现原理

通过前面对Redisson可重入锁的学习,我们知道加锁的大概调用流程如下:
org.redisson.RedissonLock#lock()
        org.redisson.RedissonLock#lock(long, java.util.concurrent.TimeUnit, boolean)
                org.redisson.RedissonLock#tryAcquire
                        org.redisson.RedissonLock#tryAcquireAsync
                                org.redisson.RedissonLock#tryLockInnerAsync

在RedissonFairLock类中,它重写了tryLockInnerAsync()方法:

/**
 * 尝试获取公平锁
 * @param waitTime 未指定超时时间,为-1
 * @param leaseTime 锁默认超时时间:30000毫秒
 * @param unit 时间单位,毫秒
 * @param threadId 线程ID
 * @param command
 * @return
 * @param <T>
 */
@Override
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    // 线程默认等待时间:300000毫秒
    long wait = threadWaitTime;

    // 如有指定等待时间的话,则重新设置wait的值。在本例中,waitTime = -1
    if (waitTime > 0) {
        wait = unit.toMillis(waitTime);
    }

    // 获取当前系统时间戳
    long currentTime = System.currentTimeMillis();
    System.out.println(currentTime);

    // 根据不同的命令类型执行不同的LUA脚本
    // EVAL_NULL_BOOLEAN
    if (command == RedisCommands.EVAL_NULL_BOOLEAN) {
        return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
                // remove stale threads
                "while true do " +
                    "local firstThreadId2 = redis.call('lindex', KEYS[2], 0);" +
                    "if firstThreadId2 == false then " +
                        "break;" +
                    "end;" +
                    "local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2));" +
                    "if timeout <= tonumber(ARGV[3]) then " +
                        // remove the item from the queue and timeout set
                        // NOTE we do not alter any other timeout
                        "redis.call('zrem', KEYS[3], firstThreadId2);" +
                        "redis.call('lpop', KEYS[2]);" +
                    "else " +
                        "break;" +
                    "end;" +
                "end;" +

                "if (redis.call('exists', KEYS[1]) == 0) " +
                    "and ((redis.call('exists', KEYS[2]) == 0) " +
                        "or (redis.call('lindex', KEYS[2], 0) == ARGV[2])) then " +
                    "redis.call('lpop', KEYS[2]);" +
                    "redis.call('zrem', KEYS[3], ARGV[2]);" +

                    // decrease timeouts for all waiting in the queue
                    "local keys = redis.call('zrange', KEYS[3], 0, -1);" +
                    "for i = 1, #keys, 1 do " +
                        "redis.call('zincrby', KEYS[3], -tonumber(ARGV[4]), keys[i]);" +
                    "end;" +

                    "redis.call('hset', KEYS[1], ARGV[2], 1);" +
                    "redis.call('pexpire', KEYS[1], ARGV[1]);" +
                    "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;" +
                "end;" +
                "return 1;",
                Arrays.asList(getRawName(), threadsQueueName, timeoutSetName),
                unit.toMillis(leaseTime), getLockName(threadId), currentTime, wait);
    }

    // EVAL_LONG,本例中就是这种类型,重点关注这个
    if (command == RedisCommands.EVAL_LONG) {
        return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
                /**
                 * 第一部分: 死循环的作用主要是用于清理过期的等待线程,主要避免下面场景,避免无效客户端占用等待队列资源
                 * 1、获取锁失败,然后进入等待队列,但是网络出现问题,那么后续很有可能就不能继续正常获取锁了。
                 * 2、获取锁失败,然后进入等待队列,但是之后客户端所在服务器宕机了。
                 */

                // 开启死循环
                "while true do " +
                    // 通过lindex指令获取redisson_lock_queue:{fairLock}等待队列的第一个元素,也就是第一个等待的线程ID,如果存在,直接跳出循环
                    // lindex指令:返回List列表中下标为指定索引值的元素。 如果指定索引值不在列表的区间范围内,返回nil
                    "local firstThreadId2 = redis.call('lindex', KEYS[2], 0);" +
                    // 如果第一个等待的线程ID为空,说明等待队列为空,没有人在排队,则直接跳出循环
                    "if firstThreadId2 == false then " +
                        "break;" +
                    "end;" +
                    // 如果等待队列中第一个元素不为空(例如返回了LockName,即客户端UUID拼接线程ID),通过zscore指令从zset集合redisson_lock_timeout:{fairLock}中获取第一个等待线程ID对应的分数,其实就是超时时间戳
                    // zscore: 返回有序集中成员的分数值
                    "local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2));" +
                    // 如果超时时间戳 小于等于 当前时间的话,那么首先从超时集合中移除该节点,接着也在等待队列中弹出第一个节点
                    "if timeout <= tonumber(ARGV[4]) then " +
                        // a、通过zrem指令从redisson_lock_timeout:{fairLock}超时集合中删除第一个等待线程ID对应的元素
                        "redis.call('zrem', KEYS[3], firstThreadId2);" +
                        // b、通过lpop指令从redisson_lock_queue:{fairLock}等待队列中移除第一个等待线程ID对应的元素
                        "redis.call('lpop', KEYS[2]);" +
                    "else " +
                        // 如果超时时间戳 大于 当前时间,说明还没超时,则直接跳出循环
                        "break;" +
                    "end;" +
                "end;" +

                /**
                 * 第二部分: 检查是否可以获取锁
                 * 满足下面两个条件之一可以获取锁:
                 *  1、当前锁不存在(锁未被获取) and 等待队列不存在
                 *  2、当前锁不存在(锁未被获取) and 等待队列存在 and 等待队列中的第一个等待线程就是当前客户端当前线程
                 */

                // 通过exists指令判断当前锁是否存在
                // 通过exists指令判断redisson_lock_queue:{fairLock}等待队列是否存在
                // 判断redisson_lock_queue:{fairLock}等待队列第一个元素是否就是当前线程(当前线程在队首)
                "if (redis.call('exists', KEYS[1]) == 0) " +
                    "and ((redis.call('exists', KEYS[2]) == 0) " +
                        "or (redis.call('lindex', KEYS[2], 0) == ARGV[2])) then " +

                    // 从等待队列和超时集合中移除当前线程
                    "redis.call('lpop', KEYS[2]);" +
                    "redis.call('zrem', KEYS[3], ARGV[2]);" +

                    // 刷新超时集合中,其它等待线程的超时时间,减少300000毫秒超时时间,即更新它们的分数
                    // zrange redisson_lock_timeout:{fairLock} 0 -1: 返回整个zset集合所有元素
                    "local keys = redis.call('zrange', KEYS[3], 0, -1);" +
                    "for i = 1, #keys, 1 do " +
                        // 循环遍历,通过zincrby对redisson_lock_timeout:{fairLock}集合中指定成员的分数减去300000
                        // 减少等待队列中所有等待线程的超时时间
                        // todo:wsh 有客户端可以成功获取锁的时候,为什么要减少其它等待线程的超时时间?
                        // todo:wsh 因为这里的客户端都是调用 lock()方法,就是等待直到最后获取到锁;所以某个客户端可以成功获取锁的时候,要帮其他等待的客户端刷新一下等待时间,不然在分支一的死循环中就被干掉了?
                        "redis.call('zincrby', KEYS[3], - (ARGV[3]), keys[i]);" +
                    "end;" +

                    // 往加锁集合(map) myLock 中加入当前客户端当前线程,加锁次数为1,然后刷新 myLock 的过期时间
                    // 加锁同样使用的是hash数据结构,redis key = fairLock,  hash key = 【进程唯一ID + ":" + 线程ID】, hash value = 锁重入次数
                    "redis.call('hset', KEYS[1], ARGV[2], 1);" +
                    // 默认超时时间:30秒
                    "redis.call('pexpire', KEYS[1], ARGV[1]);" +
                    // 返回nil,表示获取锁成功,如果执行到这里,就return结束了,不会执行下面的第三、四、五部分
                    "return nil;" +
                "end;" +

                /**
                 * 第三部分: 检查锁是否已经被持有,公平锁重入
                 */

                // 通过hexists指令判断当前持有锁的线程是不是自己,如果是自己的锁,则执行重入,增加加锁次数,并且刷新锁的过期时间。
                "if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then " +
                    // 更新哈希数据结构中重入次数加一
                    "redis.call('hincrby', KEYS[1], ARGV[2], 1);" +
                    // 重新设置锁过期时间为30秒
                    "redis.call('pexpire', KEYS[1], ARGV[1]);" +
                    // 返回nil,表示锁重入成功,如果执行到这里,就return结束了,不会执行下面的第四、五部分
                    "return nil;" +
                "end;" +

                /**
                 * 第四部分: 检查线程是否已经在等待队列中,如果当前线程本就在等待队列中,返回等待时间
                 */

                // 利用 zscore 获取当前线程在超时集合中的超时时间
                "local timeout = redis.call('zscore', KEYS[3], ARGV[2]);" +
                // 不等于false, 说明当前线程在等待队列中才会执行if逻辑
                "if timeout ~= false then " +
                    // 真正的超时是队列中前一个线程的超时,但这大致正确,并且避免了遍历队列
                    // 返回实际的等待时间为:超时集合里的时间戳 - 300000毫秒 - 当前时间戳
                    // 如果执行到这里,就return结束了,不会执行下面的第五部分
                    "return timeout - tonumber(ARGV[3]) - tonumber(ARGV[4]);" +
                "end;" +

                /**
                 * 第五部分: 将线程添加到队列末尾,并在timeout set中设置其超时时间为队列中前一个线程的超时时间(如果队列为空则为锁的超时时间)加上threadWaitTime
                 */

                // 获取等待队列redisson_lock_queue:{fairLock}最后一个元素,即等待队列中最后一个等待的线程
                "local lastThreadId = redis.call('lindex', KEYS[2], -1);" +
                "local ttl;" +
                // 如果等待队列中最后的线程不为空且不是当前线程
                "if lastThreadId ~= false and lastThreadId ~= ARGV[2] then " +
                    // ttl = 最后一个等待线程在zset集合的分数 - 当前时间戳。 看最后一个线程还有多久超时
                    "ttl = tonumber(redis.call('zscore', KEYS[3], lastThreadId)) - tonumber(ARGV[4]);" +
                "else " +
                    // 如果等待队列中不存在其他的等待线程,直接返回锁key的过期时间
                    "ttl = redis.call('pttl', KEYS[1]);" +
                "end;" +
                // 计算锁超时时间 = ttl + 300000 + 当前时间戳
                "local timeout = ttl + tonumber(ARGV[3]) + tonumber(ARGV[4]);" +
                // 将当前线程添加到redisson_lock_timeout:{fairLock} 超时集合中,超时时间戳作为score分数,用来在有序集合中排序
                "if redis.call('zadd', KEYS[3], timeout, ARGV[2]) == 1 then " +
                    // 通过rpush将当前线程添加到redisson_lock_queue:{fairLock}等待队列中
                    "redis.call('rpush', KEYS[2], ARGV[2]);" +
                "end;" +
                // 返回ttl
                "return ttl;",
                Arrays.asList(getRawName(), threadsQueueName, timeoutSetName),
                unit.toMillis(leaseTime), getLockName(threadId), wait, currentTime);
    }

    throw new IllegalArgumentException();
}

可以看到,Redisson公平锁的加锁LUA脚本比较复杂,但是整体可以拆分为五个部分去分析。

三、LUA脚本分析

1)、第一部分

删除过期的等待线程。

这个死循环的作用主要用于清理过期的等待线程,主要避免下面场景,避免无效客户端占用等待队列资源。

  • a、获取锁失败,然后进入等待队列,但是网络出现问题,那么后续很有可能就不能继续正常获取锁了。
  • b、获取锁失败,然后进入等待队列,但是之后客户端所在服务器宕机了。

主要流程:

  • 1、开启死循环
  • 2、利用lindex 命令判断等待队列中第一个元素是否存在,如果存在,直接跳出循环
lindex redisson_lock_queue:{myLock} 0
  • 3、如果等待队列中第一个元素不为空(例如返回了LockName,即客户端UUID拼接线程ID),利用 zscore 在 超时记录集合(sorted set) 中获取对应的超时时间
zscore redisson_lock_timeout:{myLock} UUID:threadId
  • 4、如果超时时间已经小于当前时间,那么首先从超时集合中移除该节点,接着也在等待队列中弹出第一个节点
zrem redisson_lock_timeout:{myLock} UUID:threadId
lpop redisson_lock_queue:{myLock}
  • 5、如果等待队列中的第一个元素还未超时,直接退出死循环

2)、第二部分

检查现在是否可以获取锁。

场景:

  • 锁不存在
  • 等待队列为空
  • 等待队列不为空,并且等待队列中的第一个元素就是当前客户端当前线程

主要流程:

  • 1、当前锁还未被获取 and(等待队列不存在 or 等待队列的第一个元素是当前客户端当前线程)
exists myLock:判断锁是否存在

exists redisson_lock_queue:{myLock}:判断等待队列是否为空

lindex redisson_lock_timeout:{myLock} 0:获取等待队列中的第一个元素,用于判断是否等于当前客户端当前线程
  • 2、如果步骤1满足,从等待队列和超时集合中移除当前线程
lpop redisson_lock_queue:{myLock}:弹出等待队列中的第一个元素,即当前线程

zrem redisson_lock_timeout:{myLock} UUID:threadId:从超时集合中移除当前客户端当前线程
  • 3、刷新超时集合中,其他元素的超时时间,即更新他们得分数
zrange redisson_lock_timeout:{myLock} 0 -1:从超时集合中获取所有的元素

 遍历,然后执行下面命令更新分数,即超时时间:

zincrby redisson_lock_timeout:{myLock} -30w毫秒 keys[i]

因为这里的客户端都是调用 lock()方法,就是等待直到最后获取到锁;所以某个客户端可以成功获取锁的时候,要帮其他等待的客户端刷新一下等待时间,不然在分支一的死循环中就被干掉了。

  • 4、最后,往加锁集合(map) myLock 中加入当前客户端当前线程,加锁次数为1,然后刷新 myLock 的过期时间,返回nil
hset myLock UUID:threadId 1:将当前线程加入加锁记录中。
espire myLock 3w毫秒:重置锁的过期时间。

3)、第三部分

检查锁是否已经被持有,锁重入。

场景:

  • 当前线程已经成功获取过锁,现在重新再次获取锁。即:Redisson 的公平锁是支持可重入的。

主要流程:

  • 1、利用 hexists 命令判断加锁记录集合中,是否存在当前客户端当前线程
hexists myLock UUID:threadId
  • 2、如果存在,那么增加加锁次数,并且刷新锁的过期时间
hincrby myLock UUID:threadId 1:增加加锁次数

pexpire myLock 30000毫秒:刷新锁key的过期时间

4)、第四部分

检查线程是否已经在等待队列中。

  • 1、利用 zscore 获取当前线程在超时集合中的超时时间
zscore redisson_lock_timeout:{myLock} UUID:threadId
  • 2、返回实际的等待时间为:超时集合里的时间戳-30w毫秒-当前时间戳

5)、第五部分

将线程添加到队列末尾,并在timeout set中设置其超时时间为队列中前一个线程的超时时间(如果队列为空则为锁的超时时间)加上threadWaitTime。

主要流程:

  • 1、利用 lindex 命令获取等待队列中排在最后的线程
lindex redisson_lock_queue:{myLock} -1
  • 2、计算 ttl
  • 2-1:如果等待队列中最后的线程不为空且不是当前线程,根据此线程计算出ttl
zscore redisson_lock_timeout:{myLock} lastThreadId:获取等待队列中最后的线程得过期时间

ttl = timeout - 当前时间戳
  •       2-2:如果等待队列中不存在其他的等待线程,直接返回锁key的过期时间
ttl = pttl myLock
  • 3、计算timeout,并将当前线程放入超时集合和等待队列中
timeout = ttl + 30w毫秒 + 当前时间戳

zadd redisson_lock_timeout:{myLock} timeout UUID:threadId:放入超时集合

rpush redisson_lock_queue:{myLock} UUID:threadId:如果成功放入超市集合,同时放入等待队列
  •  4、最后返回ttl

6)、参数说明

先对LUA脚本中使用到的一些参数进行说明:

  • KEYS[1]: 我们指定的分布式锁的key,如本例中redissonClient.getFairLock("fairLock")的 "fairLock"
  • KEYS[2]: 加锁等待队列的名称,redisson_lock_queue:{分布式锁key}。如本例中为: redisson_lock_queue:{fairLock}
  • KEYS[3]: 等待队列中线程锁时间的set集合名称,redisson_lock_timeout:{分布式锁key},是按照锁的时间戳存放到集合中的。如本例中为: redisson_lock_timeout:{fairLock}
  • ARGV[1]: 锁的超时时间,本例中为锁默认超时时间:30000毫秒(30秒)
  • ARGV[2]: 【进程唯一ID + ":" + 线程ID】组合
  • ARGV[3]: 线程等待时间,默认为300000毫秒(300秒)
  • ARGV[4]: 当前系统时间戳

四、公平锁加锁流程

下面我们模拟三个不同的线程t1、t2、t3依次获取公平锁,下面是详细的分析过程。

1)、线程t1加锁

a、第一部分

// 开启死循环
"while true do " +
    // 通过lindex指令获取redisson_lock_queue:{fairLock}等待队列的第一个元素,也就是第一个等待的线程ID,如果存在,直接跳出循环
    // lindex指令:返回List列表中下标为指定索引值的元素。 如果指定索引值不在列表的区间范围内,返回nil
    "local firstThreadId2 = redis.call('lindex', KEYS[2], 0);" +
    // 如果第一个等待的线程ID为空,说明等待队列为空,没有人在排队,则直接跳出循环
    "if firstThreadId2 == false then " +
        "break;" +
    "end;" +
    // 如果等待队列中第一个元素不为空(例如返回了LockName,即客户端UUID拼接线程ID),通过zscore指令从zset集合redisson_lock_timeout:{fairLock}中获取第一个等待线程ID对应的分数,其实就是超时时间戳
    // zscore: 返回有序集中成员的分数值
    "local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2));" +
    // 如果超时时间戳 小于等于 当前时间的话,那么首先从超时集合中移除该节点,接着也在等待队列中弹出第一个节点
    "if timeout <= tonumber(ARGV[4]) then " +
        // a、通过zrem指令从redisson_lock_timeout:{fairLock}超时集合中删除第一个等待线程ID对应的元素
        "redis.call('zrem', KEYS[3], firstThreadId2);" +
        // b、通过lpop指令从redisson_lock_queue:{fairLock}等待队列中移除第一个等待线程ID对应的元素
        "redis.call('lpop', KEYS[2]);" +
    "else " +
        // 如果超时时间戳 大于 当前时间,说明还没超时,则直接跳出循环
        "break;" +
    "end;" +
"end;"

获取redisson_lock_queue:{fairLock}在List等待队列的第一个元素,也就是第一个等待的线程ID,刚开始,队列是空的,所以什么都获取不到,此时就会直接退出while true死循环。

b、第二部分

// 通过exists指令判断当前锁是否存在
// 通过exists指令判断redisson_lock_queue:{fairLock}等待队列是否存在
// 判断redisson_lock_queue:{fairLock}等待队列第一个元素是否就是当前线程(当前线程在队首)
"if (redis.call('exists', KEYS[1]) == 0) " +
    "and ((redis.call('exists', KEYS[2]) == 0) " +
        "or (redis.call('lindex', KEYS[2], 0) == ARGV[2])) then " +

    // 从等待队列和超时集合中移除当前线程
    "redis.call('lpop', KEYS[2]);" +
    "redis.call('zrem', KEYS[3], ARGV[2]);" +

    // 刷新超时集合中,其它等待线程的超时时间,减少300000毫秒超时时间,即更新它们的分数
    // zrange redisson_lock_timeout:{fairLock} 0 -1: 返回整个zset集合所有元素
    "local keys = redis.call('zrange', KEYS[3], 0, -1);" +
    "for i = 1, #keys, 1 do " +
        // 循环遍历,通过zincrby对redisson_lock_timeout:{fairLock}集合中指定成员的分数减去300000
        // 减少等待队列中所有等待线程的超时时间
        // todo:wsh 有客户端可以成功获取锁的时候,为什么要减少其它等待线程的超时时间?
        // todo:wsh 因为这里的客户端都是调用 lock()方法,就是等待直到最后获取到锁;所以某个客户端可以成功获取锁的时候,要帮其他等待的客户端刷新一下等待时间,不然在分支一的死循环中就被干掉了?
        "redis.call('zincrby', KEYS[3], - (ARGV[3]), keys[i]);" +
    "end;" +

    // 往加锁集合(map) myLock 中加入当前客户端当前线程,加锁次数为1,然后刷新 myLock 的过期时间
    // 加锁同样使用的是hash数据结构,redis key = fairLock,  hash key = 【进程唯一ID + ":" + 线程ID】, hash value = 锁重入次数
    "redis.call('hset', KEYS[1], ARGV[2], 1);" +
    // 默认超时时间:30秒
    "redis.call('pexpire', KEYS[1], ARGV[1]);" +
    // 返回nil,表示获取锁成功,如果执行到这里,就return结束了,不会执行下面的第三、四、五部分
    "return nil;" +
"end;"

通过exists指令判断当前锁【fairLock】是否存在,刚开始确实是没人加锁的,此时肯定是不存在的,所以exists fairLock返回0,并且等待队列redisson_lock_queue:{fairLock}此时也是空的,所以满足if条件,接着执行if中的具体逻辑:

  • lpop redisson_lock_queue:{fairLock},弹出队列的第一个元素,现在队列是空的,所以什么都不会干;
  • zrem redisson_lock_timeout:{fairLock} UUID:threadId,从set集合中删除threadId对应的元素,此时因为这个set集合是空的,所以什么都不会干;
  • zrange redisson_lock_timeout:{fairLock} 0 -1: 返回整个zset集合所有元素,因为zset集合此时是空的,所以什么都不会干;
  • hset fairLock UUID:threadId_01 1,线程t1加锁成功;
  • pexpire fairLock 30000:将这个锁key的生存时间设置为30000毫秒;

执行完第二部分的LUA脚本后,直接return了nil,表示获取锁成功,也就是说不会执行下面的第三、四、五部分,此时这个锁被线程t1持有着,在外层代码中,就会认为是加锁成功,此时就会开启一个watch dog看门狗定时调度的程序,每隔10秒判断一下,当前这个线程是否还对这个锁key持有着锁,如果是,则刷新锁key的生存时间为30000毫秒 (看门狗的具体流程跟Redisson可重入锁流程一致)。

2)、线程t2加锁

此时t1已经获取到了锁,如果t2来执行加锁逻辑,具体的代码逻辑是怎样执行的呢?

a、第一部分

// 开启死循环
"while true do " +
    // 通过lindex指令获取redisson_lock_queue:{fairLock}等待队列的第一个元素,也就是第一个等待的线程ID,如果存在,直接跳出循环
    // lindex指令:返回List列表中下标为指定索引值的元素。 如果指定索引值不在列表的区间范围内,返回nil
    "local firstThreadId2 = redis.call('lindex', KEYS[2], 0);" +
    // 如果第一个等待的线程ID为空,说明等待队列为空,没有人在排队,则直接跳出循环
    "if firstThreadId2 == false then " +
        "break;" +
    "end;" +
    // 如果等待队列中第一个元素不为空(例如返回了LockName,即客户端UUID拼接线程ID),通过zscore指令从zset集合redisson_lock_timeout:{fairLock}中获取第一个等待线程ID对应的分数,其实就是超时时间戳
    // zscore: 返回有序集中成员的分数值
    "local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2));" +
    // 如果超时时间戳 小于等于 当前时间的话,那么首先从超时集合中移除该节点,接着也在等待队列中弹出第一个节点
    "if timeout <= tonumber(ARGV[4]) then " +
        // a、通过zrem指令从redisson_lock_timeout:{fairLock}超时集合中删除第一个等待线程ID对应的元素
        "redis.call('zrem', KEYS[3], firstThreadId2);" +
        // b、通过lpop指令从redisson_lock_queue:{fairLock}等待队列中移除第一个等待线程ID对应的元素
        "redis.call('lpop', KEYS[2]);" +
    "else " +
        // 如果超时时间戳 大于 当前时间,说明还没超时,则直接跳出循环
        "break;" +
    "end;" +
"end;"

进入while true死循环,lindex redisson_lock_queue:{fairLock} 0,获取队列的第一个元素,此时队列还是空的,所以获取到的是false,直接退出while true死循环。

b、第二部分

// 通过exists指令判断当前锁是否存在
// 通过exists指令判断redisson_lock_queue:{fairLock}等待队列是否存在
// 判断redisson_lock_queue:{fairLock}等待队列第一个元素是否就是当前线程(当前线程在队首)
"if (redis.call('exists', KEYS[1]) == 0) " +
    "and ((redis.call('exists', KEYS[2]) == 0) " +
        "or (redis.call('lindex', KEYS[2], 0) == ARGV[2])) then " +

    // 从等待队列和超时集合中移除当前线程
    "redis.call('lpop', KEYS[2]);" +
    "redis.call('zrem', KEYS[3], ARGV[2]);" +

    // 刷新超时集合中,其它等待线程的超时时间,减少300000毫秒超时时间,即更新它们的分数
    // zrange redisson_lock_timeout:{fairLock} 0 -1: 返回整个zset集合所有元素
    "local keys = redis.call('zrange', KEYS[3], 0, -1);" +
    "for i = 1, #keys, 1 do " +
        // 循环遍历,通过zincrby对redisson_lock_timeout:{fairLock}集合中指定成员的分数减去300000
        // 减少等待队列中所有等待线程的超时时间
        // todo:wsh 有客户端可以成功获取锁的时候,为什么要减少其它等待线程的超时时间?
        // todo:wsh 因为这里的客户端都是调用 lock()方法,就是等待直到最后获取到锁;所以某个客户端可以成功获取锁的时候,要帮其他等待的客户端刷新一下等待时间,不然在分支一的死循环中就被干掉了?
        "redis.call('zincrby', KEYS[3], - (ARGV[3]), keys[i]);" +
    "end;" +

    // 往加锁集合(map) myLock 中加入当前客户端当前线程,加锁次数为1,然后刷新 myLock 的过期时间
    // 加锁同样使用的是hash数据结构,redis key = fairLock,  hash key = 【进程唯一ID + ":" + 线程ID】, hash value = 锁重入次数
    "redis.call('hset', KEYS[1], ARGV[2], 1);" +
    // 默认超时时间:30秒
    "redis.call('pexpire', KEYS[1], ARGV[1]);" +
    // 返回nil,表示获取锁成功,如果执行到这里,就return结束了,不会执行下面的第三、四、五部分
    "return nil;" +
"end;"

通过exists fairLock,因为t1正在持有fairLock这把锁,所以exists返回1,锁key已经存在了,说明已经有人加锁了,if条件肯定就不满足了,不会进入if里面,也就是说对于t2线程,第二部分的LUA啥都没做,接着看第三部分。

c、第三部分

// 通过hexists指令判断当前持有锁的线程是不是自己,如果是自己的锁,则执行重入,增加加锁次数,并且刷新锁的过期时间。
"if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then " +
    // 更新哈希数据结构中重入次数加一
    "redis.call('hincrby', KEYS[1], ARGV[2], 1);" +
    // 重新设置锁过期时间为30秒
    "redis.call('pexpire', KEYS[1], ARGV[1]);" +
    // 返回nil,表示锁重入成功,如果执行到这里,就return结束了,不会执行下面的第四、五部分
    "return nil;" +
"end;"

执行hexists fairLock UUID:threadId判断锁是不是自己的,很显然,当前锁被t1线程持有着,并不是自己(t2线程),所以不满足if条件,也不会进入if逻辑,继续执行第四部分。

d、第四部分

// 利用 zscore 获取当前线程在超时集合中的超时时间
"local timeout = redis.call('zscore', KEYS[3], ARGV[2]);" +
// 不等于false, 说明当前线程在等待队列中才会执行if逻辑
"if timeout ~= false then " +
    // 真正的超时是队列中前一个线程的超时,但这大致正确,并且避免了遍历队列
    // 返回实际的等待时间为:超时集合里的时间戳 - 300000毫秒 - 当前时间戳
    // 如果执行到这里,就return结束了,不会执行下面的第五部分
    "return timeout - tonumber(ARGV[3]) - tonumber(ARGV[4]);" +
"end;"

当前来获取锁的线程t2并不在zset集合redisson_lock_timeout:{fairLock}中,不满足if条件,不会进入if逻辑,继续执行第五部分。

e、第五部分

// 获取等待队列redisson_lock_queue:{fairLock}最后一个元素,即等待队列中最后一个等待的线程
"local lastThreadId = redis.call('lindex', KEYS[2], -1);" +
"local ttl;" +
// 如果等待队列中最后的线程不为空且不是当前线程
"if lastThreadId ~= false and lastThreadId ~= ARGV[2] then " +
// ttl = 最后一个等待线程在zset集合的分数 - 当前时间戳。 看最后一个线程还有多久超时
"ttl = tonumber(redis.call('zscore', KEYS[3], lastThreadId)) - tonumber(ARGV[4]);" +
"else " +
// 如果等待队列中不存在其他的等待线程,直接返回锁key的过期时间
"ttl = redis.call('pttl', KEYS[1]);" +
"end;" +
// 计算锁超时时间 = ttl + 300000 + 当前时间戳
"local timeout = ttl + tonumber(ARGV[3]) + tonumber(ARGV[4]);" +
// 将当前线程添加到redisson_lock_timeout:{fairLock} 超时集合中,超时时间戳作为score分数,用来在有序集合中排序
"if redis.call('zadd', KEYS[3], timeout, ARGV[2]) == 1 then " +
// 通过rpush将当前线程添加到redisson_lock_queue:{fairLock}等待队列中
"redis.call('rpush', KEYS[2], ARGV[2]);" +
"end;" +
// 返回ttl
"return ttl;"

tonumber() 是lua中自带的函数,tonumber会尝试将它的参数转换为数字。

执行lindex redisson_lock_queue:{fairLock} -1获取等待队列中最后一个等待的线程,此时等待队列还是空的,所以计算ttl = pttl fairLock,即ttl = 当前锁fairLock的剩余过期时间 = 28000毫秒

所以timeout = ttl + 300000毫秒 + 当前时间 = 28000毫秒 + 300000毫秒 + 当前时间 = 1663143100976 = 2022-09-14 16:11:40

接着执行: zadd redisson_lock_timeout:{fairLock} 1663143100976 d23e0d6b-437c-472c-9c9d-2147907ab8f9:47

在zset集合中插入一个元素,元素的值是d23e0d6b-437c-472c-9c9d-2147907ab8f9:47,对应的分数是1663143100976(会用这个时间的long型的一个时间戳来表示这个时间,时间越靠后,时间戳就越大),sorted set,有序set集合,他会自动根据你插入的元素的分数从小到大来进行排序。

继续执行: rpush redisson_lock_queue:{fairLock} d23e0d6b-437c-472c-9c9d-2147907ab8f9:47 就是将d23e0d6b-437c-472c-9c9d-2147907ab8f9:47插入到等待队列的头部,也就是说t2线程进入等待队列中。

执行到这里,线程t2成功加入等待队列和zset超时集合中。

3)、线程t3加锁

经过前面的步骤,线程t1还持有着fairLock锁,线程t2已经进入等待队列redisson_lock_queue:{fairLock}中,此时线程t3进来获取锁,具体的代码逻辑是怎样执行的呢?

a、第一部分

// 开启死循环
"while true do " +
    // 通过lindex指令获取redisson_lock_queue:{fairLock}等待队列的第一个元素,也就是第一个等待的线程ID,如果存在,直接跳出循环
    // lindex指令:返回List列表中下标为指定索引值的元素。 如果指定索引值不在列表的区间范围内,返回nil
    "local firstThreadId2 = redis.call('lindex', KEYS[2], 0);" +
    // 如果第一个等待的线程ID为空,说明等待队列为空,没有人在排队,则直接跳出循环
    "if firstThreadId2 == false then " +
        "break;" +
    "end;" +
    // 如果等待队列中第一个元素不为空(例如返回了LockName,即客户端UUID拼接线程ID),通过zscore指令从zset集合redisson_lock_timeout:{fairLock}中获取第一个等待线程ID对应的分数,其实就是超时时间戳
    // zscore: 返回有序集中成员的分数值
    "local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2));" +
    // 如果超时时间戳 小于等于 当前时间的话,那么首先从超时集合中移除该节点,接着也在等待队列中弹出第一个节点
    "if timeout <= tonumber(ARGV[4]) then " +
        // a、通过zrem指令从redisson_lock_timeout:{fairLock}超时集合中删除第一个等待线程ID对应的元素
        "redis.call('zrem', KEYS[3], firstThreadId2);" +
        // b、通过lpop指令从redisson_lock_queue:{fairLock}等待队列中移除第一个等待线程ID对应的元素
        "redis.call('lpop', KEYS[2]);" +
    "else " +
        // 如果超时时间戳 大于 当前时间,说明还没超时,则直接跳出循环
        "break;" +
    "end;" +
"end;"

while true死循环,执行lindex redisson_lock_queue:{fairLock} 0,获取等待队列中的第一个元素d23e0d6b-437c-472c-9c9d-2147907ab8f9:47,代表的是这个t2线程正在队列里排队。

执行zscore redisson_lock_timeout:{fairLock} d23e0d6b-437c-472c-9c9d-2147907ab8f9:47,从zset有序集合中获取d23e0d6b-437c-472c-9c9d-2147907ab8f9:47对应的分数,也就是对应的过时时间,timeout = 1663143100976 。

接着判断timeout是否小于等于当前时间,显然条件不成立,退出死循环,继续执行第二部分。

b、第二部分

// 通过exists指令判断当前锁是否存在
// 通过exists指令判断redisson_lock_queue:{fairLock}等待队列是否存在
// 判断redisson_lock_queue:{fairLock}等待队列第一个元素是否就是当前线程(当前线程在队首)
"if (redis.call('exists', KEYS[1]) == 0) " +
    "and ((redis.call('exists', KEYS[2]) == 0) " +
        "or (redis.call('lindex', KEYS[2], 0) == ARGV[2])) then " +

    // 从等待队列和超时集合中移除当前线程
    "redis.call('lpop', KEYS[2]);" +
    "redis.call('zrem', KEYS[3], ARGV[2]);" +

    // 刷新超时集合中,其它等待线程的超时时间,减少300000毫秒超时时间,即更新它们的分数
    // zrange redisson_lock_timeout:{fairLock} 0 -1: 返回整个zset集合所有元素
    "local keys = redis.call('zrange', KEYS[3], 0, -1);" +
    "for i = 1, #keys, 1 do " +
        // 循环遍历,通过zincrby对redisson_lock_timeout:{fairLock}集合中指定成员的分数减去300000
        // 减少等待队列中所有等待线程的超时时间
        // todo:wsh 有客户端可以成功获取锁的时候,为什么要减少其它等待线程的超时时间?
        // todo:wsh 因为这里的客户端都是调用 lock()方法,就是等待直到最后获取到锁;所以某个客户端可以成功获取锁的时候,要帮其他等待的客户端刷新一下等待时间,不然在分支一的死循环中就被干掉了?
        "redis.call('zincrby', KEYS[3], - (ARGV[3]), keys[i]);" +
    "end;" +

    // 往加锁集合(map) myLock 中加入当前客户端当前线程,加锁次数为1,然后刷新 myLock 的过期时间
    // 加锁同样使用的是hash数据结构,redis key = fairLock,  hash key = 【进程唯一ID + ":" + 线程ID】, hash value = 锁重入次数
    "redis.call('hset', KEYS[1], ARGV[2], 1);" +
    // 默认超时时间:30秒
    "redis.call('pexpire', KEYS[1], ARGV[1]);" +
    // 返回nil,表示获取锁成功,如果执行到这里,就return结束了,不会执行下面的第三、四、五部分
    "return nil;" +
"end;"

通过exists fairLock,因为t1正在持有fairLock这把锁,所以exists返回1,锁key已经存在了,说明已经有人加锁了,if条件肯定就不满足了,不会进入if里面,也就是说对于t3线程,第二部分的LUA啥都没做,接着看第三部分。

c、第三部分

// 通过hexists指令判断当前持有锁的线程是不是自己,如果是自己的锁,则执行重入,增加加锁次数,并且刷新锁的过期时间。
"if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then " +
    // 更新哈希数据结构中重入次数加一
    "redis.call('hincrby', KEYS[1], ARGV[2], 1);" +
    // 重新设置锁过期时间为30秒
    "redis.call('pexpire', KEYS[1], ARGV[1]);" +
    // 返回nil,表示锁重入成功,如果执行到这里,就return结束了,不会执行下面的第四、五部分
    "return nil;" +
"end;"

 执行hexists fairLock UUID:threadId判断锁是不是自己的,很显然,当前锁被t1线程持有着,并不是自己(t3线程),所以不满足if条件,也不会进入if逻辑,继续执行第四部分。

d、第四部分

// 利用 zscore 获取当前线程在超时集合中的超时时间
"local timeout = redis.call('zscore', KEYS[3], ARGV[2]);" +
// 不等于false, 说明当前线程在等待队列中才会执行if逻辑
"if timeout ~= false then " +
    // 真正的超时是队列中前一个线程的超时,但这大致正确,并且避免了遍历队列
    // 返回实际的等待时间为:超时集合里的时间戳 - 300000毫秒 - 当前时间戳
    // 如果执行到这里,就return结束了,不会执行下面的第五部分
    "return timeout - tonumber(ARGV[3]) - tonumber(ARGV[4]);" +
"end;"

当前来获取锁的线程t3并不在zset集合redisson_lock_timeout:{fairLock}中,不满足if条件,不会进入if逻辑,继续执行第五部分。

e、第五部分

// 获取等待队列redisson_lock_queue:{fairLock}最后一个元素,即等待队列中最后一个等待的线程
"local lastThreadId = redis.call('lindex', KEYS[2], -1);" +
"local ttl;" +
// 如果等待队列中最后的线程不为空且不是当前线程
"if lastThreadId ~= false and lastThreadId ~= ARGV[2] then " +
// ttl = 最后一个等待线程在zset集合的分数 - 当前时间戳。 看最后一个线程还有多久超时
"ttl = tonumber(redis.call('zscore', KEYS[3], lastThreadId)) - tonumber(ARGV[4]);" +
"else " +
// 如果等待队列中不存在其他的等待线程,直接返回锁key的过期时间
"ttl = redis.call('pttl', KEYS[1]);" +
"end;" +
// 计算锁超时时间 = ttl + 300000 + 当前时间戳
"local timeout = ttl + tonumber(ARGV[3]) + tonumber(ARGV[4]);" +
// 将当前线程添加到redisson_lock_timeout:{fairLock} 超时集合中,超时时间戳作为score分数,用来在有序集合中排序
"if redis.call('zadd', KEYS[3], timeout, ARGV[2]) == 1 then " +
// 通过rpush将当前线程添加到redisson_lock_queue:{fairLock}等待队列中
"redis.call('rpush', KEYS[2], ARGV[2]);" +
"end;" +
// 返回ttl
"return ttl;"

执行lindex redisson_lock_queue:{fairLock} -1,获取等待队列最后一个等待的线程,此时,t2正在等待队列中,于是获取到的lastThreadId = d23e0d6b-437c-472c-9c9d-2147907ab8f9:47。

接着执行ttl = tonumber(redis.call('zscore', KEYS[3], lastThreadId)) - tonumber(ARGV[4]) 。

所以timeout = ttl + 300000毫秒 + 当前时间 = 1663143400976

将t3线程放入到队列和有序集合中:

zadd redisson_lock_timeout:{fairLock} 1663143400976 d23e0d6b-437c-472c-9c9d-2147907ab8f9:49

rpush redisson_lock_queue:{fairLock} d23e0d6b-437c-472c-9c9d-2147907ab8f9:49。

执行到这里,线程t3也成功加入等待队列和zset超时集合中。

4)、线程t1释放锁,线程t2获取锁

上面已经知道了,多个线程加锁过程中实际会进行排队,根据加锁的时间来作为获取锁的优先级,如果此时t1释放了锁,来看下t2是如果获取锁的。

在Redisson中,如果线程获取锁失败时,有一个while(true)死循环,不断尝试去获取锁。也就是当线程t1释放锁后,线程t2还会不断尝试加锁,也就是还是不断执行前面说的LUA脚本。

a、第一部分

// 开启死循环
"while true do " +
    // 通过lindex指令获取redisson_lock_queue:{fairLock}等待队列的第一个元素,也就是第一个等待的线程ID,如果存在,直接跳出循环
    // lindex指令:返回List列表中下标为指定索引值的元素。 如果指定索引值不在列表的区间范围内,返回nil
    "local firstThreadId2 = redis.call('lindex', KEYS[2], 0);" +
    // 如果第一个等待的线程ID为空,说明等待队列为空,没有人在排队,则直接跳出循环
    "if firstThreadId2 == false then " +
        "break;" +
    "end;" +
    // 如果等待队列中第一个元素不为空(例如返回了LockName,即客户端UUID拼接线程ID),通过zscore指令从zset集合redisson_lock_timeout:{fairLock}中获取第一个等待线程ID对应的分数,其实就是超时时间戳
    // zscore: 返回有序集中成员的分数值
    "local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2));" +
    // 如果超时时间戳 小于等于 当前时间的话,那么首先从超时集合中移除该节点,接着也在等待队列中弹出第一个节点
    "if timeout <= tonumber(ARGV[4]) then " +
        // a、通过zrem指令从redisson_lock_timeout:{fairLock}超时集合中删除第一个等待线程ID对应的元素
        "redis.call('zrem', KEYS[3], firstThreadId2);" +
        // b、通过lpop指令从redisson_lock_queue:{fairLock}等待队列中移除第一个等待线程ID对应的元素
        "redis.call('lpop', KEYS[2]);" +
    "else " +
        // 如果超时时间戳 大于 当前时间,说明还没超时,则直接跳出循环
        "break;" +
    "end;" +
"end;"

通过lindex指令获取redisson_lock_queue:{fairLock}在List等待队列的第一个元素,因为此时t2、t3线程都在等待队列中,所以会执行zscore redisson_lock_timeout:{fairLock} d23e0d6b-437c-472c-9c9d-2147907ab8f9:47,从zset有序集合中获取d23e0d6b-437c-472c-9c9d-2147907ab8f9:47对应的分数,也就是对应的过时时间,timeout = 1663143100976 。

接着判断timeout是否小于等于当前时间,显然条件不成立,退出死循环,继续执行第二部分。

b、第二部分

// 通过exists指令判断当前锁是否存在
// 通过exists指令判断redisson_lock_queue:{fairLock}等待队列是否存在
// 判断redisson_lock_queue:{fairLock}等待队列第一个元素是否就是当前线程(当前线程在队首)
"if (redis.call('exists', KEYS[1]) == 0) " +
    "and ((redis.call('exists', KEYS[2]) == 0) " +
        "or (redis.call('lindex', KEYS[2], 0) == ARGV[2])) then " +

    // 从等待队列和超时集合中移除当前线程
    "redis.call('lpop', KEYS[2]);" +
    "redis.call('zrem', KEYS[3], ARGV[2]);" +

    // 刷新超时集合中,其它等待线程的超时时间,减少300000毫秒超时时间,即更新它们的分数
    // zrange redisson_lock_timeout:{fairLock} 0 -1: 返回整个zset集合所有元素
    "local keys = redis.call('zrange', KEYS[3], 0, -1);" +
    "for i = 1, #keys, 1 do " +
        // 循环遍历,通过zincrby对redisson_lock_timeout:{fairLock}集合中指定成员的分数减去300000
        // 减少等待队列中所有等待线程的超时时间
        // todo:wsh 有客户端可以成功获取锁的时候,为什么要减少其它等待线程的超时时间?
        // todo:wsh 因为这里的客户端都是调用 lock()方法,就是等待直到最后获取到锁;所以某个客户端可以成功获取锁的时候,要帮其他等待的客户端刷新一下等待时间,不然在分支一的死循环中就被干掉了?
        "redis.call('zincrby', KEYS[3], - (ARGV[3]), keys[i]);" +
    "end;" +

    // 往加锁集合(map) myLock 中加入当前客户端当前线程,加锁次数为1,然后刷新 myLock 的过期时间
    // 加锁同样使用的是hash数据结构,redis key = fairLock,  hash key = 【进程唯一ID + ":" + 线程ID】, hash value = 锁重入次数
    "redis.call('hset', KEYS[1], ARGV[2], 1);" +
    // 默认超时时间:30秒
    "redis.call('pexpire', KEYS[1], ARGV[1]);" +
    // 返回nil,表示获取锁成功,如果执行到这里,就return结束了,不会执行下面的第三、四、五部分
    "return nil;" +
"end;"

通过exists fairLock,因为t1已经释放了fairLock这把锁,所以exists返回0,锁key不存在,说明目前还没有人加锁,第一个条件【(redis.call('exists', KEYS[1]) == 0)】成立。

等待队列redisson_lock_queue:{fairLock}此时也不为空,所以第二个条件【(redis.call('exists', KEYS[2]) == 0)】不成立,但是后面是or关联的判断,通过lindex判断等待队列中的第一个元素是否为当前请求的线程,刚刚好,目前排在队首的就是t2线程,所以整个条件成立,进入if逻辑:

  • 从redisson_lock_queue:{fairLock}队列和redisson_lock_timeout:{fairLock}超时集合中删除该线程;
  • 减少队列中所有等待线程的超时时间;
  • 加锁同样使用的是hash数据结构,redis key = fairLock, hash key = 【进程唯一ID + ":" + 线程ID】, hash value = 锁重入次数;