5.【Redisson源码】公平锁加锁流程

目录

一、公平锁演示

二、公平锁实现原理

三、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 = 锁重入次数;

猜你喜欢

转载自blog.csdn.net/Weixiaohuai/article/details/128498478
今日推荐