【分布式】手把手带你搞定分布式锁,redisson分布式锁

6 篇文章 0 订阅
5 篇文章 0 订阅

目录

1-2 分布式锁的出现

演示:构建企业服务集群

1-3 JVM本地锁

synchronized

ReentrantLock

分布式下的本地锁问题

1-4 分布式锁原理

1-5 MySql 悲观锁与乐观锁

悲观锁

乐观锁

小节

1-6 Redis锁setnx与业务代码处理

1-7 setnx锁超时自动过期

1-8 添加setnx锁请求标识防勿删

1-9 递归改造while循环

1-10 LUA 原子性操作

1-11 setnx 锁自动续期

java代码实现

测试

1-12 Redisson 概述与入门整合​编辑

入门示例

测试

1-13 Redisson 分布式锁测试

Redisson 常用基本配置

测试

1-14 Redisson 分布式锁底层源码品读

加锁

自动续期

解锁

1-15 Redisson 公平锁Fair Lock

测试

1-16 Redisson 联锁MultiLock

1-17 Redisson 红锁RedLock 以及其算法阐述

1-18 Redisson 读写锁ReadWriteLock

1-19 Redisson 信号量Semaphore

基于JUC的信号量

基于Redisson的信号量

1-20 Redisson 闭锁CountDownLatch


1-2 分布式锁的出现

当一个系统里,用户去修改公共资源的时候,那么可能会出现一些问题,如下代码:

第一个用户修改了企业信息,第二个用户也提交修改了信息,但是第一个用户没执行完可能慢了一拍,这个时候第二个用户修改了,那么最终的数据会成为用户1修改的结果,如果高并发用户都来修改信息,最终数据就乱套了,所以这里在分布式系统,在微服务系统,涉及到公共资源,那么一般最好都需要加上分布式锁。

所以我们的目的目标就是要在分布式环境下,保证共享资源被有序的顺序的访问。

演示:构建企业服务集群

为了测试方便,排除权限校验:

通过apipost引入不同参数,来看最终修改的结果是哪个请求的,结果可以发现第一个请求没处理完毕,第二个请求进来,这个时候,最终的值是第一个请求的,而不是第二个请求的。所以这个结果并不是我们所想要的了。

我们目前的场景其实还不够典型,最经典最典型的还是高并发下单扣库存,因为有可能会出现负数,库存到达0以下这样的情况。所以分布式锁是非常有必要的。

1-3 JVM本地锁

synchronized

ReentrantLock(可重入锁)

不管是synchronized还是ReentrantLock,都是基于本地的,单体绝对没问题,使用他们后的吞吐量是会下降的,因为以前是并行的请求,现在是顺序执行,所以一个完成后接着一个,势必吞吐量就会下来了。

那么在高并发的情况之下,以上两种方式是一般来说是绝对不会使用的。

分布式下的本地锁问题

因为他是本地锁,所以他只会影响本地线程,一旦在微服务或者分布式的环境之下,应该说是集群环境下,因为节点都是水平服务复制的,由于负载均衡,同一个接口在不同集群的节点下都会被访问到。如此,那么其他计算机节点的本地JVM是无法被影响的,因为他只能锁自己,锁不住其他服务节点的线程,所以此时还是依然存在共享资源被争抢的问题,这个时候就是服务和服务之间的争抢了。

1-4 分布式锁原理

用户端请求共享资源的之前,会先去争抢一把锁,谁先拿到才能访问共享资源,拿到以后再释放锁。如果抢到了锁,后续其他的请求只能等待释放,释放了以后再次争抢锁。等其他请求全部释放完,这个锁就会消失。 

这个锁可以认为是一个令牌token,只有拿到令牌的线程才能够访问这个共享资源,当然,这个token是需要通过技术编码手段来实现的。而且他是互斥锁,有且只有一个。只要有请求争抢到这个token,其他请求必须等到锁释放。

举个例子:我们这的艾鹿薇奢侈品,由于疫情,是不让所有人全进去店里购买的,必须每人排队发放一张卡,有了这张卡才能进店里购买,并且只能进一个人(或一个家庭),直达出来,才能放后面的进入,也就是每次都是一个个的进去,这么这个一张卡其实就是令牌的理念,也就是分布锁了。

其实分布式锁的主要目的其实就是为的让数据达到一致性。让客户端,让进程同步的来访问共享资源,在并发的场景下可以达到一致性。

分布式锁的类型:

  • JVM锁(本地锁,集群下失效)
  • mysql乐观锁
  • mysql悲观锁
  • redis 分布式锁
  • zookeeper分布式锁

1-5 MySql 悲观锁与乐观锁

悲观锁

select ... for update

在查询的后面加上for update,锁住记录。此时其他用户请求在执行操作的时候,则会被阻塞的;只有在之前用户提交或者回滚以后,才能执行。

这个悲观锁也可以称之为行级锁,他锁住的是行记录,所以他所影响的范围就是;当然除了行级锁以外还有表级锁,他锁的就是整张表,影响的范围当然是整张表了,当然我们不可能会使用表级锁,因为其他行记录完全不可用,因为全锁了,性能太差了。

悲观锁看似还可以,但是他有一个非常致命的问题,那就是容易造成死锁。那就是对多条记录进行加锁的时候,顺序紊乱了,加锁记录太多了,很容易引发死锁问题。

乐观锁

对需要的数据表增加version字段,提供版本号的支持

  1. 查询记录,当前version为0
  2. 更新记录,设置当前记录的version为1(累加1)。但是,更新的sql语句需要添加 where version=0
  3. 如果当前有很多人都要更新,那么他们都能获得当前的version为0,但是只有1个人可以更新成功,因为成功以后版本号则变为了1,因为他们的自身条件还是 where version=0,则其他人的请求则异常。如此就控制了这条共享资源被同时更新了。

乐观锁虽然也可以控制,但是并发访问的时候会出现大量的错误,所以可能导致后续的请求全部失败,在互联网下单场景是很显然不行的,会造成平台损失大量订单的。当然我们也可以递归去处理乐观锁,但是随着并发的上升,吞吐量会越来越低。

所以说高并发使用乐观锁性能是极低的。

此外。verion是用来控制的,但是verion本身也有可能会被篡改,被篡改就意味着锁失效,可能出现脏数据。就是你再更新的时候,虽然你觉得没问题更新成功了,但是那个version可能是被改了,只是你觉得没问题,但是实际上你入库的数据有问题。 第一个用户更新version变为1了,被别人用户进行篡改成0,后面的请求会被重新进行更新。

 这其实就是一个典型的数据库ABA数据不一致问题。

小节

如果我们的共享资源都在数据库中,那么我们完全可以通过数据库锁来实现,但是往往我们在分布式微服务的大环境开发中,所涉及到的共享资源可能还会有mongodb、redis、elasticsearch等。所以数据库锁是不够的。

而且数据库锁的性能也是很差的,对此,我们往往需要采用分布式中间件来实现分布锁这样的机制。

1-6 Redis锁setnx与业务代码处理

redis 的 setnx区别于普通set,他是set key if not exist,当一个key不存在的时候,可以设置成功。那么,我们就可以把setnx来设定某个key为一把锁,这个key存在的时候,则表示获得锁,那么请求无法操作共享资源,除非这个key不存在了,那就行。

如下图:

第一次设置成功,第二次设置不成功,因为这个key没有释放,除非删除了,或者超时清除了,那么才可以。

从上面操作可以看得出来,这其实也是分布式锁的3个关键步骤,加锁设值,删除解锁,重试(死循环或者递归)

通过如下流程可以更好梳理思路:

代码整合:

思考问题:

  • 如果业务执行的过程抛出异常了,怎么办?锁会一直没释放。
  • 如果当前运行这段代码的计算机节点突然停电了,代码整找准备删除lock,这个时候咋办?锁也会一直存在

1-7 setnx锁超时自动过期

上一节课遗留思考问题:

  • 如果业务执行的过程抛出异常了,怎么办?锁会一直没释放。
  • 如果当前运行这段代码的计算机节点突然停电了,代码整找准备删除lock,这个时候咋办?锁也会一直存在

由于上一节课提出的两个问题,其实我们要保证锁最终不管怎样都要释放,所以,我们可以为锁添加过期时间,如上图。

一旦后续发生故障,那么30秒后还是能释放锁。

但是这个时候还是会有问题,程序正好运行到图1.1还没来得及设置过期时间,拉电了,此时锁设置成功,但是没有设置过期时间,还是有问题。

所以,要么全设置成功,原子性必须得保证。

我们可以使用 setnx内置的,可以多加时间参数来设置。  

1-8 添加setnx锁请求标识防勿删

每个请求删除的时候,必须只能删除自己的锁,所以在生成锁的时候,创建一个uuid作为标记即可,在删除的时候进行判断就行了。

1-9 递归改造while循环

目前所使用的递归方案,高并发时也容易造成内存溢出,那么其实可以改造一下,改为死循环即可,只要获得锁失败,则返回去尝试获得锁即可。避免原子性问题删除锁放在fianlly里

1-10 LUA 原子性操作

之前的代码思考一下,其实还是有问题的

图中箭头处,当我们拿出锁后,并且判断也成功了,在这一刹那间,锁也可能正好失效吧。这个时候已经进入了判断内部了,所以会执行删除锁,但是这个时候因为锁恰好失效,所以其他请求就占有锁,那么自己在删除锁的时候,其实删除的是别人的锁,这样在极端的情况下其实也会出问题的。此时怎么办?

查询锁并且删除锁,这其实也是原子性操作,因为上一节课说了,这里也是可能会删除其他的锁的,因为原子性保证不了。

所以接下来我们所需要做的,就是保证查询以及判断都是原子性的操作。这里就需要结合使用LUA脚本来解决这个问题。

可以打开redis官网:Commands | Docs

相当于代码里的判断语句

解释:get命令获得key与参数比对,如果比对一致,则删除,否则返回0。
这是一段脚本,是一个命令一起运行的,所以要比我们程序代码中的调用要来的更好,因为这是原子性操作。要么全成功,要么全失败。

在命令行可以通过eval命令来进行操作:

EVAL "return ARGV[1]" 3 name age sex lee 18 man 183 200

# redis.call 可以调用redis的相关命令
EVAL "return redis.call('get',KEYS[1])" 1 name

把上述脚本转换为一个字符串(大家可以直接复制):

String lockScript =
        " if redis.call('get',KEYS[1]) == ARGV[1] "
        + " then "
        +   " return redis.call('del',KEYS[1]) "
        + " else "
        +   " return 0 "
        + " end "
        ;

在通过redis调用即可:

1-11 setnx 锁自动续期

遗留问题思考:

  •  我在这里设置了30秒,如果业务执行时间很长,需要35秒,这个时候还没等业务执行完毕就释放锁了,那么其他请求就会进来处理共享资源,那么锁其实就失效了,没起到作用了。

前面我们设置了超时时间,但是如果真的业务执行很耗时,超时了,那么我们应该给他自动续期啊。

redis service其实是多线程的,开启(fork)一个子线程,定时检查,如果lock还在,则在超时时间重置,如此循环,直到业务完成后删除锁。(或者使用while死循环也行)

LUA脚本:

String checkScript =
        " if redis.call('get',KEYS[1]) == ARGV[1] "
        + " then "
        +   " return redis.call('expire',KEYS[1],30) "
        + " else "
        +   " return 0 "
        + " end "
        ;

转换为字符串去运行:

if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('expire',KEYS[1],30) else return 0 end

EVAL "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('expire',KEYS[1],30) else return 0 end" 1 lock abc

运行后,原来永不过期的,现在被附上了30秒时间,表示这段脚本没问题

java代码实现

使用自定义定时线程池,不好控制去关闭。使用定时器工具组件

延迟delay毫秒后,执行第一次task,然后每隔period毫秒执行一次task。

解锁的时候接触定时任务

测试

在业务代码中增加sleep测试

那么执行过程中,会经过几次的续期,结束了,就释放timer。

1-12 Redisson 概述与入门整合

Redisson: Easy Redis Java client and Real-Time Data Platform
https://github.com/redisson/redisson

和Jedis以及RedisTemplate一样,Redisson其实也是redis的一个客户端。

Redisson里面封装了很多有用的api和功能实现,非常实用,当然也包含了分布式锁。Jedis这样的客户端仅仅只是把提供了客户端调用,很多功能其实需要自己去实现封装的。Redisson所提供的是实用redis最简单最便捷的方法,Redisson的宗旨也是让我们使用者关注业务本身,而不是要更关注redis,要把redis这块分离,使得我们的精力更加集中于业务上。

Redisson内部结合实用了LUA脚本实现了分布式锁,并且可以对其做到续约释放等各项功能,非常完善。当然也包含了JUC里面的一些锁,JUC里面的只能在本地实现,集群分布式下则失效,如果要使用则可以使用Redisson提供的工具来实现锁就行了。

入门示例

在api工程加入maven依赖:

<dependency>
   <groupId>org.redisson</groupId>
   <artifactId>redisson</artifactId>
   <version>3.19.0</version>
</dependency>  

配置redisson放入容器:

还原service代码:
整合redisson:

上面的代码其实就是设计为可重入锁,不多赘述,简单来讲,就是方法运行,可以多次使用同一把锁。或者说一个线程在不释放的情况下可以获得锁多次,不过在释放的时候也需要释放多次。(有兴趣课后建议去学习一下JUC相关内容)

测试

apipost测试接口最终结果的顺序即可。

1-13 Redisson 分布式锁测试

Redisson 常用基本配置

测试

  1. 拔电源测试会否解锁
  2. 自动续期测试(看门狗)
  3. lock设置自定义时间,比如15秒,超时是否自动续期(无看门狗)
  4. 测试可重入锁(用同一把锁):重入2次,释放2次,

1-14 Redisson 分布式锁底层源码品读

https://github.com/redisson/redisson/wiki/8.-分布式锁和同步器

加锁

lock实现如下:

底层本质上就是运行了一个lua脚本:

脚本解读:

  • 先通过exists判断锁是否存在,如果为0则表示锁不存在
  • 锁不存在,则创建一个锁并且设置过期时间
  • 如果hexists判断是否存在,判断是自己的锁,则重置过期时间(第二个判断可以理解为锁的重入)
  • 如果两个判断都不通过,则返回一个pttl的毫秒时间

自动续期,超时时间为30s,每次续期3/1时间

加锁成功后会调用scheduleExpirationRenewal这个方法:


这个方法的目的就是为的定时过期时间的重置。

此方法内部本质上也是一个timer的定时器:

脚本解读:

  • 判断这个锁是不是自己的
  • 如果是自己的锁,则重置时间
  • 成功返回1,失败返回0

成功继续递归,失败则取消:

解锁

最终通过脚本会删除lock

解锁后最终取消自动续期的定时器:

1-15 Redisson 公平锁Fair Lock

公平锁其实就是对所有人公平,大家去抢购商品去饭店吃饭,并不是一拥而入,而是有顺序的。那么上一节课的默认锁是非公平的,大家都可以抢,谁抢到了就谁获得锁。而公平锁则不是,我们不能让一开始来的人等太久啊,对吧,我们去吃饭,第一个排队的,那么必然第一个等候进去吃饭,而不是让后面来的先插队,这样多不公平啊。所以redisson也提供公平锁的机制让我们去进行实现,也就是当有很多线程同时申请锁的时候,这些线程都会进入先进先出的一个队列,只有前面的才会优先获得锁,其他线程只有等到前面的锁释放了,才会被分配锁,此时,这个锁的全称可以称之为:可重入的公平锁。

公平锁和分布式锁概念区别:公平锁也是分布式锁,但是分布式锁是为了保证业务的独占,处理公共资源的时候,不被其他请求(或者线程)影响,而公平锁是有序的顺序的去抢锁,和业务本身没有关系,不要搞混。

测试

由于apipost模拟的请求是在执行完毕后在发送的,再次我们可以通过打断点,在网页端进行模拟测试

测试结果取消,目前公平锁的顺序是无法保证的,最后一个访问的请求插队了:

使用公平锁后可以解决该问题。

1-16 Redisson 联锁MultiLock

当一个请求线程需要同时处理多个共享资源的时候,可以使用联锁,也就是一次性申请多个锁,同时锁住多个共享资源,这个联锁可以防止死锁的出现。

比如我们修改企业的时候,虽然现在只有一个共享资源,但是如果企业表水平分割了,分为了多个字表,那么字表其实也是共享资源,又或者说修改企业的同时还修改了其他共享资源,这个时候,其实都应该加锁的,就得使用联锁。如此,相关的共享资源都可以有原子性保障。

此外,联锁可以由不同的redisson实例来创建,如此,如果一个redisson实例节点宕机了,那么联锁就会失效。

联锁使用不多。

1-17 Redisson 红锁RedLock 以及其算法阐述

使用率不多,了解即可。

Redis分布式锁有一个致命的弱点,那就是redis服务器如果宕机了,那么锁肯定不能使用了,必定存在问题。

扩展主从哨兵:

如果使用主从哨兵,也有问题,setnx数据还没来得及同步给slave,这个时候master宕机,那么某个slave成为新的master,那么这个时候是没有锁的数据的,此时并发请求进来,将会再次获得锁(第二把新锁),那么此时就有问题了,从而导致锁机制失效。

集群形态,三主三从,其实也是同样的情况,也有可能造成丢失锁。

这个时候,我们需要使用红锁redlock的算法来进行处理,保证锁是OK不会失效。红锁是redis特有的专属算法,其他中间件不具备。

  1. 有5个redis节点,他们相互独立,都是独立运行在不同的服务器节点里的。
  2. 获得锁之前,先获得当前的时间戳,用于后面的计算。
  3. 从5个redis中去获得锁,每个节点都获得一下,使用setnx和之前一样,并且也需要设置锁的过期时间。如果获得锁时间太长则超时失败,因为这个节点可能宕机了,此时就跳过继续往下一个redis实例去尝试获得锁。
  4. 计算每个节点获得锁的时间,综合必须小于设置锁的时间,比如每个节点消耗了10秒,总计50秒,而我们的锁设置30秒,那肯定不行。
  5. 设置的节点数一般为单数,保证半数以上获得锁成功就表示当前获得分布式锁是OK的。
  6. 假设每个节点消耗1秒,那么初始超时的30秒,减去5秒,剩余25秒,那么此时会在25秒后释放锁,而不是30秒。
  7. 如果获得锁失败,则需要对所有节点的锁进行释放,因为我们不知道哪个节点成功哪个节点失败,所以统一对所有的节点进行解锁unlock操作。
  8. 如果业务操作成功,则对所有节点释放锁即可。

所以,如果面试的时候被问到,如果redis挂了,分布锁失效,你应该要回答红锁方面的相关内容,而不是说保证redis高可用,说集群高可用的意义不大。

1-18 Redisson 读写锁ReadWriteLock

并发的请求主要有并发读并发写,那么读写锁呢,就是更加细化的控制,都是并发写请求,或者并发读写请求则不行,并发读呢是可以的。

测试:

  • 写写:上一个请求写入完毕后,下一个请求才能执行写操作,和之前的锁机制一致
  • 写读:读请求需要等待上一个写操作完毕后,才能读,避免读取到脏数据
  • 读读:无所谓,可以并发
  • 查看rdm,redis中写锁只能有1个,读锁可以存在多个

1-19 Redisson 信号量Semaphore

Redisson的Semaphore本质上和JUC的意思一样,只是可以在分布式下更完善。

Semaphore信号量本质上也是用于限流的,比如红绿灯可以限制车流量,如果没有红绿灯,那么车流量一旦很大,那么基本上就会造成交通瘫痪。又比如节假日去迪士尼或者环球电影城,人流量很大,车位就会很紧缺,很多时候停车场的入口处都会有一个指示牌,显示剩余空余的停车位还有多少个,这个也是信号量。再比如,我没去吃饭,餐厅只能容纳10个人,那么后面的人就得排队,吃完一桌去进去一个人,这也是同样的道理。

基于JUC的信号量

基于JUC的信号量,可以测试锁住的资源,释放后,才会被后续

使用场景:可以用于限流,比如现在我有1万个并发,系统处理不过来,只能处理300个左右的请求,这个时候我们就只允许进来300个线程可以访问,超过的只能等待。

基于Redisson的信号量

上面的JUC信号量在分布式下会失效的,所以需要借助redisson来实现

测试:加锁和释放可以分开作为两个单独方法,手动控制,加锁过程可以观察redis。

1-20 Redisson 闭锁CountDownLatch

闭锁:所有资源全部准备好,才算成功。也能称之为(倒计数)计数器。
场景:我们做危化需要发车,装货的时候不是装好货了,才能走,我们有一个CountDownLatch,会有很多步骤,每个步骤必须做完并且检查完毕(所有的步骤数 ),才算好,这个时候我们有个信号灯,才会从红色变为绿色,否则车子是走不了的,因为我们运输的是危险化学品,并不是说走就走的,必须等待一切资源就绪,才能发车,要不然会相当危险。

  • 6
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值