Redis分布式锁核心原理源码

文章目录

  • 概述
  • 一、Redis实现分布式锁
    • 1.1、第一版
    • 1.2、第二版
    • 1.3、第三版
    • 1.3、第四版
  • 二、Redisson实现分布式锁核心源码分析
    • 2.1、加锁核心源码
    • 2.2、锁续期核心源码
    • 2.3、重试机制核心源码
    • 2.4、解锁核心源码
  • 总结


概述

  传统的单机锁(Synchronized,ReentrantLock)都是进程级别的锁,无法应对服务多实例部署的场景(每个服务实例都有自己的进程),如果需要跨进程加锁,则需要引入第三方工具对于进程统一管理。
  使用Redis可以实现简易的分布式锁,而最常见的成熟的分布式锁方案是Redisson

一、Redis实现分布式锁

  案例工程:减库存,没有加锁控制,在高并发的场景下必然会出现超卖的问题。如果是在单点部署的情况下,可以通过本地锁解决,但是目前服务多点部署,本地锁的方案无法进行控制。

@Service
public class DistributedLockDemo {@Resourceprivate StringRedisTemplate stringRedisTemplate;private final String STOCK_PREFIX = "stock:";private final String STOCK_LOCK = "stock:lock:";public void deduceStock(int orderNum,int orderId){//业务代码int stockNumber = Integer.parseInt(stringRedisTemplate.opsForValue().get(STOCK_PREFIX + orderId));if (stockNumber > 0){stockNumber = stockNumber - orderNum;}stringRedisTemplate.opsForValue().set(STOCK_PREFIX + orderId, String.valueOf(stockNumber));}
}

1.1、第一版

  以stock:lock:前缀加上orderId作为key,使用setIfAbsent进行加锁,setIfAbsent命令是Redis原生的setNx命令在客户端的体现,setNx命令是仅仅当设置的key不存在时,才可以成功,保证互斥性。
  这样做存在的问题是,如果在执行业务代码的过程中,出现了异常,那么解锁的代码则永远无法执行,造成死锁

@Service
public class DistributedLockDemo {@Resourceprivate StringRedisTemplate stringRedisTemplate;private final String STOCK_PREFIX = "stock:";private final String STOCK_LOCK = "stock:lock:";public void deduceStock(int orderNum,int orderId){Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(STOCK_LOCK + orderId, "lock");if (lock){//业务代码int stockNumber = Integer.parseInt(stringRedisTemplate.opsForValue().get(STOCK_PREFIX + orderId));if (stockNumber > 0){stockNumber = stockNumber - orderNum;}stringRedisTemplate.opsForValue().set(STOCK_PREFIX + orderId, String.valueOf(stockNumber));stringRedisTemplate.delete(STOCK_LOCK + orderId);}}
}

1.2、第二版

  针对第一版的问题进行改造,将解锁的代码放到finally代码块中。这种方案依旧会存在问题,因为finally代码块只能保证程序出错时最终执行,无法保证服务器宕机造成的死锁,所以最好在加锁时设置一个超时时间,到期自动释放。

    public void deduceStock(int orderNum,int orderId){try {Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(STOCK_LOCK + orderId, "lock");if (lock){//业务代码int stockNumber = Integer.parseInt(stringRedisTemplate.opsForValue().get(STOCK_PREFIX + orderId));if (stockNumber > 0){stockNumber = stockNumber - orderNum;}stringRedisTemplate.opsForValue().set(STOCK_PREFIX + orderId, String.valueOf(stockNumber));stringRedisTemplate.delete(STOCK_LOCK + orderId);}} catch (Exception e) {}finally {stringRedisTemplate.delete(STOCK_LOCK + orderId);}}

1.3、第三版

  设置超时时间,可以使用stringRedisTemplate.expire方法,但是这样写:

Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(STOCK_LOCK + orderId, "lock");
stringRedisTemplate.expire(STOCK_LOCK + orderId, 10, TimeUnit.SECONDS);

  是不具有原子性的,需要分为两条命令执行,如果在执行两条命令之间出现问题,依旧会造成死锁的问题。在Redis的层面提供了一条命令 set NX EX,保证设置超时时间和加锁是原子性操作:

Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(STOCK_LOCK + orderId, "lock",10, TimeUnit.SECONDS);

  加锁时存在的问题看似是解决了,但是解锁的代码:

stringRedisTemplate.delete(STOCK_LOCK + orderId);

  会存在一种情况:

  • 线程一获取到了锁,然后在执行业务代码的时候陷入了阻塞。
  • 线程一的锁到期自动释放。
    • 线程二获取到了锁,执行业务代码
  • 线程一从阻塞状态恢复,执行完业务代码,要执行最终的解锁逻辑
  • 线程一将线程二的锁解锁。

1.3、第四版

  为了避免当前线程将其他线程的锁误解锁,需要在加锁时加入自己的线程唯一标识,并且在解锁时进行判断
  注意,不要用当前Thread.currentThread().getId()方法去获取线程ID,因为不同机器上的线程ID可能会重复。Redisson底层也不是直接用上述的API获取的线程ID,而是和UUID进行了拼接。

//加锁
String threadId = UUID.randomUUID().toString().replace("-","");
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(STOCK_LOCK + orderId, threadId,10, TimeUnit.SECONDS);//解锁
String threadIdFromRedis = stringRedisTemplate.opsForValue().get("STOCK_LOCK + orderId");
if (threadId.equals(threadIdFromRedis)){stringRedisTemplate.delete(STOCK_LOCK + orderId);
}

  但是这样写, 解锁和之前的加锁设置超时时间有同样的问题,都是操作分为了两步,不能保证原子性。 在解锁的判断上,Redis并没有提供原子性的命令,需要自己去通过lua脚本实现。


  经过四版改动,自己用Redis实现的分布式锁已经基本可用了,但是深究下来依旧存在一些问题或不足:

  1. 如果执行业务代码的时间,超过了设置的锁超时时间,当前逻辑是没有自动续期的。
  2. 当前的逻辑不支持锁重入。
  3. 当前的逻辑没有实现重试机制,获取不到锁的线程无法进行重试。

二、Redisson实现分布式锁核心源码分析

  相比较于自己通过set NX EX + lua脚本实现的分布式缓存锁,Redisson是更为成熟的方案,也推荐在生产环境使用。Redisson分布式锁在API层面是非常简单的:

public class RedissonLockDemo {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Resourceprivate Redisson redisson;private final String STOCK_PREFIX = "stock:";private final String STOCK_LOCK = "stock:lock:";public void deduceStock(int orderNum, int orderId) {//获取分布式锁RLock lock = redisson.getLock(STOCK_LOCK + orderId);try {//加分布式锁,可以指定超时时间,没有指定超时时间默认30s,底层会自动续期。lock.lock();//业务代码int stockNumber = Integer.parseInt(stringRedisTemplate.opsForValue().get(STOCK_PREFIX + orderId));if (stockNumber > 0) {stockNumber = stockNumber - orderNum;}stringRedisTemplate.opsForValue().set(STOCK_PREFIX + orderId, String.valueOf(stockNumber));} catch (Exception e) {} finally {lock.unlock();}}
}

  关键代码:

//获取分布式锁
RLock lock = redisson.getLock(STOCK_LOCK + orderId);
//加分布式锁,可以指定超时时间,没有指定超时时间默认30s,底层会自动续期。
lock.lock();
//解锁
lock.unlock();

2.1、加锁核心源码

  跟踪lock.lock();,进入lockInterruptibly
在这里插入图片描述
  首先第一次加锁,进入的是tryAcquire方法,最终的核心逻辑是:
在这里插入图片描述
  底层执行的是一段lua脚本,lua脚本和pipeline类似,也是可以将命令批量执行。虽然脚本中分了很多条命令,但是其他客户端要等到当前客户端的lua脚本全部执行完,才能执行脚本。

  • KEYS[1]:是作为当前分布式锁的Key,也就是用户在redisson.getLock时传入的。
  • ARGV[1]:是默认的超时时间,30s。
  • ARGV[2]:是当前线程的唯一标识,用线程ID拼接上UUID。
# 加锁的逻辑
# 当前分布式锁的key不存在
"if (redis.call('exists', KEYS[1]) == 0) then " +
#	调用hset命令,key是分布式锁的key,value的key是当前线程的唯一标识,用线程ID拼接上UUID。 value是1(重入次数)
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
#  设置超时时间
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
# 返回null
"return nil; " +
"end; " +
# 重入的逻辑
# 当前分布式锁的key存在,并且是当前线程持有
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
# 重入次数 + 1
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
# 设置超时时间
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
# 返回null 
"return nil; " +
"end; " +
# 查询指定键的剩余生存时间,并且返回
"return redis.call('pttl', KEYS[1]);"

  上面这个脚本的执行,包含了可重入锁初次加锁的逻辑,最终还会返回当前锁的剩余时间

2.2、锁续期核心源码

  Redisson锁的续期,也称为看门狗机制。如果要实现锁续期,常见的设计思想是在业务线程执行时,开启一个守护线程,对业务线程进行监控,如果锁到期,业务线程还没有执行完,就执行续期的逻辑
  在Redisson中的实现,调用完tryLockInnerAsync方法后,会回调operationComplete,通过future.getNow();获取到加锁的结果,上面的lua脚本,在加锁成功和重入成功后,都会返回null。
在这里插入图片描述
  进入scheduleExpirationRenewal方法,该方法就是实现续期的核心方法实现,类似于一个延迟任务的线程池,延迟30/3 = 10s执行,整个方法分为两部分
  首先依旧是执行一段lua脚本**(KEYS[1],ARGV[2],ARGV[1] 和第一段加锁时的lua脚本参数含义相同)**

# 当前分布式锁的key存在,并且是当前线程持有
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
# 进行续期30s
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
# 续期成功就返回1
"return 1; " +
"end; " +
# 否则返回 0 
"return 0;",

  第二部分则是拿到lua脚本执行的结果,递归调用scheduleExpirationRenewal方法,延迟10s执行,最终的结果是每隔10s进行一次续期,每次续期30s
在这里插入图片描述

2.3、重试机制核心源码

  加锁时的lua脚本,如果没有加锁或者重入成功,那么最终返回的是key的剩余生存时间
在这里插入图片描述

  返回到方法的最外层,在lockInterruptibly中执行自旋重试的逻辑。这里的自旋重试并非是在while循环中不断地循环,而是有一定的间隔时间。
  在进入while循环后首先会再次尝试获取锁,如果失败了,就通过Semaphore的API,在规定的TTL毫秒内尝试获取许可,如果有其他线程释放(即唤醒),当前线程就会继续执行。如果超时仍未获取到许可,则返回 false。
在这里插入图片描述
  如果业务代码执行的时间短于设置的锁超时时间,那么其他等待锁的线程并不会阻塞到超时时间后再去竞争锁,在执行while循环之前,会通过redis的发布订阅模型,将自身存入一个队列中。
在这里插入图片描述
  唤醒队列中元素的逻辑,在解锁中。

2.4、解锁核心源码

  解锁同样是通过lua脚本,将判断线程标识和解锁组成原子性的操作,解锁的lua脚本在unlockInnerAsync方法中:

  • KEYS[1]:当前分布式锁的key
  • KEYS[2]:当前分布式锁的key 拼接上 redisson_lock__channel
  • ARGV[1]:解锁消息标识,默认0L
  • ARGV[2]:锁超时释放时间
  • ARGV[3]:当前线程的唯一标识,用线程ID拼接上UUID。

  主线程在解锁的时候会往队列中发送给一条消息,唤醒等待线程:

  • 当前锁不存在,超时释放了
  • 存在并且解锁成功
# 当前key对应的分布式锁不存在
"if (redis.call('exists', KEYS[1]) == 0) then " +
# 发布解锁消息标识到当前分布式锁的key 拼接上 redisson_lock__channel
"redis.call('publish', KEYS[2], ARGV[1]); " +
# 返回1
"return 1; " +
"end;" +
# 当前key对应的分布式锁非本线程持有
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
# 返回null
"return nil;" +
"end; " +
# 可重入锁的解锁,重入次数 - 1
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
# 重入次数>0
"if (counter > 0) then " +
# 重新设置超时时间
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
# 返回
"return 0; " +
"else " +
# 删除当前key对应的分布式锁
"redis.call('del', KEYS[1]); " +
# 发布解锁消息标识到当前分布式锁的key 拼接上 redisson_lock__channel
"redis.call('publish', KEYS[2], ARGV[1]); " +
# 返回1
"return 1; "+
"end; " +
"return nil;",

  消费者 (正在阻塞等待的线程) 接受到了消息,会回调LockPubSubonmessage方法,被唤醒然后重新争抢锁。
在这里插入图片描述

总结

在这里插入图片描述

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

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

相关文章

关于vue2使用elform的rules校验

在使用vue2开发项目的时候使用element组件的el-form大多数情况都需要用到必填项校验 举个栗子&#xff1a; <el-form :model"ruleForm" :rules"rules" ref"ruleForm" label-width"100px" class"demo-ruleForm"><e…

langchain从入门到精通(二十六)——RAG优化策略(四)问题分解策略提升负责问题检索准确率

1. LangChain 少量示例提示模板 在与 LLM 的对话中&#xff0c;提供少量的示例被称为 少量示例&#xff0c;这是一种简单但强大的指导生成的方式&#xff0c;在某些情况下可以显著提高模型性能&#xff08;与之对应的是零样本&#xff09;&#xff0c;少量示例可以降低 Prompt…

Nuxt.js基础(Tailwind基础)

​​1. 按钮组件实现​​ ​​传统 CSS <!-- HTML --> <button class"btn-primary">提交</button><!-- CSS --> <style>.btn-primary {background-color: #3490dc;padding: 0.5rem 1rem;border-radius: 0.25rem;color: white;transi…

[C语言]存储结构详解

C语言存储结构总结 在C语言中&#xff0c;数据根据其类型和声明方式被存储在不同的内存区域。以下是各类数据存储位置的详细总结&#xff1a; 内存五大分区 存储区存储内容生命周期特点代码区(.text)程序代码(机器指令)整个程序运行期只读常量区(.rodata)字符串常量、const全…

【实战】 容器中Spring boot项目 Graphics2D 画图中文乱码解决方案

场景 架构&#xff1a;spring boot 容器技术&#xff1a;docker 服务器&#xff1a;阿里云 开发环境&#xff1a;windows10 IDEA 一、问题 服务器中出现Graphics2D 画图中文乱码 本地环境运行正常 二、原因 spring boot 容器中没有安装中文字体 三、解决方案 安装字体即可 …

深入浅出:Vue2 数据劫持原理剖析

目录 一、什么是数据劫持&#xff1f; 二、核心 API&#xff1a;Object.defineProperty 三、Vue2 中的数据劫持实现 1. 对象属性的劫持 2. 嵌套对象的处理 3. 数组的特殊处理 四、结合依赖收集的完整流程 五、数据劫持的局限性 六、Vue3 的改进方案 总结 一、什么是数…

数据湖 vs 数据仓库:数据界的“自来水厂”与“瓶装水厂”?

数据湖 vs 数据仓库&#xff1a;数据界的“自来水厂”与“瓶装水厂”&#xff1f; 说起“数据湖”和“数据仓库”&#xff0c;很多刚入行的朋友都会觉得&#xff1a; “听起来好高大上啊&#xff01;但到底有啥区别啊&#xff1f;是湖更大还是仓库更高端&#xff1f;” 我得说…

Node.js-path模块

Path 模块 path 模块提供了 操作路径 的功能&#xff0c;我们将介绍如下几个较为常用的几个 API ​​path.resolve([…paths]) 将路径片段​​解析为绝对路径​​&#xff08;从右向左拼接&#xff0c;遇到绝对路径停止&#xff09; // 若参数为空&#xff0c;返回当前工作目…

Java面试题029:一文深入了解MySQL(1)

欢迎大家关注我的专栏,该专栏会持续更新,从原理角度覆盖Java知识体系的方方面面。 一文吃透JAVA知识体系(面试题)https://blog.csdn.net/wuxinyan123/category_7521898.html?fromshare=blogcolumn&sharetype=blogcolumn&sharerId=7521898&

vue3.0所采用得Composition Api与Vue2.XOtions Api有什么不同?

Vue 3.0 引入的 Composition API 相较于 Vue 2.x 的 Options API 有显著的不同。下面从几个方面对比这两者的差异&#xff1a; ✅ 1. 代码组织方式不同 Vue 2.x — Options API 使用 data、methods、computed、watch 等分散的选项组织逻辑。 每个功能点分散在不同的选项中&am…

【IP 潮玩行业深度研究与学习】

潮玩行业发展趋势分析&#xff1a;全球市场格局与中国政策支持体系 潮玩产业正经历从"小众收藏"到"大众情绪消费"的深刻转型&#xff0c;2025年中国潮玩市场规模已达727亿元&#xff0c;预计2026年将突破1100亿元&#xff0c;年复合增长率高达26%。这一千…

进程通信-消息队列

消息队列允许一个进程将一个消息发送到一个队列中&#xff0c;另一个进程从该队列中接收这个消息。 使用流程&#xff1a; 写端&#xff1a; 使用结构体 mq_attr 设置消息队列属性&#xff0c;有四个选项&#xff1a; long mq_flags; // 队列属性: 0 表示阻塞 long …

串行通信接口USART,printf重定向数据发送,轮询和中断实现串口数据接收

目录 UART通信协议的介绍 实现串口数据发送 CubeMX配置 printf重定向代码编写 实现串口数据接收 轮询方式实现串口数据接收 接收单个字符 接收不定长字符串&#xff08;接收的字符串以\n结尾&#xff09; 中断方式实现串口数据接收 CubeMX配置 UART中断方式接收数据…

Kafka 生产者和消费者高级用法

Kafka 生产者和消费者高级用法 1 生产者的事务支持 Kafka 从版本0.11开始引入了事务支持&#xff0c;使得生产者可以实现原子操作&#xff0c;确保消息的可靠性。 // 示例代码&#xff1a;使用 Kafka 事务 producer.initTransactions(); try {producer.beginTransaction();pr…

k8s中crictl命令常报错解决方法

解决使用crictl命令时报默认端点弃用的报错 报错核心原因 默认端点弃用&#xff1a; crictl 会默认尝试多个容器运行时端点&#xff08;如 dockershim.sock、containerd.sock 等&#xff09;&#xff0c;但这种 “自动探测” 方式已被 Kubernetes 弃用&#xff08;官方要求手动…

回转体水下航行器简单运动控制的奥秘:PID 控制和水动力方程的运用

在水下航行器的控制领域中&#xff0c;回转体水下航行器的运动控制是一个关键课题。 今天&#xff0c;就来深入探讨一下其简单运动控制中&#xff0c;PID 控制以及水动力方程的相关运用。 PID 控制的基本原理PID 控制&#xff08;比例 - 积分 - 微分控制&#xff09;是一种广…

从入门到精通:npm、npx、nvm 包管理工具详解及常用命令

目录 1. 引言2. npm (Node Package Manager)2.1 定义与用途2.2 常见命令2.3 使用示例 3. npx (Node Package Execute)3.1 定义与用途3.2 常见命令3.3 使用示例3.4 npm 与 npx 的区别 4. nvm (Node Version Manager)4.1 定义与用途4.2 安装 nvm4.3 常见命令4.4 使用示例 5. 工具…

es6特性-第二部分

Promise 介绍和基本使用 Promise是ES6引入的异步编程的新解决方案&#xff0c;主要用来解决回调地狱问题。语法上 Promise是一个构造函数,用来封装异步操作并可以获取其成功或失败的结果。 Promise构造函数:new Promise() Promise.prototype.then方法 Promise.prototype.ca…

java:如何用 JDBC 连接 TDSQL 数据库

要使用JDBC连接TDSQL数据库&#xff08;腾讯云分布式数据库&#xff0c;兼容MySQL协议&#xff09;&#xff0c;请按照以下步骤编写Java程序&#xff1a; 1. 添加MySQL JDBC驱动依赖 在项目的pom.xml中添加依赖&#xff08;Maven项目&#xff09;&#xff1a; <dependenc…

2025年四川省高考志愿填报深度分析与专业导向策略报告——基于599分/24000位次考生-AI

2025年四川省高考志愿填报深度分析与专业导向策略报告——基于599分/24000位次考生 摘要 本报告旨在为预估高考成绩599分、全省物理类位次在24,000名左右的2025年四川考生&#xff0c;提供一份兼具科学性、前瞻性与专业深度的志愿填报策略方案。报告严格遵循“位次法”为核心…