目录
1-17 Redisson 红锁RedLock 以及其算法阐述
1-18 Redisson 读写锁ReadWriteLock
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字段,提供版本号的支持
- 查询记录,当前version为0
- 更新记录,设置当前记录的version为1(累加1)。但是,更新的sql语句需要添加
where version=0
- 如果当前有很多人都要更新,那么他们都能获得当前的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 概述与入门整合![](https://i-blog.csdnimg.cn/direct/e4dc9f750563463b97b563baea2fb17e.jpeg)
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 常用基本配置
测试
- 拔电源测试会否解锁
- 自动续期测试(看门狗)
- lock设置自定义时间,比如15秒,超时是否自动续期(无看门狗)
- 测试可重入锁(用同一把锁):重入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特有的专属算法,其他中间件不具备。
- 有5个redis节点,他们相互独立,都是独立运行在不同的服务器节点里的。
- 获得锁之前,先获得当前的时间戳,用于后面的计算。
- 从5个redis中去获得锁,每个节点都获得一下,使用setnx和之前一样,并且也需要设置锁的过期时间。如果获得锁时间太长则超时失败,因为这个节点可能宕机了,此时就跳过继续往下一个redis实例去尝试获得锁。
- 计算每个节点获得锁的时间,综合必须小于设置锁的时间,比如每个节点消耗了10秒,总计50秒,而我们的锁设置30秒,那肯定不行。
- 设置的节点数一般为单数,保证半数以上获得锁成功就表示当前获得分布式锁是OK的。
- 假设每个节点消耗1秒,那么初始超时的30秒,减去5秒,剩余25秒,那么此时会在25秒后释放锁,而不是30秒。
- 如果获得锁失败,则需要对所有节点的锁进行释放,因为我们不知道哪个节点成功哪个节点失败,所以统一对所有的节点进行解锁unlock操作。
- 如果业务操作成功,则对所有节点释放锁即可。
所以,如果面试的时候被问到,如果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,会有很多步骤,每个步骤必须做完并且检查完毕(所有的步骤数 ),才算好,这个时候我们有个信号灯,才会从红色变为绿色,否则车子是走不了的,因为我们运输的是危险化学品,并不是说走就走的,必须等待一切资源就绪,才能发车,要不然会相当危险。