目录
一、公平锁演示
二、公平锁实现原理
三、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 = 锁重入次数;