【Redisson分布式锁】Redisson公平锁实现机制

news2024/11/19 5:52:01

欢迎关注公众号(通过文章导读关注:【11来了】),及时收到 AI 前沿项目工具及新技术的推送!

在我后台回复 「资料」 可领取编程高频电子书
在我后台回复「面试」可领取硬核面试笔记

文章导读地址:点击查看文章导读!

感谢你的关注!

在这里插入图片描述

Redisson公平锁加锁源码分析

image-20240305145104682

上一篇说了 可重入锁 加锁的流程,这个可重入锁其实就是非公平锁,非公平体现在哪里呢?

体现在当前客户端如果抢锁失败的话,会拿到这个锁的剩余存活时间,会进行等待,等待之后再次去尝试加锁,里边是没有任何排队的逻辑的,因此是非公平锁

首先还是将使用的代码给放上:

public static void main(String[] args) throws InterruptedException {
    Config config = new Config();
    config.useSingleServer()
            .setAddress("redis://127.0.0.1:6379")
            .setPassword("123456")
            .setDatabase(0);
    //获取客户端
    RedissonClient redissonClient = Redisson.create(config);
    RLock fairLock = redissonClient.getFairLock("fair_11_come");
    fairLock.lock();
}

队列放在哪里存储?

Redisson 的公平锁和非公平锁的区别只在最终执行的 lua 脚本有区别,所以这里就只说 最后的 lua 脚本是怎么实现公平锁的!

首先来思考一下,要实现公平锁肯定是需要一个队列的,那这个队列放在哪里存储呢?

可以放在本地吗? 肯定不行,因为 Redisson 分布式锁使用在分布式环境下的,放在本地其他节点都感知不到,当然不行

因此,这个队列还是放在分布式缓存 Redis 中比较合适,毕竟锁也是在 Redis 中记录的,将队列也放在 Redis 中也不用引入其他的技术栈,并且可以通过 lua 脚本执行,来保证原子性

公平锁 lua 脚本分析

公平锁的加锁流程最终会走到 RedissonFairLock # tryLockInnerAsync() 方法中,在该方法中执行 lua 脚本进行排队、加锁等一系列操作,因此这个 lua 脚本是比较长的,而关于这个 lua 脚本网上也有许多讲解的,这里直接将注释贴在 lua 脚本上,接下来通过画图的方式讲解这个公平锁的加锁以及排队流程

接下来为了保证阅读起来比较方便,将这个 lua 脚本分为 5 个分支来讲

lua 脚本参数

这个 lua 脚本中有一些参数,这里先介绍一下这些参数是什么:

  • KEYS[1] :锁的名称,即 fair_11_come

  • KEYS[2] :Redis 中的等待队列名称,即 redisson_lock_queue:{fair_11_come}

  • KEYS[3] :Redis 中的 Set 有序集合名称,超时时间作为 score 进行排序,即 redisson_lock_timeout:{fair_11_come}

  • ARGV[1] :默认的锁释放时间,即 30000ms

  • ARGV[2] :UUID + threadId,用于标识具体加锁线程,即 54a63d7a-926a-4ef8-9155-3f5769a10a1f:1

  • ARGV[3] :线程等待的时间,即 300000ms

  • ARGV[4]:当前的时间戳,即 1709556953230

有了这些参数,接下来看 lua 脚本就清晰很多了,这里我先将这 5 个分支的 lua 脚本以及注释贴出来,这里先不细说 lua 脚本,大家可以直接跳过这个 lua 脚本看后边的客户端加锁案例, 根据客户端加锁案例来理解加锁的流程,通过加锁案例来理解 lua 脚本为什么这么设计!

分支1

image-20240305104109434

分支2

image-20240305104120054

分支3

image-20240305104132195

分支4

image-20240305143223651

分支5

image-20240305104149819

从加锁流程分析 lua 脚本

这里从加锁流程来分析上边的 lua 脚本,来理解整个公平锁的加锁流程是怎样的,假设有 3 个客户端:A、B、C

这里假设加的公平锁的名称为 fair_11_come

客户端 A 加锁

此时加入客户端 A 第一个过来加锁,到【分支1】,毫无疑问会从 while 循环中跳出来,因为等待队列中根本就没有等待线程,于是向下继续执行

到【分支2】,客户端 A 发现在 Redis 中不存在 fair_11_come 这个哈希结构, 并且等待队列中也没有等待线程,于是客户端 A 可以加锁,通过 hset 进行加锁,并且设置 过期时间为 30000ms ,也就是 30s,此时 Redis 中存在了该哈希结构如下:

"fair_11_come": {
    "UUID_A + threadId_A": "1"
}
客户端 B 来加锁

那么此时如果客户端 B 来加锁,假设此时 A 还没有释放锁

那么 B 走到 【分支1】,也会从 while 循环中跳出来,因为等待队列为空

到【分支2】,发现这个哈希结构已经存在了,说明锁被其他客户端线程占有了,于是跳过【分支2】

到【分支3】,发现不是重入锁,跳过【分支3】

到【分支4】,取当前线程的等待时间,由于还没有加入等待队列中,所以取出来是空,跳过【分支4】

到【分支5】,获取最后一个等待线程,发现为空,此时 ttl 为这个 fair_11_come 这个锁的剩余存活时间,这里假设为 ttl 为 25s,那么计算出来的 timeout 的值为 ttl + ARGV[3] + ARGV[4] 也就是 ttl + 300000ms + 当前时间戳 ,假设当前时间为 10:00:00

于是将当前节点加入等待队列中,这里假设客户端 B 的线程标识为 UUID_B:threadId_B ,此时 Redis 中锁结构以及等待队列如下:

image-20240305144335392

客户端 C 来加锁

此时假设客户端 C 来加锁,首先到【分支1】,发现没有等待超时的节点,于是退出 while 循环

到【分支2】,发现这个哈希结构已经存在了,说明锁被其他客户端线程占有了,于是跳过【分支2】

到【分支3】,发现不是重入锁,跳过【分支3】

到【分支4】,取当前线程的等待时间,由于还没有加入等待队列中,所以取出来是空,跳过【分支4】

到【分支5】,取出最后一个等待线程,发现不是空,说明前边有线程在等待了,此时 ttl前一个等待线程的 score - ARGV[4] ,前一个节点也就是 B 的 score 为 25s + 300s + 10:00:00,再减去 ARGV[4] = 10:00:00

于是客户端 C 的 ttl25s + 300s

接下来计算客户端 C 的 timeout = ttl + 300s + 当前时间戳 ,假设当前时间为 10:00:05 ,那么客户端 C 的 timeout = (25s+300s) + 300s + 10:00:05

我们可以发现,这里客户端 C 的 timeout 也就是 score 每进入一个节点排队都会多加一个 300s,所以在【分支2】中,如果有线程获取锁的话,会遍历这个 Set 集合将所有节点的 score 都减去 300s

并且将客户端 C 加入等待队列:

image-20240305144344171

那么至此正常的加锁流程就已经说完了

客户端存在网络问题无法加锁怎么办?

但是除了这些正常情况,还会存在异常情况,如果轮到某一个客户端加锁了,但是该客户端网络存在异常,导致无法加锁,那么肯定不能让这个客户端在等待队列中一直等,从而导致后边的客户端线程也无法加锁,

这些 Redisson 都考虑到了,会给每一个客户端线程设置一个最长的等待时间,每个线程进入队列之后,最多允许等待【锁的剩余存活时间 + 300s】,所以每当有线程进入【分支1】的 while 循环中,如果发现队列中的线程已经【等待超时】了,说明这个线程可能存在网络问题,也可能是锁一直被占有没有释放,那么直接就将这个线程扔出队列即可

  • 那么被扔出去的线程如何再次加入队列呢?

在这个 lua 脚本中,如果加锁失败,在【分支4】中会返回锁的剩余存活时间,之后会在 RedissonLock # lock() 方法中进入到 while 循环,在这个 while 循环中通过信号量 Semaphore 来阻塞等待一会(锁的剩余存活时间),等待完之后,再次尝试去加锁就可以了

如果客户端被扔出队列了之后,就会在这个 lock 方法中的 while 循环中一直尝试去加锁,最后走到 lua 脚本中,会将自己重新加入到等待队列中进行等待

这里在将可重入锁的时候已经说过了,为了避免忘记还是将代码再贴一下:

image-20240305130726172

公平锁加锁总结

最后总结一下公平锁的加锁流程

公平锁的排队主要是靠 等待队列 来实现公平的,这个等待队列就是 Redis 中的列表,并且为了避免网络有问题的客户端一直在队列中,导致其他客户端线程无法获取锁的情况,因此还通过一个 有序 Set 集合 来存储每个排队客户端线程的超时等待时间,每个客户端线程最多等待【锁的剩余存活时间 + 300s】

公平锁的 lua 脚本虽然比较长,有 5 个分支,但是每个分支的功能其实是很明确的,这里再归纳一下:

【分支1】:从任务队列中剔除等待超时的节点

【分支2】:如果锁未被占有,并且自己是第一个等待锁的线程,就直接加锁

【分支3】:执行重入逻辑

【分支4】:如果当前客户端线程已经在等待队列中了,就返回实际需要等待的时间,也就是锁的剩余存活时间,返回这个时间就是方便在获取锁失败之后,阻塞等待这个时间之后,再来重试加锁

【分支5】:如果当前线程获取锁失败,就将自己加入到等待队列中,并且将等待超时时间设置到 Set 集合中去

完整的 lua 脚本

// 分支 1:while 循环,主要是将等待队列中已经【等待超时】的线程给扔出去
// 因为这些线程可能因为网络问题而无法获取锁,如果网络没有问题的话,这些线程会再次将自己加入到等待队列的
"while true do " +
    // 从等待队列中取出第一个等待的线程
    "local firstThreadId2 = redis.call('lindex', KEYS[2], 0);" +
    // 如果为空的话,说明队列中没有线程等待了,那么自己就可以跳出 while 循环出去获取锁了
    "if firstThreadId2 == false then " +
        "break;" +
    "end;" +
    // 如果不为空的话,从 Set 集合中取出这个线程的 score 值,也就是它的等待超时的时间
    "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]);" +
    // 如果超时时间 > 当前时间戳的话,说明队列中的第一个线程还没有等待超时,因此当前线程直接跳出 while 循环,接着向下走其他分支即可
    "else " +
        "break;" +
    "end;" +
"end;" +

// 分支 2:当前线程如果符合获取锁的条件,就在该分支中进行加锁
// 满足下边这两个条件,就进入这个 if 分支
// 条件 1:"fair_11_come" 这个锁的哈希结构在 Redis 中不存在
// 条件 2:(等待队列不存在)或者(等待队列存在且第一个等待的线程是当前线程)
"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]);" +

    // 获取 Set 结合中的所有节点,对他们的 score 都减去 300000ms
    // 这里当有客户端成功获取锁时,将等待队列中的超时等待时间都减去 300000ms,那么其他客户端在分支 1 的 while 循环中就将这些超时等待的线程从等待队列中剔除掉,并在后边的分支中重新加入到等待队列中
    "local keys = redis.call('zrange', KEYS[3], 0, -1);" +
    "for i = 1, #keys, 1 do " +
        "redis.call('zincrby', KEYS[3], -tonumber(ARGV[3]), keys[i]);" +
    "end;" +

    // 在这里进行加锁,并设置锁的过期时间为 30000ms
    "redis.call('hset', KEYS[1], ARGV[2], 1);" +
    "redis.call('pexpire', KEYS[1], ARGV[1]);" +
    "return nil;" +
"end;" +

// 分支 3:如果当前线程的键值对在 "fair_11_come" 这个哈希结构中存在的话,说明是重入了,直接重入次数 + 1 即可
"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;" +
    
    
// 分支 4:走到这里的话,说明分支 2 和分支 3 都不满足,也就是当前线程既不是第一个等待的线程,又不是发生重入
// 获取当前线程在 Set 集合中的 score,也就是等待超时时间
"local timeout = redis.call('zscore', KEYS[3], ARGV[2]);" +
// 如果 timeout 不是 false 的话,也就是这个当前线程已经在等待了
"if timeout ~= false then " +
    // 加锁失败,返回锁的存活时间,这里要减去这两个参数是因为 timeout = 锁的剩余存活时间+ARGV[3]+ARGV[4],这里减去这两个参数就返回锁的剩余存活时间了
    "return timeout - tonumber(ARGV[3]) - tonumber(ARGV[4]);" +
"end;" +
    
    
// 分支 5:
// 获取最后一个等待的线程
"local lastThreadId = redis.call('lindex', KEYS[2], -1);" +
"local ttl;" +
// 如果最后一个等待的线程不是空,并且不是当前线程
"if lastThreadId ~= false and lastThreadId ~= ARGV[2] then " +
    // 这里 ttl 就是上一个等待线程的等待时间 - 当前时间戳
    "ttl = tonumber(redis.call('zscore', KEYS[3], lastThreadId)) - tonumber(ARGV[4]);" +
"else " +
    // 如果最后一个等待的线程是空,说明当前线程是第一个等待的线程,ttl 设置为这个锁的剩余存活时间
    "ttl = redis.call('pttl', KEYS[1]);" +
"end;" +
// 这里计算一下 timeout
"local timeout = ttl + tonumber(ARGV[3]) + tonumber(ARGV[4]);" +
// 将当前线程加入等待队列中
"if redis.call('zadd', KEYS[3], timeout, ARGV[2]) == 1 then " +
    "redis.call('rpush', KEYS[2], ARGV[2]);" +
"end;" +
"return ttl;"

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1492041.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Vue2高级篇

Vue高级 Vue生命周期 生命周期又称为生命周期回调函数、生命周期函数、生命周期钩子, 是Vue在运行过程中的关键时刻帮我们调用的一些指函数, 生命周期函数名字不可修改, 其中的this指向的是vm或组件实例对象. 常用的生命周期钩子: mounted: 发送ajax请求、启动定时器、绑定…

软考中级-软件设计师备考的一些信息

备考资源补充 去年分享了如何备考软考中级-软件设计师及分析题的解题技巧&#xff1a;软考中级–软件设计师毫无保留的备考分享 文章中包含备考思路、备考资源和**解题技巧&#xff0c;**需要的请从上面的链接自行获取。 但有很多小伙伴说&#xff0c;之前分享的备考刷的视频…

放弃了字节32k的工作,回老家拿了8K的offer,我不后悔!

字节一年&#xff0c;人间三年。 虽然之前反复纠结和犹豫&#xff0c;在飞书的流程也是点了又关&#xff0c;但真正到了离开的这一刻&#xff0c;我居然没有太多不舍了。 可能是确实太累了&#xff0c;在字节工作的五百多个日夜里&#xff0c;基本没有在8点之前下过班&#xff…

一个复杂的数据流转换:文件流转base64

一个复杂的数据流转换&#xff1a;文件流转base64 可是我再也没遇到一个像福贵这样令我难忘的人了&#xff0c;对自己的经历如此清楚&#xff0c;又能如此精彩地讲述自己是如何衰老的。这样的老人在乡间实在是 难以遇上&#xff0c;也许是困苦的生活损坏了他们的记忆&#xff0…

JavaScript的`bind`方法:函数的“复制”与“定制”

&#x1f90d; 前端开发工程师、技术日更博主、已过CET6 &#x1f368; 阿珊和她的猫_CSDN博客专家、23年度博客之星前端领域TOP1 &#x1f560; 牛客高级专题作者、打造专栏《前端面试必备》 、《2024面试高频手撕题》 &#x1f35a; 蓝桥云课签约作者、上架课程《Vue.js 和 E…

H5双人五子棋小游戏

H5小游戏源码、JS开发网页小游戏开源源码大合集。无需运行环境,解压后浏览器直接打开。有需要的,私信本人,发演示地址,可以后再订阅,发源码,含60+小游戏源码。如五子棋、象棋、植物大战僵尸、开心消消乐、扑鱼达人、飞机大战等等 <!DOCTYPE html> <html> <…

【一起学习Arcade】(6):属性规则实例_约束规则和验证规则

一、约束规则 约束规则用于指定要素上允许的属性配置和一般关系。 与计算规则不同&#xff0c;约束规则不用于填充属性&#xff0c;而是用于确保要素满足特定条件。 简单理解&#xff0c;约束规则就是约束你的编辑操作在什么情况下可执行。 如果出现不符合规则的操作&#…

Hack The Box-Bizness

目录 信息收集 nmap dirsearch WEB Get shell 提权 get user flag get root flag 信息收集 nmap 端口扫描┌──(root㉿ru)-[~/kali/hackthebox] └─# nmap -p- 10.10.11.252 --min-rate 10000 -oA port Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-03-04 1…

链表哨兵例子

哨兵链表例子_根据值删除链表 package linklist;public class leetcode203 {public static void main(String[] args) {ListNode listNode new ListNode(1,new ListNode(2,new ListNode(3)));ListNode listNode1 removeElements(listNode,2);System.out.println(listNode1);…

【车辆安全管理】强制降速系统

在很久之前&#xff0c;我们就讨论过车辆强制降速系统的重要性&#xff0c;即使驾驶人故意撞人&#xff0c;也难以做到&#xff0c;因为强制降速系统会控制车辆的速度。强降速系统可以通过多种传感器进行智能分析&#xff0c;即使降速。 汽车的Robot化概念-CSDN博客 最近发生…

【Web】浅浅地聊JDBC java.sql.Driver的SPI后门

目录 SPI定义 SPI核心方法和类 最简单的SPIdemo演示 回顾JCBC基本流程 为什么JDBC要有SPI JDBC java.sql.Driver后门利用与验证 SPI定义 SPI&#xff1a; Service Provider Interface 官方定义&#xff1a; 直译过来是服务提供者接口&#xff0c;学名为服务发现机制 它通…

Bootstrap 入门介绍

Bootstrap 是什么 Bootstrap是一个开源的前端框架&#xff0c;旨在帮助开发人员快速构建响应式和移动优先的网站。它由Twitter开发并于2011年开源。Bootstrap提供了一组CSS样式和JavaScript组件&#xff0c;用于创建各种网页元素和交互效果。 Bootstrap 的特点 以下是Bootst…

ES入门八:Mapping的详细讲解

什么是Mapping&#xff1f;**Mapping定义了索引中的文档有哪些字段及其类型、这些字段是如何存储和索引的。**每个文档都是一个字段的集合&#xff0c;每个字段都有自己的数据类型&#xff0c;例如我们定义的books索引&#xff0c;其中有book_id、name等字段。所以Mapping的作用…

Linux运维工具-ywtool默认功能介绍

提示:工具下载链接在文章最后 目录 一.资源检查二.日志刷新三.工具升级四.linux运维工具ywtool介绍五.ywtool工具下载链接 一.资源检查 只要系统安装了ywtool工具,默认就会配置上"资源检查"的脚本资源检查脚本的执行时间:每天凌晨3点进行检查资源检查脚本的检查内容…

阿里云搭建私有docker仓库(学习)

搭建私有云仓库 首先登录后直接在页面搜索栏中搜索“容器镜像服务” 进入后直接选择个人版&#xff08;可以免费使用&#xff09; 选择镜像仓库后创建一个镜像仓库 在创建仓库之前我们先创建一个命名空间 然后可以再创建我们的仓库&#xff0c;可以与我们的github账号进行关联…

网络编程作业day5

将课堂上实现的模型&#xff08;IO多路复用&#xff09;重新自己实现一遍 服务器代码&#xff1a; #include<myhead.h> #define SER_IP "192.168.125.151" //服务器IP #define SER_PORT 8888 //服务器端口号int main(int argc, const char *argv…

首尔之春在线资源最新电影1080p高清

打开下面这个链接就可以看到 首尔之春在线资源最新电影1080p高清 如果链接打不开&#xff0c;就复制下面的网址到浏览器打开 https://www.zhufaka.cn/liebiao/A09504AE3BF8BD06 用阿里云盘下载&#xff0c;下载完成之后&#xff0c;用迅雷播放 首尔之春在线资源最新电影10…

JAVA SE 2.基本语法

1.Java的基本语法 1.基本格式 // 类的修饰包括&#xff1a;public&#xff0c;abstract&#xff0c;final 修饰符 class 类名{程序代码 } 例: public class Test{public static void main(String[] args){System.out.println("hello " "world");} }语法说明…

蓝桥杯——123

123 二分等差数列求和前缀和数组 题目分析 连续一段的和我们想到了前缀和&#xff0c;但是这里的l和r的范围为1e12&#xff0c;明显不能用O(n)的时间复杂度去求前缀和。那么我们开始观察序列的特点&#xff0c;可以按照等差数列对序列进行分块。如上图&#xff0c;在求前10个…

一台服务器,最大支持的TCP连接数是多少?

一个服务端进程最大能支持多少条 TCP 连接&#xff1f; 一台服务器最大能支持多少条 TCP 连接&#xff1f; 一、原理 TCP 四元组的信息&#xff1a;源IP、源端口、目标IP、目标端口。 一个服务端进程最大能支持的 TCP 连接个数的计算公式&#xff1a;最大tcp连接数客户端的IP…