锁产生的原因
锁是将一个空间进行封闭的工具。
在日常生活中,我们需要在一个独处的空间做一些自己的(不为人知的...)事情,在这个期间,我们就希望在这段时间内不被人打扰,就将这个空间关闭,只留下我一个人。
其他人想进来也可以,但是那必须要等到我出去,你才能进来。我不出去,有一个算一个,谁TM也别想进来😡,要不然你换一家,要不然在外面等着(你说什么?等多长时间?我也不知道🤷♂️,看心情吧.....)
就像我在商场的移动KTV里面唱歌,我总不希望在我唱歌的时候,有个陌生人突然闯进来说:"你唱歌太NM难听了,让开,我来唱..."(PS:你管我,NB你先进来,让我在外面等着啊。就习惯这种看不惯我,又无能为力的样子...略...略...略...打我啊!)。
在程序中我们经常会遇见这种场景,在并发场景中,两个请求在某一瞬间同时操作相同的业务数据,但是这两个请求需要在全局一个个的执行,如果都同时执行会造成不可预知的影响。
比如我们经常使用的某宝订单,订单一般是由 订单主信息 和 订单明细信息 两个部分组成
订单主信息: 收货人姓名,电话,地址以及订单的总金额信息 等信息
订单明细信息: 商品的名称,数量,单价,优惠,总计金额 等信息
现在假设订单主信息中的订单总金额是由明细中的总计金额合计而成的(这不是废话吗?我买的东西总金额不是商品明细的总金额还是什么?你说什么?还有运费?。。。正经商家谁还收运费啊!(ToT)/~~~)。
现在假设一个场景是同时有两个请求,一个请求是订单中增加商品A的明细数量,一个请求是商品明细中添加商品B的明细数量,每个请求都需要重新计算订单的总金额信息
订单主信息
| 订单ID | 收货人 | 收货地址 | 订单总金额 |
|---|---|---|---|
| 110 | 法外狂徒张三 | 青城监狱557号 | 404 |
订单明细信息
| 订单明细ID | 订单ID | 商品ID | 商品名称 | 数量 | 单价 | 合计金额 |
|---|---|---|---|---|---|---|
| 110-1 | 110 | A | 越狱神器-飞天钩 | 2 | 110 | 220 |
| 110-2 | 110 | B | 越狱辅助神器-窜天猴 | 1 | 184 | 184 |
现在有两个请求同时到达
请求1:由于窜天猴和飞天钩是配合使用的,万一这个窜天猴挂了,那计划就实施不了了,再买一个窜天猴备用着,这个请求是商品B窜天猴购买数量+1,变成2个
请求2.飞天钩两个了,但是飞天钩这货质量实在不咋地,听说好多同伴都折在它的手里了,惹不起,惹不起,多买一个备着吧,多花点钱也比折了好些,于是商品 A 飞天钩 的购买数量+1,变成了3个
这个时候你会想,这两个并行时,谁先操作,谁后操作结果不都是一样的吗?最后的结果不都是A产品3个,B产品2个吗
最后的结果是
订单主信息
| 订单ID | 收货人 | 收货地址 | 订单总金额 |
|---|---|---|---|
| 110 | 法外狂徒张三 | 青城监狱557号 | 698 |
订单明细信息
| 订单明细ID | 订单ID | 商品ID | 商品名称 | 数量 | 单价 | 合计金额 |
|---|---|---|---|---|---|---|
| 110-1 | 110 | A | 越狱神器-飞天钩 | 3 | 110 | 330 |
| 110-2 | 110 | B | 越狱辅助神器-窜天猴 | 2 | 184 | 368 |
不论哪个先执行,哪个后执行都没有什么影响啊!!!
不错,请注意你的前提条件是,不论哪个先执行,还是哪个后执行,都没有什么影响。是一个先,一个后,是一个请求执行完了,然后执行下一个请求。那么有意思的问题来了,如果同时执行会有什么影响呢?(一个请求先执行,但是没有进行事务提交,此时也认为是并行操作,有关MySQL数据库事务请移步查询 MVVC)
流程分析
第一个请求
它要操作的是B商品的数量+1,那么它在这个时刻获取的订单明细为
| 订单明细ID | 订单ID | 商品ID | 商品名称 | 数量 | 单价 | 合计金额 |
|---|---|---|---|---|---|---|
| 110-1 | 110 | A | 越狱神器-飞天钩 | 2 | 110 | 220 |
| 110-2 | 110 | B | 越狱辅助神器-窜天猴 | 1 | 184 | 184 |
好了,现在我要操作B商品数量+1,修改成订单明细为
| 订单明细ID | 订单ID | 商品ID | 商品名称 | 数量 | 单价 | 合计金额 |
|---|---|---|---|---|---|---|
| 110-1 | 110 | A | 越狱神器-飞天钩 | 2 | 110 | 220 |
| 110-2 | 110 | B | 越狱辅助神器-窜天猴 | 2 | 184 | 368 |
因此订单主信息为
| 订单ID | 收货人 | 收货地址 | 订单总金额 |
|---|---|---|---|
| 110 | 法外狂徒张三 | 青城监狱557号 | 588 |
第二个请求
它要操作的是A的商品数量+1,由于是并行请求,此时它获取的订单明细也为
| 订单明细ID | 订单ID | 商品ID | 商品名称 | 数量 | 单价 | 合计金额 |
|---|---|---|---|---|---|---|
| 110-1 | 110 | A | 越狱神器-飞天钩 | 2 | 110 | 220 |
| 110-2 | 110 | B | 越狱辅助神器-窜天猴 | 1 | 184 | 184 |
现在针对A商品数量+1,修改订单明细为
| 订单明细ID | 订单ID | 商品ID | 商品名称 | 数量 | 单价 | 合计金额 |
|---|---|---|---|---|---|---|
| 110-1 | 110 | A | 越狱神器-飞天钩 | 3 | 110 | 330 |
| 110-2 | 110 | B | 越狱辅助神器-窜天猴 | 1 | 184 | 184 |
因此订单主信息为
| 订单ID | 收货人 | 收货地址 | 订单总金额 |
|---|---|---|---|
| 110 | 法外狂徒张三 | 青城监狱557号 | 514 |
故事到这里就结束了,最后无论是哪个数据最终落库,最后都是和张三想要的东西是不一样的,不是A少了一个,就是B少了一个,张三获取自由生涯的历程也是心惊胆战的。
因此为了保证类似于张三这样的人不在经历这些跌宕起伏的刺激场景,由此诞生了锁的概念:这个东西现在只有我一个人可以进行操作,其他人排队去。
锁也分为很多种类型:轻量级锁,重量级锁,自旋锁,排他锁,可重入锁,互斥锁,读写锁。。。
上述的那些锁不在本次讲述范围之内,各位看官可以自行去查询,在各个不同的场景使用不同的锁
系统中锁的类型
现在我们的目的就是想在并发场景下将流程控制住,保证在某一段时刻,只能有一个工作者操作这个业务数据。
后端单机模式
后端单机模式,由于请求最后都收口于后端,此时后端对于修改订单的接口,根据订单ID进行加锁即可
synchronized (订单ID){
// doSomething();
}
由于是单机模式,这种方案能够简单,高效的工作。
但是由于现在都是分布式系统了,你再敢这样写,CR上接收暴风雨的来临吧。
分布式模式
分布式锁主要有redis锁,zookeeper(zk),RedLock,etcd锁,如果有不了解这几个组件的,可以先去学习看一下。
redis锁
简单粗暴setNx
秘诀要点:setNx 当key不存在时,返回1,存在时返回0
这就就是有请求进来了,针对要操作的业务主键,不管三七二十八,先来一个setNx,来看看返回结果。
哎呦,小伙子,不错嘛,你看根据你的业务主键执行这个小玩意儿返回的是1,拿到锁了,来...来...来....大爷里面请,小程(程序),有大爷来了,出来工作了!!!
又来了一个,我看看啊,先使用你的业务主见执行这个我看看,哎呦,返回的是0哎,竟然是0哎,来来来,小伙子,在这里等着吧,等不了就走吧,有客观把小程的房门锁了,不方便出来。等上一个客人走了,把锁释放了,你再试一下吧。
但是呢?这个模式有个问题,就是当原有获取锁的客人突然跳窗了,暴毙了,里面的小程一脸的懵逼。客人都没有了,我在这儿闲着?外面的人也不知道这种情况,一直以为里面有客人,这就成了一个围城:外面的人想进去,里面没有人出来。。。。
添加持有锁过期时间
因此,针对这种情况,就衍生出了,获取锁的时候,添加一个持有锁的时间,过了规定的时间,即使里面的人没有出来,外面的人也可以获取锁,但是在锁没有自动释放前,其他人还是不允许进来
这个加锁就变成了这样
public boolean lock() {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("订单ID", "XXXXX");
if (flag) {
stringRedisTemplate.expire("订单ID", 1, TimeUnit.SECONDS);
}
return flag;
}
这个加锁方式打眼一看吧,没有什么问题,加锁成功之后,然后设置超时时间。细细一品吧,有些不对劲儿啊,你加锁和设置超时时间不是在一起的啊,要是你加完锁之后,还没有设置超时时间就暴毙了怎么办,我去,大家还是想进去的进不去,里面没有人出来啊
添加锁时记录预期过期时间
上面的问题是由于在设置过期时间之前,持有锁的人就挂了,那么我把将来的过期时间设置在redis对应的value中不就行了吗?其他人获取不到锁,看一下对应的value和当前时间相比,如果当前时间 > 设置的 value,说明这个锁就是要过期的,其他人可以删除旧锁,添加新锁。嘿嘿,我可真是个小机灵鬼 !!!
serNx + expire
上面的是由于setNx与setExpire不是原子性的搞出来那么多东西, redis后期版本提供 setNx + expire原子性操作,就省去了上述的麻烦
RedLock
redLock是用于redis集群上获取分布式锁的一个工具 它要求只要集群有一半以上的master可以注册这个key,就认为是获取锁成功了 流程步骤: 1.获取当前时间戳 2.轮流尝试在每个master节点上创建锁 3.如果在大多数节点上创建锁成功,说明获取锁,否则属于创建锁失败 4.客户端计算好创建锁时间,如果超时就创建失败 5.如果锁创建失败,以此删除这个锁
优点
1.性能高
问题
你以为这样就结束了,不会吧,你不会真的这么认为吧,哪有这么简单的事情
我们说了这么多都是关于设置时间,保证redis数据不会永久存在,造成一直等待的情况,下面我们说说它还有哪些问题
1.超时时间怎么定义?比如预期持有锁时间为5s,结果有一个异常,执行了一个小时。由于5s之后,锁就自动释放了,其他线程仍然可以获取锁,执行相应的任务,和预期不符
2.如果redis是集群,master和slave是异步同步机制,如果在master上申请锁之后,结果master挂了,此时数据没有同步到slave,从slave 中选择一个节点为master之后,锁丢失
3.如果存在线程Thread1 在 T1 时间获取锁,T2时间锁过期,T5时间执行完,那么在T2时间之后,会存在其他线程Thread2在 T3 时间获取锁,T4时间执行完,T6时间释放锁,T1 < T2 < T3 < T4 < T5 < T6 那么会存在Thread1在T5时刻执行完之后,对锁进行释放操作,但是此时持有锁的线程是 Thread2 而不是Thread1,释放了别人的锁
4.如果消费者挂了之后,其他消费者需要等到一定时间之后才能继续获取锁
对于问题1而言,设置一个守护线程,当工作线程没有执行完的时候,一直给redis对应的key增加过期时间,进行续命操作,有想法,未实现
对于问题2而言,没有深入考察
对于问题3而言,删除锁的时候,比对 value数据是否与自己添加锁时的value数据是否一致,一致时才可以进行删除
感觉使用redis作为分布式锁还是有很大的问题
zk锁
zookeeper是一个分布式应用程序协调服务,它可以注册四种节点,并提供监听机制
临时节点,临时顺序节点,持久化节点,持久化顺序节点
临时节点和临时顺序节点,可以手动删除或者客户端不存在自动删除 持久化节点,持久化顺序节点,只能手动删除
当客户端写入临时节点成功,可以认为是获取到分布式锁
使用临时顺序节点获取分布式锁,并监听
下面说一下优缺点吧
优点
1.不用关注持有锁的时间 2.持有锁线程挂了可以自行释放
缺点
1.zk读写性能不高
etcd
据说etcd和redis差不多,不过能够自行续命
好了,写到这里也差不多了。。。