当前位置: 首页 > news >正文

redis--黑马点评--分布式锁实现详解

分布式锁

我们已经发现在集群模式下,Synchronized的锁失效了,Synchronized只能够保证单个JVM内部的多个线程的互斥,而没有办法让我们集群下的多个JVM内部的线程互斥。

如果想要解决这种情况,我们必须要使用分布式锁。

分布式锁工作原理:

Synchronized锁利用JVM的锁监视器来控制线程,在JVM的内部只有一个锁监视器,所以只能有一个线程获取锁,可以实现线程间的互斥,当有多个JVM的时候,就会有多个锁监视器,就会有多个线程获取锁,这样就没有办法实现多JVM进程之间的互斥。

要想解决这个问题,就不能够使用JVM内部的锁监视器,必须让多个JVM去使用同一个锁监视器,因此,这个锁监视器一定独立于JVM之间,可以被多JVM进程识别到。此时无论是资源内部的还是多JVM的进程,都应该来找这个锁监视器来获取锁。这样只会有一个线程能够获取锁。就能够实现多JVM进程之间的互斥。

过程分析:

假设有两个JVM,JVM1中的线程A,来获取锁,就需要去寻找外部的锁监视器,一旦获取成功,就回去记录当前获取锁的是这个线程A,在高并发情况下,此时如果有其他线程也来获取锁,比如JVM2线程中的线程C,但因为外部的锁监视器中已经记录锁的持有者了,因此线程C获取锁失败,则需要等待锁释放,当线程A释放锁成功后,线程C来获取锁,之后执行自己的业务。此时,无论是单个JVM内部的线程互斥,还是多个jvm之间的线程互斥,都会被同一个锁监视器监视,达到一个互斥效果。也就是一个线程能拿到线程锁。这样就避免了并发安全问题的发生。

总结:

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。

特性:

  • 多线程可见:使用redis,能够让所有JVM都能看到

  • 互斥:必须确保不管谁来访问,只能够有一个线程拿到,其余都要失败

  • 高可用:大多数情况下获取锁的时候都应该是成功的,

  • 高性能:因为加锁本身就会影响业务的性能,因此获取锁的速度需要做到高并发

  • 安全性:需要考虑获取锁之后的异常情况处理

以上是分布式锁需要满足的基本特性,除此之外,还有一些功能性的特征,

  • 是否满足可重入性

  • 阻塞锁还是非阻塞锁

  • 公平锁还是非公平锁

分布式锁的实现

分布锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种:

MySQLRedisZookeeper
互斥利用MySQL本身的互斥锁机制利用setnx的互斥命令利用节点的唯一性和有序性实现互斥
高可用
高性能一般一般
安全性断开连接,自动释放锁利用锁超时机制,到期释放临时节点,断开连接自动释放

MySQL:

互斥锁机制:MySQL事务在执行写操作时,MySQL会自动分配互斥锁,在多个事务之间就是互斥的。

举例:我们可以在业务执行之前去MySQL中申请一个互斥锁,再去执行业务,当业务执行完之后,再去提交事务,当业务抛出异常时,会自动触发回滚,锁同时也释放。

可用性:MySQL支持主从模式,可用性良好

安全性:当MySQL断开连接后,锁也会自动释放

Redis:

互斥:setnx:值再往redis数据库set数据时,只有数据不存在时才能set成功,如果存在,则set失败,假如在多线程高并发场景下,所有线程是同一个key,则只能有一个线程可以成功,这就实现了互斥。

释放锁只需要将该key的值删除即可,这样其他线程就可以往redis数据库中存储了

可用性:不仅支持主从模式,还支持集群模式、哨兵模式。

安全性:利用redis的key过期机制,可以给key上设置过期时间,这样当服务宕机时,就算没有删除该key,一旦过期,就会自动释放

Zookeeper:

互斥:Zookeeper内部可以去创建数据节点,节点具备唯一性与有序性,还可以创建临时节点,唯一性即在创建节点时,节点不能重复。

有序性即每创建一个节点,节点的Id是递增的。

利用有序性实现互斥:在多线程高并发场景下,在Zookeeper中创建节点,每个节点的id就是单调递增的,如果约定ID最小的那个线程获取锁成功,这样一来就实现互斥。

利用唯一性实现互斥:在多线程高并发场景下,在Zookeeper中创建节点,节点名称设置一样,这样一来就只有一个线程可以成功。

一般情况下利用有序性实现互斥。

释放锁就只需要将节点删除即可。

可用性:支持集群模式

性能:Zookeeper的集群模式强调 强一致性,而这种强一致性会导致主从之间做数据同步需要消耗一定的时间,性能相对来讲较于redis差一些。

安全性:一般创建的都是临时节点,就算服务宕机,当指定时间,节点就会自动释放。

基于Redis的分布式锁

思路:

实现分布式锁时需要实现的两个基本方法:

  • 获取锁:

    • 互斥:确保只能有一个线程获取锁(利用setnx命令实现)

注意事项:必须确保获取锁的动作与和添加过期时间的动作保证原子性(要么都成功,要么都失败)

解决方案:在set命令后可以跟许多参数,其中就包括ex(设置过期时间),以及nx(互斥效果)。

我们可以在一个set命令中通过设置参数同时实现互斥效果,以及设置过期时间,这样就可以实现原子性的操作。

 # 添加锁,NX是互斥,EX是设置超时时间
 set key value NX EX 10
  • 释放锁:

    • 手动释放(del命令实现)(理想情况)

    • 设置过期值令其超时释放(利用expire命令设置过期时间)(紧急情况)

发现问题:在获取锁失败之后,线程应该做什么?

在JDK中提供的锁有两种机制:

  • 阻塞:获取锁失败,进行阻塞等待,等到锁被释放为止

  • 非阻塞:获取锁失败,结束返回一个结果。

而在redis获取锁失败后,会进行一个非阻塞的机制,因为阻塞线程使用的是操作系统底层原语mtuex,每次阻塞需要操作系统从用户态切换到内核态,比较消耗性能,其次,阻塞等待实现较为麻烦。

实现流程:

线程开启,尝试获取锁,执行命令,结果有两种,OK,NIL,NIL代表失败,证明锁已经被获取,OK代表获取互斥锁成功,则执行业务,业务执行完之后,释放锁。如果在执行业务期间出现服务宕机情况,一旦到达过期时间,锁自动释放,避免死锁的发生。

代码实现

案例:基于redis实现分布式锁初级版本

需求:定义一个类,实现下面接口,利用redis实现分布式锁功能

 public interface ILock {/*** 尝试获取锁* @param timeoutSec 锁的过期时间,单位秒,过期后自动释放* @return true代表获取锁成功,false代表获取锁失败 采用非阻塞模式,获取锁只尝试一次,返回结果*/boolean tryLock(long timeoutSec);/*** 释放锁*/void unlock();}

代码展示:

 @RequiredArgsConstructorpublic class SimpleRedisLock implements ILock{//业务名称private  final String name;//锁的key前缀private  final String KEY_PREFIX = "lock:";private  final StringRedisTemplate  stringRedisTemplate;//注意事项:设置锁的名称应该与业务相关,不能将锁名称写死,不能让所有业务获取同一把锁//  锁的业务应该与锁的名称相关,不同的业务获取不同的锁@Overridepublic boolean tryLock(long timeoutSec) {//  获取锁的key值String key = KEY_PREFIX + name;//  获取当前线程的id,作为线程标识,可以识别哪个线程拿到的锁long value = Thread.currentThread().getId();Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, value+ "", timeoutSec, TimeUnit.SECONDS);//自动拆箱可能导致空指针,因此使用工具类判断return Boolean.TRUE.equals(flag);}​@Overridepublic void unlock() {// 释放锁stringRedisTemplate.delete(KEY_PREFIX + name);}}

进行测试:

在模拟集群下再次进行测试:

 //创建锁对象,锁的范围缩小到每个用户,避免锁的粒度太大,提升并发效率SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);//获取锁,设置超时时间与你的业务执行时间相关boolean isLock = lock.tryLock(1200);//判断是否获取锁成功if (!isLock) {//获取锁失败,返回错误或者重试return Result.fail("不允许重复下单");}//利用实现类自调用,防止事务自调用问题//第二种方法:创建代理对象,利用代理对象调用方法,防止事务自调用问题try {IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(voucherId, seckillVoucher);} catch (IllegalStateException e) {//释放锁lock.unlock();}

测试:

打开ApiFox发送两个请求,在idea中打上断点,查看运行状态:

image-20250608173206122

这样就证明现在只有一个服务请求成功了。因为虽然有两个进程,但是是从同一台redis机器上去获取锁,并且获取锁的名字一致。

这就是redis分布式锁的实现思路。

Redis分布式锁误删问题

问题发现:

在多线程高并发场景下,线程一来获取锁,因为只有一个线程,因此获取成功,开始执行业务,业务发生堵塞,堵塞时间超过过期时间,锁被提前释放,线程二获取锁成功,开始执行业务,在执行业务期间,线程一业务执行结束,释放锁,但是释放的是线程二的锁,因此线程三获取锁成功,开始执行业务,这样就又出现了并行执行的现象,并发安全问题。

image-20250608174617242

原因:线程一在释放锁的时候并没有检查是否是自己的锁,如果在释放锁的时候进行判断,判断锁的标识是否一致,锁是否为自身的锁。如果是可以删除,如果不是就不可以。

修改业务流程:

在业务完成后检查锁上的唯一标识是否相同,是则释放,如果不是,说明自己的锁已经释放。

image-20250608180146848

案例:改进Redis的分布式锁

需求:修改之前的分布式锁实现,满足:

  • 在获取锁时存入线程标识(可以使用UUID表示)

  • 在释放锁时现获取锁的线程标识,判断是否与当前线程标识相同

    • 如果一直则释放锁

    • 如果不一直则不释放锁

注意事项:存储线程标识时,最好使用UUID,之前使用的线程ID就是一个递增数字,在JVM内部每创建一个线程,数字就会递增。如果是在集群的情况下,多个JVM进程,那么线程ID一定有重复的。不具备唯一性,因此不能使用。因此我们可以在创建锁时,在线程ID后面加上UUID,两者结合,用UUID区分JVM进程,用线程ID区分线程。

代码实现:

 @RequiredArgsConstructorpublic class SimpleRedisLock implements ILock{//业务名称private  final String name;//锁的key前缀private  final String KEY_PREFIX = "lock:";//线程标识 toString("true") 去掉符号UUID的-private  final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";private  final StringRedisTemplate  stringRedisTemplate;//注意事项:设置锁的名称应该与业务相关,不能将锁名称写死,不能让所有业务获取同一把锁//  锁的业务应该与锁的名称相关,不同的业务获取不同的锁@Overridepublic boolean tryLock(long timeoutSec) {//  获取锁的key值String key = KEY_PREFIX + name;//  获取当前线程的id,作为线程标识,可以识别哪个线程拿到的锁String value = ID_PREFIX + Thread.currentThread().getId();Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeoutSec, TimeUnit.SECONDS);//自动拆箱可能导致空指针,因此使用工具类判断return Boolean.TRUE.equals(flag);}​@Overridepublic void unlock() {//获取线程标识String value = ID_PREFIX + Thread.currentThread().getId();//获取锁中的标识String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);//判断标识是否一致if (value.equals(id)) {//一致,释放锁stringRedisTemplate.delete(KEY_PREFIX + name);}​}}

开始测试:

开启两个服务,利用ApiFox发送两个请求:

让其中一个服务拿到锁:

image-20250608183906109

并将锁删除,用来模拟业务操作时间大于超时时间

再让另一个服务拿到锁:

image-20250608183957796

在让第一个服务继续进行到释放锁阶段:拿到标识,进行判断:

image-20250608184048027

发现不一致,则直接结束,没有权限删除:

再让第二个服务进行到锁释放阶段:

image-20250608184139115

表示一致,进行释放锁阶段。

至此,锁误删问题解决。

分布式锁原子性问题

在前面解决误删问题时,我们在unlock中拿到redis锁中的值,并在进行判断之后进行了释放锁,这两者都是redis数据库的操作,在程序拿到redis锁中的标识后,进行判断,判断成功后才会进行释放,如果在判断完成之后,释放锁时服务产生了堵塞(堵塞可能原因:在JVM内部会有守护线程(gc线程),用来回收,在其进行full GC时,会阻塞所有的代码),堵塞时间一旦超出过期时间,锁就会被释放,其他线程就有乘虚而入了,这时的第一个线程已经进行了对标识的判断,因此,在堵塞结束后,不会再进行判断,会直接释放锁,这时释放的锁就不是自己的锁,又一次发生了误删问题。而此时redis中已经没有锁,就意味着又有线程可以获得锁并执行业务,这样就又出现了并发安全问题。

如图所示:

image-20250608185617541

问题原因:判断锁标识和释放是两个动作,这两个动作之间产生阻塞。

解决方案:确保判断锁识别的动作和释放锁的动作是一个原子性操作。

一想到原子性操作就会想到MySQL中事务的进行就是原子性的,要么一起成功,要么一起失败。

而redis中的事务首先是能够保证原子性,但无法保证一致性,而且事务里面的多个操作是一个批处理,整合到一块,最终一次性去处理。因此无法将判断锁识别的动作和释放锁的动作放在同一个事务中,因为做查询动作时无法拿到结果,他是最终整合一次性执行。

因此只能利用redis中的乐观锁去做判断,确保我释放时没有被修改。但这种做法比较复杂。因此这里可以使用redis的Lua脚本来完成。

Redis的Lua脚本

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,基础语法参考菜鸟教程:Lua 教程 

在使用JAVA语言调用redis时是靠spring框架中的redisTemplate,那么使用Lua脚本语言如何调用redis呢?

Lua官方给我们提供了一个内置的函数方便调用redis:

 # 执行redis命令redis.call('命令名称','key','其他函数',...)

类似于Java的面向对象,调用call方法。

举例说明:执行set name Jack 脚本:

 redis.call('set','name','jack')

脚本的实质:在脚本中写大量的命令,用来实现一个复杂业务逻辑。

脚本举例:

 #先执行redis命令 set name Jackredis.call('set','name','jack')#执行 get namelocal name = redis.call('get','name')#返回return name

利用Lua语言写完脚本后,需要使用redis命令来调用脚本,调用脚本的常见命令如下:

image-20250608192559256

举例说明:

执行:redis.call('set','name','jack')脚本,语法如下:

image-20250608204646186

脚本内容需要用双引号修饰,因为脚本本质是一个字符串,用双引号修饰是声明脚本内容。

执行脚本后面的数字时参数的数量,指该脚本中的key类型参数数量,实例脚本中并没有任何key类型参数,因此是0.

脚本可以理解为函数,而参数就是函数中的变量,因此如果没有参数就意味着脚本内容全部写死,那脚本的拓展性就很差,

如果在实例脚本中的name与Jack传参,就意味着这个脚本可以给不同的key赋不同的值,这样脚本的拓展性就得到了很大的增强。

代码展示:无参脚本

image-20250608203756347

有参脚本:

Redis中的参数分为两类(redis是key-value结构):

  • key类型的参数

  • 其他类型的参数

众所周知:redis命令中有mset命令,就可以添加多个key与value,那么我们的脚本里也可能有这种命令,可能脚本中key的个数不定,这时候就需要区分有几个key,以及有几个value。因此我们需要得知哪些是key类型参数,哪些是其他类型参数

如果为1,则往后的一个参数是key类型参数,以此类推。

如果在脚本中传了许多key,许多其他参数,这些参数在脚本中如何索取参数?

这时候需要有一个占位符去获取参数。

在脚本中传参后,key类型参数会放在keys数组,其他参数会放在ARGV数组,在脚本中可以在keys和ARGV数组获取这些参数:

举例说明:

image-20250608210103872

image-20250608210250215

这就是redis的Lua脚本的基本用法。

回顾:

释放锁的业务流程:

  1. 获取锁中的线程标识

  2. 判断是否于指定的标识(当前线程标识)一致

  3. 如果一致则释放锁

  4. 如果不一致则什么都不做

将该业务流程用Lua脚本编译:

 -- 锁的keylocal key = KEYS[1]-- 当前线程标识local threadId = ARGV[1]-- 获取锁的线程标识,即key中valuelocal id = redis.call('get',KEYS[1])-- 比较线程标识与锁中的标识是否一致if(id == ARGV[1]) then-- 释放锁return redis.call('del',KEYS[1])endreturn 0

简化得:

 if(redis.call('get',KEYS[1]) == ARGV[1]) then-- 释放锁return redis.call('del',KEYS[1])endreturn 0
Java调用Lua脚本改造分布式锁

案例:再次改进Redis锁的分布式锁

需求:基于Lua脚本实现分布式锁的释放锁逻辑

提示:RedisTemplate调用Lua脚本的API如下:

image-20250608222926348

与redis脚本命令对应如下

image-20250608223331435

修改释放锁的业务代码:

 public void unlock() {//调用Lua脚本stringRedisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + name),ID_PREFIX + Thread.currentThread().getId());}

注意事项:我们将Lua脚本写到一个文件中,那么程序运行到unlock方法时就需要去做一次IO流,每执行一次就需要去读取文件一次,所以我们直接提前将脚本文件定义为常量。

 private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;static {UNLOCK_SCRIPT = new DefaultRedisScript<>();UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));UNLOCK_SCRIPT.setResultType(Long.class);}

进行测试:与之前测试结果一致。

Lua脚本根本目的是为了解决判断线程标识与释放锁动作的原子性问题。因此避免了线程并发安全问题。

到此时实现的分布式锁已经是一个生产可用、相对完善的分布式锁了。

总结:

基于Redis分布式锁的实现思路:

  • 利用set nx ex 获取锁,并设置过期时间,保存线程标识

  • 释放锁先判断线程标识是否与自己一直,一致则删除锁。

特性:

  • 利用set nx 满足互斥性

  • 利用set ex 保证故障时锁依然能释放、避免死锁,提高安全性

  • 利用redis集群模式保证高可用性和高并发特性。

既然是较为完善,则说明还有进步的空间,下一篇就是分布式锁的优化。

希望对大家有所帮助!

http://www.xdnf.cn/news/931969.html

相关文章:

  • 【C/C++】EBO空基类优化介绍
  • C++----剖析list
  • 言和语的洞见,即:融智学解决方案
  • SEO新手优化步骤拆解
  • 人脸识别技术应用备案办理指南
  • 可可·香奈儿 活出自己
  • 【Elasticsearch】映射:null_value 详解
  • 代码规范和架构【立芯理论一】(2025.06.08)
  • 分形几何在医学可视化中的应用:从理论到Python实战
  • 元素水平垂直居中的方法
  • Jinja2深度解析与应用指南
  • 高等数学》(同济大学·第7版)第三章第四节“函数的单调性与曲线的凹凸性“
  • 开源大模型网关:One API实现主流AI模型API的统一管理与分发
  • 【C++系列】智能指针自定义析构
  • 如何将淘宝店铺商品搬到抖店去?利用 API 实现淘宝店铺商品到抖店的高效迁移
  • 5-C#的DateTime使用
  • Web后端基础(基础知识)
  • 基于PTN传输承载的4G网络-故障未连接...(我不理解哪错了排查了几遍没发现哪错啊啊啊啊)
  • AI架构师如何创建自己的知识库
  • JS手写代码篇---手写ajax
  • 计组_导学
  • 云备份项目
  • 行为型设计模式之Mediator(中介者)
  • java面试:JAVA并发篇
  • android计算器代码
  • uni-app学习笔记二十四--showLoading和showModal的用法
  • 自然语言处理——文本表示
  • 泛型约束:用于限制泛型类型参数的范围
  • 力扣HOT100之二分查找:4. 寻找两个正序数组的中位数
  • 深入解析 Pandas 核心数据结构:Series 与 DataFrame