信不信,90程序猿都不会正确使用分布式

中科公益爱心 http://m.39.net/disease/a_6169821.html

大家好,我是沐子。

分布式锁的话题,很多文章已经写烂了,我为什么还要写这篇文章呢?

因为我发现网上90%的文章,并没有把这个问题真正讲清楚。导致很多读者看了很多文章,依旧云里雾里。例如下面这些问题,你能清晰地回答上来吗?

数据库通过乐观锁怎么实现分布式锁?

基于Redis如何实现一个分布式锁?

Redis如何避免死锁?

Redis如何合理的设置超时时间?

Zookeeper如何规避羊群效应?

三种分布式锁的优缺点分别是什么?

这篇文章,我就来把这些问题彻底讲清楚。

读完这篇文章,你不仅可以彻底了解分布式锁,还会对「分布式系统」有更加深刻的理解。

一、为什么需要分布式锁

为了保证一个方法在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用Java并发处理相关的API(如ReentrantLcok或synchronized)进行互斥控制。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题。

二、常见的分布式锁方案

我们在使用分布式锁的时候,大部分同学可能都忽略了一点,那就是分布式锁经常出现哪些问题,以及如何解决。

可用问题:无论何时都要保证锁服务的可用性(这是系统正常执行锁操作的基础)。

死锁问题:客户端一定可以获得锁,即使锁住某个资源的客户端在释放锁之前崩溃或者网络不可达(这是避免死锁的设计原则)。

脑裂问题:集群同步时产生的数据不一致,导致新的进程有可能拿到锁,但之前的进程以为自己还有锁,那么就出现两个进程拿到了同一个锁的问题。

总的来说,设计分布式锁服务,至少要解决上面最核心的几个问题,才能评估锁的优劣,一般分布式锁有三种常见的实现方式:

1.数据库乐观锁;

2.基于分布式缓存Redis的分布式锁;

3.基于ZooKeeper的分布式锁

1.基于关系型数据库实现分布式锁

1)基于悲观锁的方式实现分布式锁

基于关系型数据库(如MySQL)来实现分布式锁是任何阶段的研发同学都需要掌握的,做法如下:先查询数据库是否存在记录,为了防止幻读取(幻读取:事务A按照一定条件进行数据读取,这期间事务B插入了相同搜索条件的新数据,事务A再次按照原先条件进行读取时,发现了事务B新插入的数据)通过数据库行锁selectforupdate锁住这行数据,然后将查询和插入的SQL在同一个事务中提交。以订单表为例:

selectidfromorderwhereorder_id=xxxforupdate

基于关系型数据库实现分布式锁比较简单,不过你要注意,基于MySQL行锁的方式会出现交叉死锁,比如事务1和事务2分别取得了记录1和记录2的排它锁,然后事务1又要取得记录2的排它锁,事务2也要获取记录1的排它锁,那这两个事务就会因为相互锁等待,产生死锁。

当然,你可以通过“超时控制”解决交叉死锁的问题,但在高并发情况下,出现的大部分请求都会排队等待,所以“基于关系型数据库实现分布式锁”的方式在性能上存在缺陷。

2)基于乐观锁的方式实现分布式锁

在数据库层面,selectforupdate是悲观锁,会一直阻塞直到事务提交,所以为了不产生锁等待而消耗资源,你可以基于乐观锁的方式来实现分布式锁,比如基于版本号的方式,首先在数据库增加一个int型字段ver,然后在SELECT同时获取ver值,最后在UPDATE的时候检查ver值是否为与第2步或得到的版本值相同。

##SELECT同时获取ver值

selectamount,old_verfromorderwhereorder_id=xxx

##UPDATE的时候检查ver值是否与第2步获取到的值相同

updateordersetver=old_ver+1,amount=yyywhereorder_id=xxxandver=old_ver

此时,如果更新结果的记录数为1,就表示成功,如果更新结果的记录数为0,就表示已经被其他应用更新过了,需要做异常处理。

这个策略源于mysql的mvcc机制,使用这个策略其实本身没有什么问题,主要的问题就是对数据表侵入较大,我们要为每个表设计一个版本号字段,然后写一条判断sql每次进行判断,增加了数据库操作的次数,在高并发的要求下,对数据库连接的开销也是无法忍受的。

2.基于分布式缓存Redis实现分布式锁

因为数据库的性能限制了业务的并发量,所以针对“和双11大促”等请求量剧增的场景,需要引入基于缓存的分布式锁,这个方案可以避免大量请求直接访问数据库,提高系统的响应能力。基于缓存实现的分布式锁,就是将数据仅存放在系统的内存中,不写入磁盘,从而减少I/O读写。接下来,我以Redis为例讲解如何实现分布式锁。

我们从最简单的开始讲起。

想要实现分布式锁,必须要求Redis有「互斥」的能力,我们可以使用SETNX命令,这个命令表示SETifNoteXists,即如果key不存在,才会设置它的值,否则什么也不做。

两个客户端进程可以执行这个命令,达到互斥,就可以实现一个分布式锁。

客户端1申请加锁,加锁成功:

.0.0.1:SETNXlock1(integer)1//客户端1,加锁成功

客户端2申请加锁,因为它后到达,加锁失败:

.0.0.1:SETNXlock1(integer)0//客户端2,加锁失败

此时,加锁成功的客户端,就可以去操作「共享资源」,例如,修改MySQL的某一行数据,或者调用一个API请求。

操作完成后,还要及时释放锁,给后来者让出操作共享资源的机会。如何释放锁呢?

也很简单,直接使用DEL命令删除这个key即可:

.0.0.1:DELlock//释放锁(integer)1

这个逻辑非常简单,整体的路程就是这样:

但是,它存在一个很大的问题,当客户端1拿到锁后,如果发生下面的场景,就会造成「死锁」:

程序处理业务逻辑异常,没及时释放锁

进程挂了,没机会释放锁

这时,这个客户端就会一直占用这个锁,而其它客户端就「永远」拿不到这把锁了。怎么解决这个问题呢?

如何避免死锁?

我们很容易想到的方案是在Redis中实现时,就是给这个key设置一个「过期时间」。这里我们假设,操作共享资源的时间不会超过10s,那么在加锁时,给这个key设置10s过期即可:

.0.0.1:SETNXlock1//加锁(integer)1.0.0.1:EXPIRElock10//10s后自动过期(integer)1

这样一来,无论客户端是否异常,这个锁都可以在10s后被「自动释放」,其它客户端依旧可以拿到锁。

但这样真的没问题吗?

还是有问题。

现在的操作,加锁、设置过期是2条命令,有没有可能只执行了第一条,第二条却「来不及」执行的情况发生呢?例如:

SETNX执行成功,执行EXPIRE时由于网络问题,执行失败

SETNX执行成功,Redis异常宕机,EXPIRE没有机会执行

SETNX执行成功,客户端异常崩溃,EXPIRE也没有机会执行

总之,这两条命令不能保证是原子操作(一起成功),就有潜在的风险导致过期时间设置失败,依旧发生「死锁」问题。

怎么办?

在Redis2.6.12版本之前,我们需要想尽办法,保证SETNX和EXPIRE原子性执行,还要考虑各种异常情况如何处理。

但在Redis2.6.12之后,Redis扩展了SET命令的参数,用这一条命令就可以了:

//一条命令保证原子性执行.0.0.1:SETlock1EX10NXOK

这样就解决了死锁问题,也比较简单。

我们再来看分析下,它还有什么问题?

试想这样一种场景:

客户端1加锁成功,开始操作共享资源

客户端1操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」

客户端2加锁成功,开始操作共享资源

客户端1操作共享资源完成,释放锁(但释放的是客户端2的锁)

看到了么,这里存在两个严重的问题:

锁过期:客户端1操作共享资源耗时太久,导致锁被自动释放,之后被客户端2持有

释放别人的锁:客户端1操作共享资源完成后,却又释放了客户端2的锁

导致这两个问题的原因是什么?我们一个个来看。

第一个问题,可能是我们评估操作共享资源的时间不准确导致的。

例如,操作共享资源的时间「最慢」可能需要15s,而我们却只设置了10s过期,那这就存在锁提前过期的风险。

过期时间太短,那增大冗余时间,例如设置过期时间为20s,这样总可以了吧?

这样确实可以「缓解」这个问题,降低出问题的概率,但依旧无法「彻底解决」问题。

为什么?

原因在于,客户端在拿到锁之后,在操作共享资源时,遇到的场景有可能是很复杂的,例如,程序内部发生异常、网络请求超时等等。

既然是「预估」时间,也只能是大致计算,除非你能预料并覆盖到所有导致耗时变长的场景,但这其实很难。

有什么更好的解决方案吗?

别急,关于这个问题,我会在后面详细来讲对应的解决方案。

我们继续来看第二个问题。

第二个问题在于,一个客户端释放了其它客户端持有的锁。

想一下,导致这个问题的关键点在哪?

重点在于,每个客户端在释放锁时,都是「无脑」操作,并没有检查这把锁是否还「归自己持有」,所以就会发生释放别人锁的风险,这样的解锁流程,很不「严谨」!

如何解决这个问题呢?

有一个更加安全的方案是为set指令的value参数设置为一个随机数,释放锁时先匹配随机数是否一致,然后再删除key。但是匹配value和删除key不是一个原子操作,Redis也没有提供类似于delifequals这样的指令,这就需要使用Lua脚本来处理了,因为Lua脚本可以保证连续多个指令的原子性执行。

//释放锁时,先比较unique_value是否相等,避免锁的误释放

ifredis.call("get",KEYS[1])==ARGV[1]then

returnredis.call("del",KEYS[1])

else

return0

end

以上,就是基于Redis的SET命令和Lua脚本在Redis单节点上完成了分布式锁的加锁、解锁,不过在实际面试中,你不能仅停留在操作上,因为这并不能满足应对面试需要掌握的知识深度,所以你还要清楚基于Redis实现分布式锁的优缺点;Redis的超时时间设置问题;站在架构设计层面上Redis怎么解决集群情况下分布式锁的可靠性问题。需要注意的是,你不用一股脑全部将其说出来,而是要做好准备,以便跟上面试官的思路,同频沟通。

基于Redis实现分布式锁的优缺点

基于数据库实现分布式锁的方案来说,基于缓存实现的分布式锁主要的优点主要有三点。

a.性能高效(这是选择缓存实现分布式锁最核心的出发点)。

b.实现方便。很多研发工程师选择使用Redis来实现分布式锁,很大成分上是因为Redis提供了setnx方法,实现分布式锁很方便。但是需要注意的是,在Redis2.6.12的之前的版本中,由于加锁命令和设置锁过期时间命令是两个操作(不是原子性的),当出现某个线程操作完成setnx之后,还没有来得及设置过期时间,线程就挂掉了,就会导致当前线程设置key一直存在,后续的线程无法获取锁,最终造成死锁的问题,所以要选型Redis2.6.12后的版本或通过Lua脚本执行加锁和设置超时时间(Redis允许将Lua脚本传到Redis服务器中执行,脚本中可以调用多条Redis命令,并且Redis保证脚本的原子性)。

c.避免单点故障(因为Redis是跨集群部署的,自然就避免了单点故障)。

当然,基于Redis实现分布式锁也存在缺点,主要是不合理设置超时时间,以及Redis集群的数据同步机制,都会导致分布式锁的不可靠性。

如何合理设置超时时间

通过超时时间来控制锁的失效时间,不太靠谱,比如在有些场景中,一个线程A获取到了锁之后,由于业务代码执行时间可能比较长,导致超过了锁的超时时间,自动失效,后续线程B又意外的持有了锁,当线程A再次恢复后,通过del命令释放锁,就错误的将线程B中同样key的锁误删除了。

所以,如果锁的超时时间设置过长,会影响性能,如果设置的超时时间过短,有可能业务阻塞没有处理完成,能否合理设置超时时间,是基于缓存实现分布式锁很难解决的一个问题。

那么如何合理设置超时时间呢?你可以基于续约的方式设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间。实现方式就是:写一个守护线程,然后去判断锁的情况,当锁快失效的时候,再次进行续约加锁,当主线程执行完成后,销毁续约锁即可。不过这种方式实现起来相对复杂,我建议你结合业务场景进行回答,所以针对超时时间的设置,要站在实际的业务场景中进行衡量。

Redis如何解决集群情况下分布式锁的可靠性?

由于Redis集群数据同步到各个节点时是异步的,如果在Redis主节点获取到锁后,在没有同步到其他节点时,Redis主节点宕机了,此时新的Redis主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。其实Redis官方已经设计了一个分布式锁算法Redlock解决了这个问题。而如果你能基于Redlock原理回答出怎么解决Redis集群节点实现分布式锁的问题,会成为面试的加分项。那官方是怎么解决的呢?

为了避免Redis实例故障导致锁无法工作的问题,Redis的开发者Antirez设计了分布式锁算法Redlock,引入该算法后即使有某个Redis实例发生故障,因为锁的数据在其他实例上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。那Redlock算法是如何做到的呢?我们假设目前有N个独立的Redis实例,客户端先按顺序依次向N个Redis实例执行加锁操作。这里的加锁操作和在单实例上执行的加锁操作一样,但是需要注意的是,Redlock算法设置了加锁的超时时间,为了避免因为某个Redis实例发生故障而一直等待的情况。当客户端完成了和所有Redis实例的加锁操作之后,如果有超过半数的Redis实例成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功。

3.基于Zookeeper实现分布式锁

在介绍ZooKeeper分布式锁前需要先了解一下ZooKeeper中节点(Znode),ZooKeeper的数据存储数据模型是一棵树(ZnodeTree),由斜杠(/)的进行分割的路径,就是一个Znode(如/locks/my_lock)。每个Znode上都会保存自己的数据内容,同时还会保存一系列属性信息。Znode又分为以下四种类型:

Zookeeper实现分布锁的大致思想为:每个客户端对某个方法加锁时,在Zookeeper上与该方法对应的指定节点的目录下,生成一个唯一的临时有序节点。判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。当释放锁的时候,只需将这个临时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。

1)排它锁

  排他锁,又称写锁或独占锁。如果事务T1对数据对象O1加上了排他锁,那么在整个加锁期间,只允许事务T1对O1进行读取或更新操作,其他任务事务都不能对这个数据对象进行任何操作,直到T1释放了排他锁。

  排他锁核心是保证当前有且仅有一个事务获得锁,并且锁释放之后,所有正在等待获取锁的事务都能够被通知到。

  Zookeeper的强一致性特性,能够很好地保证在分布式高并发情况下节点的创建一定能够保证全局唯一性,即Zookeeper将会保证客户端无法重复创建一个已经存在的数据节点。可以利用Zookeeper这个特性,实现排他锁。

  1定义锁:通过Zookeeper上的数据节点来表示一个锁   2获取锁:客户端通过调用create方法创建表示锁的临时节点,可以认为创建成功的客户端获得了锁,同时可以让没有获得锁的节点在该节点上注册Watcher监听,以便实时监听到lock节点的变更情况   3释放锁:以下两种情况都可以让锁释放      当前获得锁的客户端发生宕机或异常,那么Zookeeper上这个临时节点就会被删除      正常执行完业务逻辑,客户端主动删除自己创建的临时节点   基于Zookeeper实现排他锁流程:

2)共享锁

  共享锁,又称读锁。如果事务T1对数据对象O1加上了共享锁,那么当前事务只能对O1进行读取操作,其他事务也只能对这个数据对象加共享锁,直到该数据对象上的所有共享锁都被释放。

  共享锁与排他锁的区别在于,加了排他锁之后,数据对象只对当前事务可见,而加了共享锁之后,数据对象对所有事务都可见。

  1定义锁:通过Zookeeper上的数据节点来表示一个锁,是一个类似于/lockpath/[hostname]-请求类型-序号的临时顺序节点   2获取锁:客户端通过调用create方法创建表示锁的临时顺序节点,如果是读请求,则创建/lockpath/[hostname]-R-序号节点,如果是写请求则创建/lockpath/[hostname]-W-序号节点   3判断读写顺序:大概分为4个步骤   1)创建完节点后,获取/lockpath节点下的所有子节点,并对该节点注册子节点变更的Watcher监听   2)确定自己的节点序号在所有子节点中的顺序   3.1)对于读请求:1.如果没有比自己序号更小的子节点,或者比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了共享锁,同时开始执行读取逻辑2.如果有比自己序号小的子节点有写请求,那么等待 3.2)对于写请求,如果自己不是序号最小的节点,那么等待   4)接收到Watcher通知后,重复步骤1)   4释放锁:与排他锁逻辑一致

  基于Zookeeper实现共享锁流程:

  3)羊群效应

  在实现共享锁的"判断读写顺序"的第1个步骤是:创建完节点后,获取/lockpath节点下的所有子节点,并对该节点注册子节点变更的Watcher监听。这样的话,任何一次客户端移除共享锁之后,Zookeeper将会发送子节点变更的Watcher通知给所有机器,系统中将有大量的"Watcher通知"和"子节点列表获取"这个操作重复执行,然后所有节点再判断自己是否是序号最小的节点(写请求)或者判断比自己序号小的子节点是否都是读请求(读请求),从而继续等待下一次通知。

  然而,这些重复操作很多都是"无用的",实际上每个锁竞争者只需要   当集群规模比较大时,这些"无用的"操作不仅会对Zookeeper造成巨大的性能影响和网络冲击,更为严重的是,如果同一时间有多个客户端释放了共享锁,Zookeeper服务器就会在短时间内向其余客户端发送大量的事件通知--这就是所谓的"羊群效应"。

  改进后的分布式锁实现:

  1客户端调用create方法创建一个类似于/lockpath/[hostname]-请求类型-序号的临时顺序节点。

  2客户端调用getChildren方法获取所有已经创建的子节点列表(这里不注册任何Watcher)。

  3如果无法获取任何共享锁,那么调用exist来对比自己小的那个节点注册Watcher      读请求:向比自己序号小的最后一个写请求节点注册Watcher监听      写请求:向比自己序号小的最后一个节点注册Watcher监听   4等待Watcher监听,继续进入步骤2   Zookeeper羊群效应改进前后Watcher监听图:

三、三种分布式锁对比

1.数据库分布式锁实现的优点及缺点

a.优点:

理解起来简单,不需要维护额外的第三方中间件(比如Redis,Zk)

b.缺点:

1.db操作性能较差,并且有锁表的风险2.非阻塞操作失败后,需要轮询,占用cpu资源;3.长时间不


转载请注明:http://www.aierlanlan.com/rzgz/3551.html