分布式锁的实现与对比

一、分布式锁的概念

分布式锁如果我们从概念上来看,它分为两个维度,一个是锁,一个是分布式。

锁是什么?

        举个粗俗一点例子,我们上厕所占坑,一个坑一个门,一个门一个锁,我们蹲坑就要拿钥匙去开锁,然后方便,再解锁,然后给另外一个人方便。话糙理不糙,在java的世界里,我们希望完成某项业务是原子的,独立的,就得为这块业务上一把锁,锁的作用就是把这块业务范围的代码“锁”起来,所有想要进入该块业务的线程都得在门外等着,等我处理完再给你处理。正如上厕所不可能多个人同时拿到钥匙,一起开锁,一起蹲坑一样。java中提供了各式各样的锁,有乐观锁/悲观锁、独享锁/共享锁、互斥锁/读写锁、公平锁/非公平锁、偏向锁/轻量级锁/重量级锁、可重入锁、分段锁、自旋锁等等,至于这些锁的实现逻辑以及方法在这里我就不多言了,有兴起的同学可以度娘,一抓一大把。

分布式是什么?

   说分布式就得拿单体来说了,粗糙点一说,一个jvm虚拟机算一个单体,所有请求都由这台虚拟机完成。那个分布式又是什么呢?是不是多个单体就是分布式呢?你要是硬要这么说,也没问题,只是这也太粗糙的说法了。。。[无所谓的表情.gif] 下面也画了一张略为粗糙的图给大伙解释一下分布式框架模型。


从上图可以看出,用户的请求通过nginx的转发,有可能落到不同的jvm上,也就是说一个业务如果发生并发,有可能落到不同的jvm上,而java的锁只适用于当前的jvm,并不能跨不同的jvm来保证一项业务的原子性。也就是说java提供的各种锁,并不能解决分布式的业务原子性的问题。那我非得要解决这个问题,怎么办?根据我们编程的习惯,不同的方法有一样的逻辑业务块,我们习惯性地把同样的业务块提取出来,做成通用的方法给那些不同的方法调用。那我们把一个jvm提出来做锁不就解决了分布式锁的问题了吗?此时的架构就会变成这样


那用以上的图是不是就可以解决分布式锁的问题了?呃,低性能,低可用地完成了。。。是这种思路没错,但java锁服务又形成了一个单体服务了,无法集群化就谈不上高可用了。那么我们该如何解决这个问题呢?其实,我们的问题只是缺一个高性能,高可用的中间件做锁而已,那哪些中间件或产品有这些功能呢?那就聊到分布式锁的实现了

二、分布式锁的实现

在聊分布式锁的实现的前提下,我们先来了解一下,一个分布式锁要能做到哪些事情才能算得上是一把好的分布式锁?

1、在分布式系统环境下,一个方法在同一时间只能被 一台机器的一个线程执行

2、高性能的获取锁与释放锁

3、高可用的获取锁与释放锁

4、具备可重入性

5、具备锁失效机制,防止死锁;

6、具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。(可选)

7、具备阻塞锁特性,未获得锁的时候需要等待下次获锁的时机(可选)

基于以上的特性,我们看看能实现这些特殊的三种分布式锁的实现。

1、基于数据库(Mysql)实现分布式锁

我们知道数据库本身的事务是具有原子性的功能,而执行单条命令如:insert,update,delete都是自动提交事务的,那这几条命令,我们选一条来做分布式锁的功能就可以了。如果选update与delete,前提都是需要先insert数据的。所以我们直接选insert来实现该功能。insert之前,我们先建表。表结构如下:

CREATE TABLE `method_lock` (

  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',

  `method_name` varchar(64) NOT NULL COMMENT '锁定的方法名',

  `desc` varchar(64) NOT NULL COMMENT '备注信息',

  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

  `PRIMARY KEY (`id`),

UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE

) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

注意红色部分的语句,我们把method_name设置为unique,说明在这表里的method_name不能存在同样的值。就是当遇到多个相同的值同时插入的时候,仅且只有一条能插入成功,其它都失败。那么成功的那一条就能成功地获取到锁,剩余失败的就取不到锁。而当业务执行完成后,我们要释放锁,就delete这条数据即可。看到这里,整个获取锁-执行业务-释放锁的过程就完成了,是不是很简明了?但这也显而易见地出现了以下的问题:

1、不可重入,同一个事务在没有释放锁之前无法再次获取到该锁

2、没有失效时间,一旦解锁失败则会一直保留着这把锁

3、非阻塞的,没有获得锁的现场一旦失败不会等待,要想再次获得必须重新出发获取锁的操作

4、强依赖数据库的可用性,数据库一旦挂掉会导致业务不可用

针对上面的问题,我们也可以通过其它手段来解决的

1、不可重入。添加请求Id与count字段,同一请求Id可在update count+1,而不是插入

2、没有失效时间。添加失效时间字段,再通过调度器对过期数据进行处理

3、非阻塞。通过while循环来不断调用尝试获取锁(类似CAS,不过这个CAS的成本有点高,性能低下)

4、数据库可用性。数据库主从设计,保证高可用

数据库这种方式虽然说也是一种解决方案,但如果并发量稍微大那么一点点,就有insert来尝试获取锁,要知道mysql的UNIQUE判断也是要耗性能的,加上那么多的mysql报错也是不友好的,所以总体而言,该方案的性能不怎的,一般也少人选择并使用这种方案。

2、基于缓存(Redis)实现分布式锁

我们又知道,Redis是单线程高性能的内存数据库,而它本身就有key过期的功能,也有命令(SETNX)支持set if not exists。因此我们用setnx命令来完成锁的功能

127.0.0.1:6379> setnx methodName test

(integer) 1

127.0.0.1:6379> get methodName

"test"

127.0.0.1:6379> setnx methodName test2

(integer) 0

我们看到setnx确实是可以拿来获取锁,返回1就成功获取到锁,返回0证明之前有其它线程设置过该值,不能获取到锁。但现在有个问题,什么时候过期呢?那还不容易,expire methodName 3 就可以设置过期了,但这样又回来原子性的问题上了,两条命令没有"事务"不能保证执行的原子性。解决这个问题有两种方法,一个是使用Lua脚本,使得同时包含setnx和expire两个指令,Lua指令可以保证这两个操作是原子的,所以能保证两者要么同时成功或者同时失败。还有一种方法就是使用另一个命令

set key value [EX seconds][PX milliseconds][NX|XX]

* EX seconds: 设定过期时间,单位为秒

* PX milliseconds: 设定过期时间,单位为毫秒

* NX: 仅当key不存在时设置值

* XX: 仅当key存在时设置值

通过这种方法能正确地获取到锁了,超时时间也有了,处理完业务后,通过delete命令删除key就可以释放锁了

delete key

但使用这种方法依然是不安全的,至少不是绝对安全。怎么说?举个例子。线程1获取到锁了,失效时间为10s,也就是说redis最多能保证这10s内没有其它线程能进到该方法,万一线程1执行该方法超过10s呢?这样其它线程就有机可乘了,因为此时redis因时间过期而使key失效,其它线程就可以获得该锁了,那此时就有两个线程在处理同一件事了。这可怎么办呢?我们可以找一个"看门狗"来专门看看我们线程执行完没,redis的key是不是快要超时了,如果在key即将超时时,而线程还没执行完成,那"看门狗"帮我们把过期时间延长。而这个"看门狗"我们可以使用Redisson的tryAcquireOnceAsync来完成,至于这个怎么实现"看门狗"的功能我这里就不展开叙述了,大家可以度娘一下。

其实超时业务逻辑未执行完的问题,我们还可以把redis超时设置更长点,如果执行时间超过一个合理值,是需要检查业务代码是否有问题,一般事务不会过长,过长的事务一般也不会选择这种方式处理,可选择队列处理

解决了执行时间过长问题,我们把聚集点放回到redis集群上,这里还有个问题,如果我们在哨兵模式下,线程1在master拿到锁了,但刚好此时master宕机了,这里slave通过投票选举升级为master,但由于slave没有之前master的锁信息,线程2来问redis集群拿锁信息时,redis因没之前锁的信息,会给线程2得到该锁,这样就会导致线程1与线程2会同时持有同一把锁,都会同时某业务上进行操作。虽然这问题不常见,但总会有可能发生,遇到这样的问题要怎样处理?

针对这个问题,Redis官方也提出了红锁(Redlock)的概念,简单地说:超过半数的redis服务请求到锁的时候,才算真正获取到该,如果没有,则不算真正获取到该锁,并且需要把其它redis服务上的锁释放掉(delete)

什么意思呢?

如果redis集群里是通过哨兵模式建立起来的,假设有5台服务器,如果我们都向这5台服务器请求锁,而它们都能响应给你,说你能获取到锁,那在这5台服务器上,肯定也有了该锁的信息,即使任何一个slave因选举升级成master,那它也必然带有原锁的信息,线程2再来请求,也不会重新批发同一个锁给它。而根据哨兵模式的算法,选举数过半的slave会升级成master,所以线程1在请求这5台服务器时,最少要保证3台(N/2 + 1)或以上响应获取锁成功才能算得上真正获取锁成功。否则是失败的,失败了就要清除之前请求成功锁的redis服务的key了。具体的获取锁的步骤如下:

1、获取当前毫秒时间戳

2、从这5个实例中依次尝试获取锁,使用相同的key和随机的value。在这一步骤中,当我们在每个实例里请求锁时,每个客户端都要设置一个比锁的释放时间要小的超时时间。比如锁的自动释放时间是10s,那么超时时间可以设置为5~50毫秒。这个可以阻止客户从剩下的已经阻塞的实例里面不断的尝试获取锁。如果一个实例不可用,我们应当尝试尽快去连接下一个实例。

3、客户端计算获取锁花费的时间,即计算当前时间和第一步得到的时间的差值。当且仅当客户端能在大部分实例(N/2 +1,这里是N=5所以指的是3),并且总的获取锁时间时小于锁的有效时间,这个锁才认为是成功获取了。

4、如果成功获取到锁了,它的实际有效时间就被认为是初始的有效时间减去第3步计算出的花费时间

5、如果客户端因为某些原因获取锁失败了。(比如没有N/2+1的实例获取到锁或最终有效时间是负值),那么此时就会尝试将所有实例上的锁进行释放(即使某些实例并没有锁)

Redisson也是有红锁的实现算法RedissonRedLock,但它对性能的影响很大,如非必要,一般不采取这种手段,可能会通过修改业务的设计或采用其他技术方案来解决这种极端

3、基于Zookeeper实现分布式锁

接下来要介绍的是最后一个实现分布式锁的方式,ZooKeeper。ZooKeeper大多数应用于配置中心,服务注册与发现,分布式协调/通知等等功能上,但它还有一个我们常用也重要的功能,分布式锁。那在分布式锁方面,Zookeeper是如何做到的呢?我们知道,Zookeeper有数据节点的概念,数据节点有永久性的也有临时性的,另外,ZooKeeper允许用户为每个节点添加一个特殊的属性:SEQUENTIAL。一旦节点被标记上这个属性,那么这个节点被创建的时候,ZooKeeper会自动在其节点名后面追加一个整型数字,这个整型数字是一个由父节点维护的自增数字。也就意味着无论是持久节点还是临时节点,都可以设置成有序的,即持久顺序节点和临时顺序节点。

另外,Zookeeper还有一个重要的概念是事件监听器Watcher,当节点发生变化时,会通知过所有订阅该节点的客户端。通过这些特性,我们要实现分布式锁就容易了。以下是实现的步骤

1、创建临时节点mylock,添加特殊属性:SEQUENTIAL

2、线程A想获取锁就在mylock目录下创建临时顺序节点;

3、获取mylock目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁;

4、线程B获取所有节点,判断自己不是最小节点,设置监听最小的节点;

5、线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是不是最小的节点,如果是则获得锁

这里推荐一个Apache的开源库Curator,它是一个ZooKeeper客户端,Curator提供的InterProcessMutex是分布式锁的实现,acquire方法用于获取锁,release方法用于释放锁。

至此,获取锁-处理业务-释放锁的事情就做完了。同样地,一个方案不能做到十全十美,同样会有一些问题产生的。这种方案会有什么问题呢?

羊群效应

羊群效应这个名词估计也不用我解释,不管在生活上,职场上,股市上都会出现这个词,“人从众”。但在我们Zookeeper上的羊群效应又是什么意思呢?

在上述的方案上,不知道大家有没有发现,未获取到锁的节点都要订阅获取到锁节点的变化,也就是说当最小节点释放锁的时候,所有订阅该节点变化的客户端都被惊醒了,一拥而至地抢锁,抢到锁的线程继续处理业务,抢不到锁的继续沉睡,等待下一次觉醒。有没有像你拿着一块小面包在观赏鱼塘边扔下去的那一刻的感觉,看着密密麻麻的鱼蜂拥而至,甭想有多酸爽了。这有点像我们网络编程中的惊群效应了。面对这个问题,我们又要怎样解决呢?

这个效应无非就是大家都在监听同一个节点导致的,那我们公平一点,先来候到,进来抢锁的都要在mylock创建一个临时节点,除去获取到锁的最小节点的其它节点,只需要监听比自己小1的节点就可以了,如第二个节点监听第一个节点什么时候释放锁,第三个节点监听第二个节点,如此类推,释放锁后,自然就不再再抢锁了,下一个节点会自动拿到锁信息。当然,如果你希望实现不公平锁,就自行编写一段非顺序拿监听节点的逻辑即可,这里就不展开了,只提供一下方向。

三、各种实现的对比

至此以上的三种实现分布式的锁已经叙述完了,但有些同学还是不清楚什么时候,什么场景用什么方案,我列一下各方面的优缺点供大家参考一下


以下是按其它维度来看


四、总结

没有绝对完美的实现方式,具体要选择哪一种分布式锁,需要结合每一种锁的优缺点和业务特点而定,如果结合了场景还是挑不出来的话,就选Zookeeper吧。理由?你都挑不出来了,我就直接帮你选一个,免得你有选择困难性[笑脸.gif]

至此,上面没出现过一行代码,也希望大家感兴趣的话,自己实现,可以留意区附上git地址,让大家学习学习。

参与文章:

https://segmentfault.com/a/1190000024463575

https://blog.csdn.net/xlgen157387/article/details/79036337

你可能感兴趣的:(分布式锁的实现与对比)