分布式锁有哪些特性?
互斥性:锁的目的是获取资源的使用权,所以只让一个竞争者持有锁,这一点要尽可能保证;
安全性:避免死锁情况发生。当一个竞争者在持有锁期间内,由于意外崩溃而导致未能主动解锁,其持有的锁也能够被正常释放,并保证后续其它竞争者也能加锁;
对称性:同一个锁,加锁和解锁必须是同一个竞争者。不能把其他竞争者持有的锁给释放了,这又称为锁的可重入性。
可靠性:需要有一定程度的异常处理能力、容灾能力。
分布式锁的常用实现方式
布式锁,一般会依托第三方组件来实现,而利用Redis实现则是工作中应用最多的一种。
最简化版本直接用Redis的setnx命令,这个命令的语法是:
setnx key value
如果key不存在,则会将key设置为value,并返回1;如果key存在,不会有任务影响,返回0。
基于这个特性,我们就可以用setnx实现加锁的目的:通过setnx加锁,加锁之后其他服务无法加锁,用完之后,再通过delete解锁,深藏功与名。
支持过期时间
最简化版本有一个问题:如果获取锁的服务挂掉了,那么锁就一直得不到释放,就像石沉大海,杳无音信。所以,我们需要一个超时来兜底。
Redis中有expire命令,用来设置一个key的超时时间。但是setnx和expire不具备原子性,如果setnx获取锁之后,服务挂掉,依旧是泥牛入海。
很自然,我们会想到,set和expire,有没有原子操作?
当然有,Redis早就考虑到了这种场景,推出了如下执行语句:
set key value nx ex seconds
nx表示具备setnx特定,ex表示增加了过期时间,最后一个参数就是过期时间的值。
能够支持过期时间,目前这个锁基本上是能用了。
但是存在一个问题:会存在服务A释放掉服务B的锁的可能。
加上owner
我们来试想一下如下场景:服务A获取了锁,由于业务流程比较长,或者网络延迟、GC卡顿等原因,导致锁过期,而业务还会继续进行。这时候,业务B已经拿到了锁,准备去执行,这个时候服务A恢复过来并做完了业务,就会释放锁,而B却还在继续执行。
在真实的分布式场景中,可能存在几十个竞争者,那么上述情况发生概率就很高,导致同一份资源频繁被不同竞争者同时访问,分布式锁也就失去了意义。
基于这个场景,我们可以发现,问题关键在于,竞争者可以释放其他人的锁。那么在异常情况下,就会出现问题,所以我们可以进一步给出解决方案:分布式锁需要满足谁申请谁释放原则,不能释放别人的锁,也就是说,分布式锁,是要有归属的。
引入Lua
加入owner后的版本可以称得上是完善了吗?还有没有什么隐患呢?
牛牛也不卖关子了,到这一步其实还存在一个小问题,我们完整的流程是竞争者获取锁执行任务,执行完毕后检查锁是不是自己的,最后进行释放。
流程一梳理,你们肯定明白了,执行完毕后,检查锁,再释放,这些操作不是原子化的。
可能锁获取时还是自己的,删除时却已经是别人的了。这可怎么办呢?
Redis可没有直接提供这种场景原子化的操作啊。遇事不要慌,仔细想一想,Redis是不是还有个特性,专门整合原子操作,对,就是它——Lua。
Redis➕Lua,可以说是专门为解决原子问题而生。
有了Lua的特性,Redis才真正在分布式锁、秒杀等场景,有了用武之地,下面便是改造之后的流程:
其实到了这一步,分布式锁的前三个特性:对称性、安全性、可靠性,就满足了。可以说是一个可用的分布式锁了,能满足大多数场景的需要。
PART4.
可靠性如何保证
分布式锁的四大特性还剩下可靠性没有解决。
针对一些异常场景,包括Redis挂掉了、业务执行时间过长、网络波动等情况,我们来一起分析如何处理。
前面我们谈及的内容,基本是基于单机考虑的,如果Redis挂掉了,那锁就不能获取了。这个问题该如何解决呢?
一般来说,有两种方法:主从容灾和多级部署。
主从容灾
最简单的一种方式,就是为Redis配置从节点,当主节点挂了,用从节点顶包。
但是主从切换,需要人工参与,会提高人力成本。不过Redis已经有成熟的解决方案,也就是哨兵模式,可以灵活自动切换,不再需要人工介入。
通过增加从节点的方式,虽然一定程度解决了单点的容灾问题,但并不是尽善尽美的,由于同步有时延,Slave可能会损失掉部分数据,分布式锁可能失效,这就会发生短暂的多机获取到执行权限。
有没有更可靠的办法呢?
多机部署
如果对一致性的要求高一些,可以尝试多机部署,比如Redis的RedLock,大概的思路就是多个机器,通常是奇数个,达到一半以上同意加锁才算加锁成功,这样,可靠性会向ETCD靠近。
现在假设有5个Redis主节点,基本保证它们不会同时宕掉,获取锁和释放锁的过程中,客户端会执行以下操作:
1.向5个Redis申请加锁;
2.只要超过一半,也就是3个Redis返回成功,那么就是获取到了锁。如果超过一半失败,需要向每个Redis发送解锁命令;
3.由于向5个Redis发送请求,会有一定时耗,所以锁剩余持有时间,需要减去请求时间。这个可以作为判断依据,如果剩余时间已经为0,那么也是获取锁失败;
4.使用完成之后,向5个Redis发送解锁请求。
这种模式的好处在于,如果挂了2台Redis,整个集群还是可用的,给了运维更多时间来修复。
另外,多说一句,单点Redis的所有手段,这种多机模式都可以使用,比如为每个节点配置哨兵模式,由于加锁是一半以上同意就成功,那么如果单个节点进行了主从切换,单个节点数据的丢失,就不会让锁失效了。这样增强了可靠性。
可靠性深究
是不是有RedLock,就一定能保证可靠的分布式锁?
这里牛牛先说结论:由于分布式系统中的三大困境(简称NPC),所以没有完全可靠的分布式锁!
让我们来看看RedLock在NPC下的表现。
N:Network Delay(网络延迟)
当分布式锁获得返回包的时间过长,此时可能虽然加锁成功,但是已经时过境迁,锁可能很快过期。RedLock算了做了些考量,也就是前面所说的锁剩余持有时间,需要减去请求时间,如此一来,就可以一定程度解决网络延迟的问题。
P:Process Pause(进程暂停)
比如发生GC,获取锁之后GC了,处于GC执行中,然后锁超时。
其他锁获取,这种情况几乎无解。这时候GC回来了,那么两个进程就获取到了同一个分布式锁。
也许你会说,在GC回来之后,可以再去查一次啊?
这里有两个问题,首先你怎么知道GC回来了?这个可以在做业务之前,通过时间,进行一个粗略判断,但也是很吃场景经验的;第二,如果你判断的时候是ok的,但是判断完GC了呢?这点RedLock是无法解决的。
C:Clock Drift(时钟漂移)
如果竞争者A,获得了RedLock,在5台分布式机器上都加上锁。为了方便分析,我们直接假设5台机器都发生了时钟漂移,锁瞬间过期了。这时候竞争者B拿到了锁,此时A和B拿到了相同的执行权限。
根据上述的分析,可以看出,RedLock也不能扛住NPC的挑战,因此,单单从分布式锁本身出发,完全可靠是不可能的。要实现一个相对可靠的分布式锁机制,还是需要和业务的配合,业务本身要幂等可重入,这样的设计可以省却很多麻烦。
总结
我们围绕互斥性、安全性、对称性层层递进,实现了一个Redis分布式锁,这样的架构在大多数业务场景都是完全够用的。
同时,我们也针对可靠性,探讨了主从容灾、Red Lock等解决方案,并分析了NPC异常场景,了解到分布式锁在什么情况会失去作用,这些知识在实际的业务中都非常实用,能够在实际开发中做出正确的决策。
PlantUML是一款开源的UML图绘制工具,支持通过文本来生成图形,使用起来非常高效。可以支持时序图、类图、对象图、活动图、思维导图等图形的绘制。
下面使用PlantUML来绘制一张流程图,可以实时预览,速度也很快!
通过在IDEA中安装插件来使用PlantUML无疑是最方便的,接下来我们来安装下IDEA的PlantUML插件。
首先在IDEA的插件市场中搜索PlantUML
,安装这个排名第一的插件;
有时候网络不好的话可能下载不下来,可以点击Plguin homepage
按钮访问插件主页,然后选择合适的版本下载压缩包;
下载成功后,选择从本地安装即可。
接下来我们使用PlantUML插件分别绘制时序图、用例图、类图、活动图、思维导图,来体验下PlantUML是不是真的好用!
时序图(Sequence Diagram),是一种UML交互图。它通过描述对象之间发送消息的时间顺序显示多个对象之间的动态协作。我们在学习Oauth2的时候,第一步就是要搞懂Oauth2的流程,这时候有个时序图帮助可就大了。下面我们使用PlantUML来绘制Oauth2中使用授权码模式颁发令牌的时序图。
首先我们需要新建一个PlantUML文件,选择时序图;
我们可以通过PlantUML提供的语法来生成Oauth2的时序图,语法还是非常简单的,具体内容如下;
@startuml
title Oauth2令牌颁发之授权码模式
actor User as user
participant "User Agent" as userAgent
participant "Client" as client
participant "Auth Login" as login
participant "Auth Server" as server
autonumber
user->userAgent:访问客户端
activate userAgent
userAgent->login:重定向到授权页面+clientId+redirectUrl
activate login
login->server:用户名+密码+clientId+redirectUrl
activate server
server-->login:返回授权码
login-->userAgent:重定向到redirectUrl+授权码code
deactivate login
userAgent->client:使用授权码code换取令牌
activate client
client->server:授权码code+clientId+clientSecret
server-->client:颁发访问令牌accessToken+refreshToken
deactivate server
client-->userAgent:返回访问和刷新令牌
deactivate client
userAgent--> user:令牌颁发完成
deactivate userAgent
@enduml
该代码将生成如下时序图,用写代码的方式来画时序图,是不是够炫酷;
本时序图关键说明如下:
title
可以用于指定UML图的标题;
通过actor
可以声明人形的参与者;
通过participant
可以声明普通类型的参与者;
通过as
可以给参与者取别名;
通过->
可以绘制参与者之间的关系,虚线箭头可以使用-->
;
在每个参与者关系后面,可以使用:
给关系添加说明;
通过autonumber
我们可以给参与者关系自动添加序号;
通过activate
和deactivate
可以指定参与者的生命线。
这里还有个比较神奇的功能,当我们右键时序图时,可以生成一个在线访问的链接;
直接访问这个链接,可以在线访问UML时序图,并进行编辑,是不是很酷!
用例图(Usecase Diagram)是用户与系统交互的最简表示形式,展现了用户和与他相关的用例之间的关系。通过用例图,我们可以很方便地表示出系统中各个角色与用例之间的关系,下面我们用PlantUML来画个用例图。
首先我们需要新建一个PlantUML文件,选择用例图,该用例图用于表示顾客、主厨、美食家与餐馆中各个用例之间的关系,具体内容如下;
@startuml
left to right direction
actor Guest as g
package Professional {
actor Chief as c
actor "Food Critic" as fc
}
package Restaurant {
usecase "Eat Food" as uc1
usecase "Pay For Food" as uc2
usecase "Drink" as uc3
usecase "Review" as uc4
}
g--> uc1
g--> uc2
g--> uc3
fc--> uc4
@enduml
该代码将生成如下用例图;
本用例图关键说明如下:
left to right direction
表示按从左到右的顺序绘制用例图,默认是从上到下;
通过package
可以对角色和用例进行分组;
通过actor
可以定义用户;
通过usecase
可以定义用例;
角色和用例之间的关系可以使用-->
来表示。
类图(Class Diagram)可以表示类的静态结构,比如类中包含的属性和方法,还有类的继承结构。下面我们用PlantUML来画个类图。
首先我们需要新建一个PlantUML文件,选择类图,该图用于表示Person、Student、Teacher类的结构,具体内容如下;
@startuml
class Person {
# String name
# Integer age
+ void move()
+ void say()
}
class Student {
- String studentNo
+ void study()
}
class Teacher {
- String teacherNo
+ void teach()
}
Person <|-- Student
Person <|-- Teacher
@enduml
该代码将生成如下类图,看下代码和类图,是不是发现和我们用代码定义类还挺像的;
本类图关键说明如下:
通过class
可以定义类;
通过在属性和方法左边加符号可以定义可见性,-
表示private
,#
表示protected
,+
表示public
;
通过<|--
表示类之间的继承关系。
活动图(Activity Diagram)是我们用的比较多的UML图,经常用于表示业务流程,比如电商中的下单流程就可以用它来表示。下面我们用PlantUML来画个活动图。
首先我们需要新建一个PlantUML文件,选择活动图,这里使用了mall项目中购物车中生成确认单的流程,具体内容如下;
@startuml
title 生成确认单流程
start
:获取购物车信息并计算好优惠;
:从ums_member_receive_address表中\n获取会员收货地址列表;
:获取该会员所有优惠券信息;
switch(根据use_type判断每个优惠券是否可用)
case(0)
:全场通用;
if (判断所有商品总金额是否\n满足使用起点金额) then (否)
:得到用户不可用优惠券列表;
stop
endif
case(-1)
:指定分类;
if (判断指定分类商品总金额\n是否满足使用起点金额) then (否)
:得到用户不可用优惠券列表;
stop
endif
case(-2)
:判断指定商品总金额是否满足使用起点金额;
if (判断指定分类商品总金额\n是否满足使用起点金额) then (否)
:得到用户不可用优惠券列表;
stop
endif
endswitch
:得到用户可用优惠券列表;
:获取用户积分;
:获取积分使用规则;
:计算总金额,活动优惠,应付金额;
stop
@enduml
该代码将生成如下活动图,在活动图中我们既可以用if else
,又可以使用switch
,甚至还可以使用while循环
,功能还是挺强大的;
本活动图关键说明如下:
通过start
和stop
可以表示流程的开始和结束;
通过:
和;
中间添加文字来定义活动流程节点;
通过if
+then
+endif
定义条件判断;
通过switch
+case
+endswitch
定义switch判断。
思维导图(Mind Map),是表达发散性思维的有效图形工具,它简单却又很有效,是一种实用性的思维工具。之前在我的mall学习教程中就有很多地方用到了,下面我们用PlantUML来画个思维导图。
首先我们需要新建一个PlantUML文件,选择思维导图,这里使用了mall学习路线中的大纲视图,具体内容如下;
@startmindmap
+[#17ADF1] mall学习路线
++[#lightgreen] 推荐资料
++[#lightblue] 后端技术栈
+++_ 项目框架
+++_ 数据存储
+++_ 运维部署
+++_ 其他
++[#orange] 搭建项目骨架
++[#1DBAAF] 项目部署
+++_ Windows下的部署
+++_ Linux下使用Docker部署
+++_ Linux下使用Docker Compose部署
+++_ Linux下使用Jenkins自动化部署
--[#1DBAAF] 电商业务
---_ 权限管理模块
---_ 商品模块
---_ 订单模块
---_ 营销模块
--[#orange] 技术要点
--[#lightblue] 前端技术栈
--[#lightgreen] 进阶微服务
---_ Spring Cloud技术栈
---_ 项目部署
---_ 技术要点
--[#yellow] 开发工具
--[#lightgrey] 扩展学习
@endmindmap
该代码将生成如下思维导图,其实使用PlantUML我们可以自己定义图形的样式,这里我自定义了下颜色;
本思维导图关键说明如下:
通过+
和-
可以表示思维导图中的节点,具有方向性;
通过[#颜色]
可以定义节点的边框颜色;
通过_
可以去除节点的边框;
虽然目前可以绘制UML图的图形化工具很多,但是对于程序员来说,使用代码来绘图可能更直接,效率更高,尤其是配合IDEA使用。如果你想使用代码来绘图,不妨尝试下PlantUML吧。
官方文档:https://plantuml.com/zh/
下面是三种限流的方式:
当然,限流有许多种实现的方式,Redis具有很强大的功能,我用Redis实践了三种的实现方式,可以较为简单的实现其方式。Redis不仅仅是可以做限流,还可以做数据统计,附近的人等功能,这些可能会后续写到。
我们在使用Redis的分布式锁的时候,大家都知道是依靠了setnx的指令,在CAS(Compare and swap)的操作的时候,同时给指定的key设置了过期实践(expire),我们在限流的主要目的就是为了在单位时间内,有且仅有N数量的请求能够访问我的代码程序。所以依靠setnx可以很轻松的做到这方面的功能。
比如我们需要在10秒内限定20个请求,那么我们在setnx的时候可以设置过期时间10,当请求的setnx数量达到20时候即达到了限流效果。代码比较简单就不做展示了。
当然这种做法的弊端是很多的,比如当统计1-10秒的时候,无法统计2-11秒之内,如果需要统计N秒内的M个请求,那么我们的Redis中需要保持N个key等等问题
其实限流涉及的最主要的就是滑动窗口,上面也提到1-10怎么变成2-11。其实也就是起始值和末端值都各+1即可。
而我们如果用Redis的list数据结构可以轻而易举的实现该功能
我们可以将请求打造成一个zset数组,当每一次请求进来的时候,value保持唯一,可以用UUID生成,而score可以用当前时间戳表示,因为score我们可以用来计算当前时间戳之内有多少的请求数量。而zset数据结构也提供了range方法让我们可以很轻易的获取到2个时间戳内有多少请求
代码如下
public Response limitFlow(){
Long currentTime = new Date().getTime();
System.out.println(currentTime);
if(redisTemplate.hasKey("limit")) {
Integer count = redisTemplate.opsForZSet().rangeByScore("limit", currentTime - intervalTime, currentTime).size(); // intervalTime是限流的时间
System.out.println(count);
if (count != null && count > 5) {
return Response.ok("每分钟最多只能访问5次");
}
}
redisTemplate.opsForZSet().add("limit",UUID.randomUUID().toString(),currentTime);
return Response.ok("访问成功");
}
通过上述代码可以做到滑动窗口的效果,并且能保证每N秒内至多M个请求,缺点就是zset的数据结构会越来越大。实现方式相对也是比较简单的。
提到限流就不得不提到令牌桶算法了。
令牌桶算法提及到输入速率和输出速率,当输出速率大于输入速率,那么就是超出流量限制了。
也就是说我们每访问一次请求的时候,可以从Redis中获取一个令牌,如果拿到令牌了,那就说明没超出限制,而如果拿不到,则结果相反。
依靠上述的思想,我们可以结合Redis的List数据结构很轻易的做到这样的代码,只是简单实现
依靠List的leftPop来获取令牌
// 输出令牌
public Response limitFlow2(Long id){
Object result = redisTemplate.opsForList().leftPop("limit_list");
if(result == null){
return Response.ok("当前令牌桶中无令牌");
}
return Response.ok(articleDescription2);
}
再依靠Java的定时任务,定时往List中rightPush令牌,当然令牌也需要唯一性,所以我这里还是用UUID进行了生成
// 10S的速率往令牌桶中添加UUID,只为保证唯一性
@Scheduled(fixedDelay = 10_000,initialDelay = 0)
public void setIntervalTimeTask(){
redisTemplate.opsForList().rightPush("limit_list",UUID.randomUUID().toString());
}
综上,代码实现起始都不是很难,针对这些限流方式我们可以在AOP或者filter中加入以上代码,用来做到接口的限流,最终保护你的网站。
Redis其实还有很多其他的用处,他的作用不仅仅是缓存,分布式锁的作用。他的数据结构也不仅仅是只有String,Hash,List,Set,Zset。有兴趣的可以后续了解下他的GeoHash算法;BitMap,HLL以及布隆过滤器数据(Redis4.0之后加入,可以用Docker直接安装redislabs/rebloom)结构。