redis面试(十三)公平锁排队代码剖析

我们来看一下第二种redis分布式锁

第一种锁是可重入锁,非公平可重入锁,所谓的非公平可重入锁是什么意思呢?胡乱的争抢,根本没有任何公平性和顺序性可言

第二种锁,可重入锁,公平锁

通过公平锁,可以保证,客户端获取锁的顺序,就跟他们请求获取锁的顺序,是一样的,公平锁,排队,谁先申请获取这把锁,谁就可以先获取到这把锁,这个是按照顺序来的

会把各个客户端对加锁的请求进行排队处理,保证说先申请获取锁的,就先可以得到这把锁,实现所谓的公平性

可重入非公平锁、公平锁,他们在整体的技术实现上都是一样的,只不过唯一不同的一点就是在于加锁的逻辑那里

java 复制代码
RLock fairLock = redisson.getFairLock("anyLock");
fairLock.lock();
fairLock.unlock();

这个代码就是获取公平锁的方法。

RedissonFairLock是RedissonLock的子类,整体的锁的技术框架的实现,都是跟之前讲解的RedissonLock是一样的,无非就是重载了一些方法,加锁和释放锁的lua脚本的逻辑稍微复杂了一些,别的没什么特别的

第一个线程第一次加锁

我们来分析一下这个加锁的lua脚本

java 复制代码
if (command == RedisCommands.EVAL_LONG) {
    return commandExecutor.evalWriteAsync(getName(), 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[4]) then "
                + "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]); " +
                    "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; " +
                    
                "local firstThreadId = redis.call('lindex', KEYS[2], 0); " +
                "local ttl; " + 
                "if firstThreadId ~= false and firstThreadId ~= ARGV[2] then " + 
                    "ttl = tonumber(redis.call('zscore', KEYS[3], firstThreadId)) - tonumber(ARGV[4]);" + 
                "else "
                  + "ttl = redis.call('pttl', KEYS[1]);" + 
                "end; " + 
                    
                "local timeout = ttl + tonumber(ARGV[3]);" + 
                "if redis.call('zadd', KEYS[3], timeout, ARGV[2]) == 1 then " +
                    "redis.call('rpush', KEYS[2], ARGV[2]);" +
                "end; " +
                "return ttl;", 
                Arrays.<Object>asList(getName(), threadsQueueName, timeoutSetName), 
                            internalLockLeaseTime, getLockName(threadId), currentTime + threadWaitTime, currentTime);
}

首先,第一行while true do 进入一个while的死循环

第二行local firstThreadId2 = redis.call('lindex', KEYS[2], 0);

先看一下KEYS[2]这个参数是什么,也就是这部分lua脚本下面那个List里面第二个参数,第一个是getName(),不用想肯定是和我们传的"anyLock"有关,那第二个KEYS[2] = threadsQueueName = redisson_lock_queue:{anyLock},基于redis的数据结构实现的一个队列,第三个KEYS[3] = timeoutSetName = redisson_lock_timeout:{anyLock} 基于redis的数据结构实现的一个Set数据集合,有序集合,可以自动按照你给每个数据指定的一个分数(score)来进行排序

ARGV = internalLockLeaseTime, getLockName(threadId), currentTime

ARGV[1] = 30000毫秒

ARGV[2] = UUID:threadId 与线程有关

ARGV[3] = 当前时间(10:00:00) + 5000毫秒 = 10:00:05

ARGV[4] = 当前时间(10:00:00)

再回到lua脚本 local firstThreadId2 = redis.call('lindex', KEYS[2], 0);

lindex 命令用于通过索引获取列表中的元素。也可以使用负数下标,以 -1 表示列表的最后一个元素, -2 表示列表的倒数第二个元素

那这行的意思就是从名为redisson_lock_queue:{anyLock} 的队列数组中弹出来下标为0的元素,也就是队列中的第一个元素

下一行,如果不存在的话,直接跳出while循环

java 复制代码
if firstThreadId2 == false then "
    + "break;"
+ "end;

那我们第一次加锁的时候,肯定是不存在的,所以往下看其他逻辑

这里有几个判断,第一个exists anyLock 这个锁是否存在,不存在,返回true

第二个和第三个是or

第二个exists redisson_lock_queue:{anyLock},队列是否存在,不存在,返回true

第三个lindex redisson_lock_queue:{anyLock} 弹出第一个元素,是否等于 UUID:threadId 这个是要返回false,但是第二和第三个判断 是or,所以第二第三只要有一个true就成立了

java 复制代码
if (redis.call('exists', KEYS[1]) == 0) and ((redis.call('exists', KEYS[2]) == 0) "
        + "or (redis.call('lindex', KEYS[2], 0) == ARGV[2])) then

那继续往下走

lpop redisson_lock_queue:{anyLock},弹出队列的第一个元素,现在队列是空的,所以什么都不会干

zrem redisson_lock_timeout:{anyLock} UUID:threadId,从set集合中删除threadId对应的元素,此时因为这个set集合是空的,所以什么都不会干

hset anyLock UUID:threadId 1,加锁,这和之前的加锁逻辑一样,加一个名字为anyLock的map结构,键值对key:value 为"UUID:threadId": 1

redis.call('pexpire', KEYS[1], ARGV[1]); 给这个锁设置过期时间,默认30s

返回一个nil,在外层代码中,就会认为是加锁成功,此时就会开启一个watchdog看门狗定时调度的程序,每隔10秒判断一下,当前这个线程是否还对这个锁key持有着锁,如果是,则刷新锁key的生存时间为30000毫秒

这就是公平锁的加锁原理

第二个线程第一次加锁

那这是第一次加锁,后面是怎么实现公平锁? 再来看一下

第二个线程来尝试加锁,首先也是进入while true死循环,lindex redisson_lock_queue:{anyLock} 0,获取队列的第一个元素,此时队列还是空的,所以获取到的是false,直接退出while true死循环

再次进入这个判断,这次就有些不一样了

'exists', anyLock == 0 此时anyLock锁已经存在了,所以这个条件肯定就不成立了

那进行下面的判断

if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then

这个是判断,在名为anyLock这个map锁的键值对中 有没有名为 "UUID-02:threadId-02" 的key,此时肯定也是不成立,因为现在就是这个线程第一次请求加锁的。

再往下就是排队的关键逻辑了,我们来分析一下

local firstThreadId = redis.call('lindex', KEYS[2], 0);

取出来队列中的第一个元素

if firstThreadId ~= false and firstThreadId ~= ARGV[2] then

这是判断取出来的元素不为空,此时不成立

所以else中的逻辑 ttl = redis.call('pttl', KEYS[1]);这个是获取 anyLock这个锁的剩余生存时间,假设是20000毫秒

继续往下local timeout = ttl + tonumber(ARGV[3]); 算出来 ttl + 当前时间 + 5000毫秒是什么时间

比如:当前是2023-01-01 10:00:00, 那么加上20000毫秒,再加 5000毫秒,结果就是10:00:25 的long型时间戳

if redis.call('zadd', KEYS[3], timeout, ARGV[2]) == 1 then

在set有序集合redisson_lock_timeout:{anyLock} 中,新增一个线程是 UUID-02:threadId-02的数据,排序权重是2023-01-01 10:00:25的long型时间戳 ,并且新增成功的话,

rpush', KEYS[2], ARGV[2]

在队列 redisson_lock_queue:{anyLock} 中也新增一个元素UUID-02:threadId-02的数据

最后返回一个anyLock的存活时间ttl,之前的逻辑还记得吧,如果加锁的时候返回有效期时间的话,也会进入一个while死循环不断地尝试加锁。重新执行lua脚本

后面的线程也是同理,timeout时间戳不断增大,有序集合redisson_lock_timeout:{anyLock} 中会按照这个权重自动排序,队列 redisson_lock_queue:{anyLock} 中也按照放入的顺序往后排。

第三个线程第一次加锁

这次进来这个lua脚本的时候就要进入这个逻辑中了

local firstThreadId2 = redis.call('lindex', KEYS[2], 0); 判断队列中第一个元素是否存在,上面已经放进去了,肯定是存在的,而且这是第二个线程的

local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2));

获取有序队列中,元素UUID-02:threadId-02的权重值。

if timeout <= tonumber(ARGV[4]) then

上面我们说了,这个权重值是2023-01-01 10:00:25的long型时间戳,那这里是判断当前时间的时间戳和这个相比。 意思就是,当前时间是否已经超过了2023-01-01 10:00:25。

这次我们先假设不成立,继续往下

exists', KEYS[1] == 0 肯定也是不成立,已经存在了,

此时队列中第一个元素是UUID-02:threadId-02

ARGV[2] 是UUID-03:threadId-03

local firstThreadId = redis.call('lindex', KEYS[2], 0);

那这里判断的两个条件成立

firstThreadId不等于空,并且不等于当前线程

if firstThreadId ~= false and firstThreadId ~= ARGV[2] then

这里获取的就是,第一个线程的权重时间戳-当前时间的时间戳,意思是,队列第一个线程还有多久会去竞争锁

然后再拿着这个时间差+当前时间+5s

这样一来,这个线程的权重在有序队列中,肯定是排在第一个线程后面的。

ttl = tonumber(redis.call('zscore', KEYS[3], firstThreadId)) - tonumber(ARGV[4]);

然后就是入队,排队

if redis.call('zadd', KEYS[3], timeout, ARGV[2]) == 1 then

redis.call('rpush', KEYS[2], ARGV[2]);

此时我们看一下情况

如果超过的话,理论上来说anyLock这个锁已经被释放掉了。

那就把元素UUID-02:threadId-02从 有序集合redisson_lock_timeout:{anyLock} 中移除

redis.call('zrem', KEYS[3], firstThreadId2);

队列redisson_lock_queue:{anyLock}中也把第一个元素删除

redis.call('lpop', KEYS[2]);

相关推荐
tatasix7 小时前
Go Redis实现排行榜
开发语言·redis·golang
2401_850410838 小时前
redis的持久化
数据库·redis·bootstrap
Java雪荷8 小时前
基于 Vant UI + Redisson BitSet 实现签到日历
java·redis·vue
xmh-sxh-13148 小时前
redis相关数据类型介绍
redis
TANGLONG2228 小时前
【初阶数据结构与算法】八大排序算法之归并排序与非比较排序(计数排序)
java·数据结构·c++·算法·面试·蓝桥杯·排序算法
独行soc9 小时前
#渗透测试#漏洞利用#红蓝攻防#信息泄露漏洞#Tomcat信息泄露漏洞的利用
安全·web安全·面试·tomcat·漏洞挖掘·信息泄露·tomcat漏洞
MySheep.10 小时前
爬虫数据存储:Redis、MySQL 与 MongoDB 的对比与实践
redis·爬虫·mysql
正在绘制中10 小时前
Java重要面试名词整理(六):MongoDB
java·mongodb·面试
随猿Fa10 小时前
arthas查看拼接好参数的sql, redis, es完整可直接执行的命令
redis·sql·elasticsearch
Pandaconda10 小时前
【Golang 面试题】每日 3 题(六)
开发语言·笔记·后端·面试·职场和发展·golang·go