当前位置: 首页 > 科技观察

如果有人问你什么是分布式锁,就把这篇文章发给他

时间:2023-03-18 12:46:20 科技观察

现在面试一般都会说分布式系统。通常面试官会从服务框架(SpringCloud、Dubbo)开始,一路讲分布式事务、分布式锁、ZooKeeper等知识。那么说一下分布式锁的知识,首先我们来看看Redis分布式锁的实现原理。说实话,如果在公司生产环境使用分布式锁,肯定是用开源库,比如Redis分布式锁,一般就用Redisson框架,非常好用。有兴趣的可以去Redisson官网看看如何在项目中引入Redisson的依赖,进而实现基于Redis的分布式锁的加锁和释放。让我向您展示一个简单的代码片段以供使用。首先我们来一个直观感受:上面的代码怎么样,是不是感觉很简单?此外,它们还支持Redis单实例、Redissentinel、RedisCluster、redismaster-slave等多种部署架构,可以给你最好的实现。Redisson实现Redis分布式锁的底层原理还是不错的。下面通过一张手绘图,告诉大家Redisson这个开源框架是如何实现Redis分布式锁的。锁定机制让我们看一下上图。现在客户端需要锁定。如果客户端面对的是RedisCluster集群,他会先根据Hash节点选择一台机器。这里注意,选机器就行!这个非常重要!然后,一个Lua脚本将被发送到Redis。Lua脚本如下:为什么要用Lua脚本?因为很多复杂的业务逻辑可以封装在一个Lua脚本中发送给Redis来保证这个复杂业务逻辑执行的原子性。那么,这个Lua脚本是什么意思呢?这里KEYS[1]代表你锁定的Key,例如:RLocklock=redisson.getLock("myLock");在这里你自己设置锁定的锁定密钥是“myLock”。ARGV[1]表示锁定密钥的默认生命周期,默认为30秒。ARGV[2]表示被锁定客户端的ID,类似如下:8743c9c0-0795-4907-87fd-6c719a6b4586:1。我给大家解释一下,第一段中的if判断语句是用existsmyLock命令来判断的。如果要锁定的锁Key不存在,则将其锁定。如何锁定它?很简单,使用以下命令:hsetmyLock。8743c9c0-0795-4907-87fd-6c719a6b4586:11.通过这个命令设置一个Hash数据结构。这条命令执行后,会出现类似下面的数据结构:上面的意思是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”客户端已经完成锁定锁Key“myLock”。然后执行“pexpiremyLock30000”命令设置锁匙myLock的生命时间为30秒。好了,至此,锁定就完成了。锁互斥机制那么这个时候,如果客户端2尝试加锁,执行同一个Lua脚本,会发生什么情况呢?很简单,第一个if判断会执行“existsmyLock”,发现myLock的锁key已经存在。然后第二个if判断,判断myLock锁Key的Hash数据结构中是否包含客户端2的ID,显然不是,因为里面包含了客户端1的ID。因此,客户端2会得到一个pttlmyLock返回的数字,它代表锁KeymyLock的剩余生命周期。例如,还剩15000毫秒可活。此时client2会进入一个while循环,不断尝试加锁。看门狗自动延长机制。客户端1锁定的锁定密钥的默认生命周期仅为30秒。如果超过30秒,客户端1还想持有锁。我应该怎么办?简单的!一旦client1加锁成功,就会启动看门狗。它是一个后台线程,每10秒检查一次。如果client1还持有lockkey,那么lockkey的生命周期会不断延长。可重入锁机制如果client1已经持有锁,那么可重入锁会怎样呢?比如下面的代码:我们来分析一下上面的Lua脚本。第一个if判断肯定不成立,“existsmyLock”会显示lockKey已经存在。第二次if判断会为真,因为myLock的Hash数据结构中包含的ID是客户端1的ID,即“8743c9c0-0795-4907-87fd-6c719a6b4586:1”。这时候就会执行可重入锁的逻辑。他会使用:incrbymyLock8743c9c0-0795-4907-87fd-6c71a6b4586:11.通过这个命令,客户端1的锁数会加1。此时myLock数据结构变成如下:可以看到,myLockHash数据结构中的clientID对应着锁的数量。释放锁机制如果执行lock.unlock(),就可以释放分布式锁,此时的业务逻辑也很简单。其实说白了就是每次myLock数据结构中的锁数减1。如果发现锁的数量为0,说明客户端不再持有锁。这时候会使用“delmyLock”命令从Redis中删除Key。然后,另一个客户端2可以尝试完成锁定。这就是所谓的分布式锁开源Redisson框架的实现机制。一般在生产系统中,我们可以使用Redisson框架提供的类库来实现基于Redis的分布式锁的加锁和释放。上面提到的Redis分布式锁的缺点上面的方案最大的问题是,如果把myLock这样的锁key的值写入到一个RedisMaster实例中,会被异步复制到对应的MasterSlave实例中。但是这个过程中一旦RedisMaster宕机,主备切换,RedisSlave变成RedisMaster。那么就会导致当client2尝试加锁的时候,在新的RedisMaster上加锁完成,client1也认为加锁成功。这时候,多个客户端会锁定一个分布式锁。这时候系统肯定会在业务语义上出现问题,导致各种脏数据的产生。所以这就是RedisCluster造成的Redis分布式锁最大的缺陷,或者说redis主从架构的主从异步复制:当RedisMaster实例宕机时,多个客户端可能同时完成加锁。七张图彻底解释了ZooKeeper分布式锁的实现原理。先说一下ZooKeeper实现分布式锁的原理。同样的,我直接基于Curator这个比较常用的开源框架,讲一下这个框架对ZooKeeper(以下简称ZK)分布式锁的实现。一般除了大公司自己封装分布式锁框架外,建议大家使用这些开源框架来实现分布式锁。这是一种更快、更省事的方式。ZooKeeper分布式锁机制接下来我们看一下多客户端获取和释放ZK分布式锁的整个过程以及背后的原理。首先,我们来看下图。如果有两个client想要竞争ZK上的一个分布式锁,会发生什么?如果对ZK了解不多,建议先百度。简单了解一些基本概念,比如ZK有哪些节点类型等等。见上图。ZK中有一把锁,这把锁就是ZK上的一个节点。然后,两个客户端都必须获取此锁。具体如何获取呢?假设客户端A迈出了第一步,发起了向ZK添加分布式锁的请求。这个锁请求在ZK中使用了一个特殊的概念,叫做“临时序列节点”。简单点说,就是在锁节点“my_lock”的正下方创建一个时序节点。这个时序节点有一个由ZK自己维护的节点序号。比如第一个客户端来搭建一个时序节点,ZK内部会命名为:xxx-000001。然后第二个client来创建一个时序节点,ZK可能会命名为:xxx-000002。请注意,最后一个数字是从1开始依次递增的,ZK会保持这个顺序。所以这个时候,如果客户端A先发起请求,就会产生一个顺序节点。看下图,Curator框架大概是这样的:你看,客户端A发起锁请求。将在您要锁定的节点下创建一个临时顺序节点。这一大堆长名字是Curator框架自己生成的。然后,最后一位数字是“1”。请注意,因为客户端A是第一个发起请求的,所以顺序节点的序号为“1”。然后客户端A完成创建一个顺序节点。还没完,他会去查锁节点“my_lock”下的所有子节点,而这些子节点是按照序号排序的,这时候他大概会得到这样一个集合:那么客户端A会去一个key判断,也就是说:嘿!大哥,这个合集里,我创建的序列节点排在第一位吗?如果是这样,那我可以锁定它!因为很明显我是***我是第一个创建顺序节点的人,所以我是第一个尝试加分布式锁的人!答对了!添加锁成功!请看下图,大家直观感受下整个过程:那么,如果客户端A加完锁后,客户端B过来要加锁。这时候,他会做同样的事情:先在锁节点“my_lock”下创建一个临时的序列节点,名字会变成这样的:我们看下图:客户端B是第二个创建的一个序列节点,所以ZK会在内部维护序列号为“2”。然后clientB会按照锁判断逻辑,查询“my_lock”锁节点下的所有子节点,并按照序号顺序排列。这时,他看到的类似于:同时查看自己创建的时序节点是否在集合中的第一个?显然不是,第一个是客户端A创建的序号为“01”的序号节点。所以锁失败了!锁失效后,客户端B会通过ZKAPI为其序列节点的前一个序列节点添加监听器。ZK自然可以监控某个节点。如果不知道ZK的基本用法,可以百度一下,很简单。客户端B的时序节点是:它的最后一个时序节点,不就是下面那个吗?即客户端A创建的时序节点!所以,客户端B会在这个节点上添加一个监听器,监听这个节点是否被删除等变化!看下图:那么,客户端A加锁后,可能会处理一些代码逻辑,然后释放锁。那么,释放锁的过程是怎样的呢?其实很简单,就是删除你在ZK中创建的顺序节点,也就是删除这个节点。删除那个节点后,ZK会负责通知监听这个节点的监听器,也就是之前客户端B添加的监听器,说:兄弟,你监听的节点被删除了,有人释放了锁。这时,客户端B的监听器感知到之前的顺序节点已经被删除,即在他前面的一个客户端释放了锁。此时会通知客户端B再次尝试获取锁,即获取“my_lock”节点下的子节点集合。此时:集合中此时只有客户端B创建的唯一一个时序节点!那么好吧,客户端B判断自己其实是集合中的第一个顺序节点,Bingo!可以上锁!只要完成加锁,运行后续的业务代码,运行完再次释放锁即可。其实如果有客户端C、客户端D等N个客户端竞争一个ZK分布式锁,原理是类似的:大家上来直接在一个锁节点下一个接一个地创建临时时序节点。如果您不是第一个节点,请将侦听器添加到您的最后一个节点。只要前一个节点释放了锁,就会排到最前面,相当于一个排队机制。使用临时顺序节点的另一个目的是,如果客户端在创建临时顺序节点后不小心崩溃了,也没有关系。ZK在感知到客户端宕机时,会自动删除对应的临时时序节点。用于自动释放锁,或者自动取消自己的队列。***,先看一下使用Curator框架加锁和释放锁的过程:其实实用的开源框架还是不错的,方便。Curator框架的ZK分布式锁加锁和释放锁的实现原理就是我们上面说的。但是如果你想手动实现一组代码。考虑各种细节,异常处理等等还是有点繁琐。所以如果你考虑使用ZK分布式锁,可以参考这篇文章的思路。每秒千单场景下的分布式锁高并发优化实践那我就和大家聊一个有意思的话题:每秒千单场景下分布式锁的并发能力如何优化?首先我们来看一下这个问题的背景?前段时间有个朋友在外面面试,然后有一天他和我聊天:国内有个不错的电商公司,面试官给了他一道场景题:如果下单的时候,用分布式锁防止库存超卖,但是是每秒几千单的高并发场景。如何针对高并发优化分布式锁来应对这种场景?他说他当时没有回答,因为他没有任何想法。其实听到这个面试题的时候,我也觉得有点意思,因为如果让我去面试应聘者,我应该给的范围更广一些。比如让面试的同学说一下电商高并发秒杀场景下超卖库存的解决方案,各种方案的优缺点和实践,然后再聊分布式锁的话题。因为解决库存超卖问题的技术有很多,比如悲观锁、分布式锁、乐观锁、队列序列化、Redis原子操作等等。但是由于面试官小哥仅限于使用分布式锁来解决超卖库存,所以我估计只想问一点:高并发场景下如何优化分布式锁的并发性能。我觉得面试官提问的角度是可以接受的,因为在实际生产中,分布式锁保证了数据的准确性,但是它天生的并发能力有点弱。正好之前在自己项目的其他场景做过一个高并发场景的分布式锁优化方案,所以正好趁着这位朋友的面试题,给大家分享一下分布式锁的高并发优化思路。聊聊天。库存超卖现象是如何产生的?首先我们来看一下如果不使用分布式锁,所谓的电商库存超卖是什么意思。我们看下图:这张图其实很清晰。假设订单系统部署在两台机器上,不同用户想同时购买10部iPhone,分别向订单系统发送请求。然后每个订单系统实例去数据库查询,当前iPhone库存为12台。两个大哥看着,都乐了。12台库存大于10台采购量!于是,订单系统的每一个实例都向数据库发送SQL下单,然后减去10个单位的库存,其中一个把库存从12个单位减去2个单位,另一个从2个单位减去库存到-8个单位。现在完了,库存为负!泪目,送两个用户的20部iPhone都没有!这怎么能好。如何使用分布式锁解决库存超卖问题?我们如何使用分布式锁来解决库存超卖的问题呢?其实很简单。回想一下我们上次讲的分布式锁的实现原理:同一个锁Key,同一时间只有一个客户端能拿到锁,其他客户端会陷入绝望的等待,试图拿到锁。只有拿到锁的客户端才能执行下面的业务逻辑。代码看起来像上面的代码。我们现在分析一下。为什么这样做可以避免库存超卖?大家可以按照上面步骤的序号,马上就明白了。从上图可以看出,订单系统只有一个实例可以成功添加分布式锁,然后只有一个实例可以检查库存,判断库存是否充足,下单扣除库存,然后释放锁。释放锁后,可以锁定订单系统的另一个实例。然后查看库存,发现库存只有2台。库存不足,无法采购,订单失败。不会将库存减少到-8。有没有其他办法解决库存超卖的问题?当然有!比如悲观锁、分布式锁、乐观锁、队列序列化、异步队列分散、Redis原子操作等等,很多解决方案,我们有一些针对超卖库存的解决方案,有自己的一套优化机制。但是前面说了,这篇文章讲的是分布式锁的并发优化,不是超卖库存的解决方案,所以超卖库存只是一个业务场景。分布式锁方案在高并发场景下表现不错。下面我们来看看,分布式锁方案在高并发场景下有什么问题?问题很大!兄弟,不知道你看不看。添加分布式锁后,同一商品的订单请求会导致所有客户端锁定同一商品的库存锁key。例如,下单购买产品iPhone时,必须锁定锁钥“iphone_stock”。这会导致对同一产品的订单请求,必须对其进行序列化并逐一处理。回去反复看上图,应该能搞清楚这个问题。假设加锁后,释放锁前,查看库存→创建订单→扣除库存,这个过程性能很高,整个过程耗时20毫秒,应该不错。那么1秒就是1000毫秒,只能容纳50个请求串行处理这个产品。比如一秒来50个请求,都是iphone的订单,那么每个请求20毫秒处理,一个一个处理,1000毫秒处理50个请求。看看下面的图片,加深你的感受。所以看到这里,大家至少明白单纯使用分布式锁来处理超卖库存的弊端了。缺点是当多个用户同时下单同一商品时,会基于分布式锁串行处理,无法同时处理同一商品的大量订单。这种方案如果是处理普通的低并发、无秒杀场景的小型电商系统,或许还可以接受。因为如果并发很低,每秒请求不到10个。如果没有单品的瞬间高并发秒杀,其实同一商品1秒下单1000单是很少见的,因为小电商系统没有那种场景。如何针对高并发优化分布式锁?好了,终于进入正题了,那么现在该怎么办呢?面试官说,我现在卡了,超卖库存用分布式锁解决,一秒iPhone下的几千单怎么优化?现在按照刚才的计算,一秒钟只能处理50个iPhone订单。其实说起来很简单。相信很多人都看过Java中ConcurrentHashMap的源码和底层原理。他们应该知道里面的核心思想,就是分段锁!把数据分成很多段,每个段是一个单独的锁,这样当多个线程来并发修改数据的时候,可以同时修改不同段的数据。并不是说只有一个线程可以同时独占修改ConcurrentHashMap中的数据。另外,Java8新增了一个LongAdder类,同样针对Java7之前的AtomicLong进行了优化,解决了CAS操作在高并发场景下使用乐观锁,会导致大量线程重复循环for的问题很长时间。LongAdder也使用了类似的分段CAS操作,如果失败会自动迁移到下一个分段进行CAS。其实分布式锁的优化思路也是类似的。之前,我们在另一个业务场景中实现了这个方案并投入生产,并不是针对库存超卖的问题。但是超卖库存这个业务场景很好,也很容易理解,所以我们就拿这个场景来举例。看下图:这是分段锁定。如果您目前有1,000部iPhone库存,那么您可以将它们分成20个库存部分。如果你愿意,你可以在数据库表中创建20个库存字段,比如stock_01,stock_02等。你也可以把20个库存键放在像Redis这样的地方。简而言之就是拆解你的1000件库存给他,每个库存段是50件库存,比如stock_01对应50件库存,stock_02对应50件库存。然后,每秒1000个请求来了,好!这时候你其实可以自己写一个简单的随机算法。每个请求在20个段位股票中随机抽取一个,选出一个进行锁定。答对了!这样就好了,最多同时执行20个订单请求。每个订单请求锁定一个库存段,然后在业务逻辑中,对数据库或Redis中的分段库存进行处理。操作即可,包括查看库存→判断库存是否充足→扣除库存。这相当于什么?相当于20毫秒,可以并发处理20个订单请求。那么在1秒内,可以顺序处理20*50=1000个iPhone订单请求。一条数据切分后,有一个坑大家一定要注意:如果某个订单请求被点击锁定,然后发现切分的库存里面的库存不足,怎么办这次?这时候就得自动释放锁,然后马上换到下一个段库存,再尝试加锁,尝试处理。这个过程必须要实现。分布式锁并发优化方案有哪些缺点?一定有一些缺点。最大的不足是很不方便,实现起来也太复杂了:首先,你要分段存储一段数据。一个存货栏本来是好的,现在要分成20个存货栏。其次,每次处理库存的时候,都得自己写一个随机算法,随机选择一个段来处理。***,如果一个segment中的数据不足,就得自动切换到下一个segment数据处理。这个过程是通过手工写代码实现的,还是比较费工夫和麻烦的。但是我们确实在一些业务场景中使用了分布式锁,那么我们就要优化锁并发,进一步使用分段锁的技术方案,效果当然非常好,并发性能可以提升几十倍。本次优化方案的后续改进:以我们在本文中提到的超卖库存场景为例。这么玩下去,会让自己很痛苦的!同样,这里的超卖库存场景只是一个演示场景。.作者:中华狮山中华狮山:十余年BAT架构经验,一线互联网公司技术总监。带领数百人团队开发过亿级大流量高并发系统。多年工作积累的研究手稿和经验总结,现整理成文,一一传授。微信公众号:石山的建筑笔记(ID:shishan100)。