2. Spring Boot使用Apache Curator实现分布式锁(可重入排它锁)「第四章 ZooKeeper Curator应用场景实战」「架构之路ZooKeeper理论和实战」
在前面我们将Curator集成到了Spring Boot项目中,这一节我们看看Curator支持的应用场景之一:分布式锁
一、基本概念
1.1 分布式锁
为了防止分布式系统中的多个进程之间相互干扰,我们需要一种分布式协调技术来对这些进程进行调度。而这个分布式协调技术的核心就是来实现这个分布式锁。
1.2 分布式锁应该具备哪些条件
(1)在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行
(2)高可用的获取锁与释放锁
(3)高性能的获取锁与释放锁
(4)具备可重入特性(可理解为重新进入,由多于一个任务并发使用,而不必担心数据错误)
(4)具备锁失效机制,防止死锁
(5)具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败
1.3分布式锁的实现有哪些
(1)Memcached:利用 Memcached 的 add 命令。此命令是原子性操作,只有在 key 不存在的情况下,才能 add 成功,也就意味着线程得到了锁。
(2)Redis:和 Memcached 的方式类似,利用 Redis 的 setnx 命令。此命令同样是原子性操作,只有在 key 不存在的情况下,才能 set 成功。
(3)Zookeeper:利用 Zookeeper 的顺序临时节点,来实现分布式锁和等待队列。Zookeeper 设计的初衷,就是为了实现分布式锁服务的。
(4)Chubby:Google 公司实现的粗粒度分布式锁服务,底层利用了 Paxos 一致性算法。
二、Curator实现分布式锁
2.1 说明
使用Curator实现分布式锁非常的简单,核心类InterProcessMutex,看官网的示例代码:
2.2 例子业务场景说明
接下来我们实现一个下单减库存的例子:
我们假设一个这样的业务场景:
在电商中,用户购买商品需要扣减商品库存,一般有两种扣减库存方式:
(1)下单减库存
优点:用户体验好,下单成功,库存直接扣除,用户支付不会出现库存不足情况
缺点:用户一直不付款,这个商品的库存就会被占用,其他人就无法购买了。
(2)支付减库存
优点:不会导致库存被恶意锁定,对商家有利。
缺点:用户体验不好,用户支付时可能商品库存不足了,会导致用户交易失败。
那么,我们一般为了用户体验,会采用下单减库存。但是为了解决下单减库存的缺陷,会创建一个定时任务,定时去清理超时未支付的订单。
2.3 未使用分布式锁的情况下
2.3.1 编码说明
这块就不进行具体展开说明了,但为了文章的完整性,这里简单说明下:
(1)表:产品表product和订单表order_info。
(2)实体类:Product和OrderInfo。
(3)持久化类:ProductRepository和OrderInfoRepository,这里使用的是Spring Data JPA进行数据库的操作。
(4)服务层:主要是下单减库存。
(5)使用多线程模拟发起下单请求:
2.3.2 测试说明
我们现在数据库有一条数据:
库存是10,然后发起请求进行测试一下,就会出现库存为负数的了:
2.4 使用分布式锁的情况下
使用分布式锁的核心就是进行调用方法之前,获取锁才能进行往下操作,否则进行等待获取锁,在操作完成之后,记得释放锁资源。
- long productId = 1;
- InterProcessMutex interProcessMutex = new InterProcessMutex(curatorFramework, "/product_"+productId);
- try {
- interProcessMutex.acquire();//加锁
- shopService.reduceStock(productId);
- } catch (Exception e) {
- e.printStackTrace();
- }finally {
- try {
- interProcessMutex.release();
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
三、Zookeeper 分布式锁加锁原理
从上面我们可以对于分布式锁的使用,简单的很,那么这个具体是怎么一个逻辑呐?如果你也有这样的疑问,请跟随文章进行往下探索。
3.1 方式一:非公平锁的实现
看如下的一个设计图:
(1)获取锁:首先判断当前锁是否已经有其他事务创建,如果创建了的话,那么就进行监听,等待被唤醒;如果没有被其它事务进行创建的话,那么就会进入下一步。这里有个小问题就是如何判断是否已经被其它事务创建呐?看过前面章节的就会关注到一个一个点,当执行create指令的时候,如果节点存在那么就会报错,无法创建成功。
(2)创建/exclusive/lock:执行create指令创建节点,如果创建失败,那么同样的进入监听等待;如果创建成功的话,那么就会获得锁,进行业务处理,业务处理完成之后,释放锁。
(3)其它节点监听:这时候其它节点监听就会收到收到释放锁的监听信息,也就是delete 节点改变的通知,然后这些节点在回到(1)进行争取锁。
但是这种方式在并发的情况下存在严重的问题,性能会下降的比较厉害,主要原因是:所有的连接都在对同一个节点进行监听,当服务器检测到删除事件时,要通知所有的连接,所有的连接同时收到事件,再次并发竞争,这就是羊群效应。
知识小百科:
(1)羊群效应
在羊群中,总有那么一些“出类拔萃”的,对肥美的小嫩草天生敏感的“领头羊”,只要它们开始行动,其他羊就会不假思索地群起响应,即便附近可能有狼,或者有更好的草地,它们也会全然不顾。这就是典型的“羊群效应”。而在这一点上,人也没比羊聪明到哪里去。我们习惯于通过观察其他人的行为来提取信息,并做出快速而省心的决定,而信息的不断趋同,彼此强化,最终就会产生跟风从众的“羊群效应”。
投资中的“羊群效应”: 我们在投资过程中,“羊群效应”在炒股与投资基金方面体现得尤为明显。对于投资经验相对欠缺的个人投资者而言,在自己举棋不定,犹豫不决时,总是会去看看其他同类投资者是怎么买的,什么时候买的,又是什么时候卖的。他们总认为在同一群体中的其他人更具有信息优势。而这样一来,个人投资者的投资资金将迅速汇聚,很容易形成趋同性的“羊群效应”,上涨时蜂拥而至,下跌时恐慌逃散,也就是我们常说的“追涨杀跌”现象。
那怎么去理解zk中的“羊群效应”?
zk的客户端可以在znode上添加一个watch,用来监听znode相关事件并被通知
羊群效应就是 一个特定的znode 改变的时候ZooKeper 触发了所有watches 的事件。举个例子,如果有1000个客户端watch 一个znode的exists调用,当这个节点被创建的时候,将会有1000个通知被发送。这种由于一个被watch的znode变化,导致大量的通知需要被发送,将会导致在这个通知期间的其他操作提交的延迟。因此,只要可能,我们都强烈建议不要这么使用watch。仅仅有很少的客户端同时去watch一个znode比较好,理想的情况是只有1个。
举个例子,有n 个clients 需要去拿到一个全局的lock.
一种简单的实现就是所有的client 去create 一个/lock znode.如果znode 已经存在,只是简单的watch 该znode 被删除。当该znode 被删除的时候,client收到通知并试图create /lock。这种策略下,就会存在上文所说的问题,每次变化都会通知所有的客户端。(羊群效应)
(2)公平锁和非公平锁
公平锁:
就是很公平,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,以后会按照FIFO的规则从队列中取到自己。
公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁:
上来就直接尝试占有锁,如果尝试失败,就再采用类似公平锁那种方式。
非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
3.1 方式二:公平锁的实现
看如下的设计图:
(1)请求进来,直接在/lock节点下创建一个临时顺序节点。
(2)判断自己是不是lock节点下最小的节点。
① 是最小的,获得锁。
② 不是。对前面的节点进行监听(watch)。
(3)获得锁的请求,处理锁,即delete节点,然后后继第一个节点收到通知,重复第2步骤判断。
如上借助于临时顺序节点,可以避免同时多个节点的并发竞争锁,缓解了服务端压力。这种实现方式所有加锁请求都进行排队加锁,是公平锁的具体实现。
这里我们思考一种情况:对于临时节点,我们之前说过当前session断开之后就会被删除了,那么他的下个节点监听的是它,这样子会不会有问题呐?
答案是不会的。当session断开,节点被删除就会通知监听它的下个节点,这样子下个节点就会执行它的回调方法,找到比它还小的节点,也就是被删除的节点的上一个节点。
四、Curator 分布式锁源码分析
接下来我们看下Curator加锁的原理,我们来看看是不是按照我们上面说的思路来进行设计的。
(1)首先我们看下new InterProcessMutex:
这里我们看到LockInternalsDriver的实现类是StandardLockInternalsDriver。
(2)接下来我们看一下获取锁的代码:interProcessMutex.acquire();
进入到acquire()代码中:
通过上面能知道真正去获取锁的代码是attemptLock
(3)attemptLock():
在attemptLock()方法中核心要关注的地方就是这个while循环:
(4)createsTheLock ():
这个就是创建锁,也就是创建节点,点击进去看下:
可以看出这里就是创建节点了,父类节点是容器节点,子节点是临时顺序节点,和上面我们介绍的思路很像。
如果成功就会返回创建成功的路径,到这里还没有获得锁的哦,往下看。
(5)internalLockLoop ():
这个就是获取锁的方法了:
这里我说一下这里面代码执行的事情:
(1)getSortedChildren():获取某个节点下的所有排序的子节点。
(2)driver.getsTheLock(): 获取锁。
这里判断很简单,就是获取到当前这个节点在已经排序的节点的位置,然后和maxLeases(这个在构造方法的时候赋值为1)进行比较,如果是在第一个位置也是就是最小的那个位置,那么就是获取到了锁,否则不是。并且紧接着不是的情况下,会返回它要watch的下个节点的路径。
(3)获取锁:一种就是获取成功了,一种情况就是获取失败了,那么就会wait(),这个方法是线程的Thread的等待方法,那么什么时候被唤醒呐?
等待能唤醒的情况那就是watch被调用了,我们看下这里的watch的实现:
进入到client.postSafeNotify():
这里就是去唤醒所有在等待的线程了。到这里我们就清晰了。
整个流程符合我们之前说的方案的思路。
五、小结
这一节内容可能有点小多,大家可以收藏起来慢慢消化一下,最后我挑几个重点给大家总结下:
5.1 分布式锁的使用
核心类是InterProcessmutex,核心代码如下:
5.2 Curator实现分布式锁的原理:
(1)请求进来,直接在/lock节点下创建一个临时顺序节点(父节点是容器节点)。
(2)判断自己是不是lock节点下最小的节点。
① 是最小的,获得锁。
② 不是。对前面的节点进行监听(watch)。
(3)获得锁的请求,处理锁,即delete节点,然后后继第一个节点收到通知,重复第2步骤判断。
5.3 羊群效应:
“羊群效应”可以理解为一种从众心理,跟风、随大流,别人干什么,我也干什么。
Zookeeper中的羊群效应就是 一个特定的znode 改变的时候ZooKeper 触发了所有watches 的事件。被监听的节点就是这只领头羊,其它监听这个节点watches事件就是其它羊,如果被监听的节点改变了,那么就会通知所有监听的事件,引起羊群效应。
购买完整视频,请前往:http://www.mark-to-win.com/TeacherV2.html?id=287