分布式锁解决方案解析

分布式锁特性
互斥
分布式锁需要保证在不同节点的不同线程的互斥。
重入性
同一个节点上的同一个线程如果获取了锁之后那么也可以再次获取这个锁
锁超时
锁超时:和本地锁一样支持锁超时,防止死锁
支持阻塞和非阻塞
和ReentrantLock一样支持lock和trylock以及tryLock(long timeOut)
mysql 实现分布式锁
lock要加事务,过程主要分为:

1、先查询记录,通过一个业务标识来查询得知有没有锁
2、查询不到记录就说明没锁,自己占用锁,做插入操作:业务id 加 节点线程信息等等
3、查询到记录,看节点、线程信息是否一样,一样获得锁,重入锁,并更新记录count值:count+1,不一样则没有锁
3、查询到记录,没有锁,继续等待,超时则返回。
unlock 要加事务,过程:

1、获取锁信息,判断count值,大于1则更新count:count - 1
2、count == 1 :直接删除记录,表明释放锁
3、其他情况告知释放锁失败
优点与缺点
适用场景: Mysql分布式锁一般适用于资源不存在数据库,可以吧mysql分布式锁封装为一个组件提供出去。
优点:理解起来简单,不需要维护额外的第三方中间件(比如Redis,Zk)。
缺点:虽然容易理解但是实现起来较为繁琐,需要自己考虑锁超时,加事务等等。性能局限于数据库,一般对比缓存来说性能较低。对于高并发的场景并不是很适合。
redis 做分布式锁
Redis因为其性能好,单线程,实现起来简单等优点,比较适合做分布式锁。
1、get
2、set
3、delete
不过get、set之间有一些问题,如果两个请求同时get,这样就会出现同时set方法,这是一个问题,所以有了另一张方法,通过setNx(set if not exist)方法来实现,获取锁:

setnx(lockkey, value) ,value 可以为当前时间+过期超时时间
如果返回 0,则锁失败,成功则设置 value为一个时间,主要防止宕机之后,这个锁一直存在,无妨释放,通过时间可以判断这个key是否过期

没有锁成功,则get(lockkey) 获取值 oldExpireTime
来查看锁时间是否过期

为什么用setnx方法?它是set if not exist的缩写,也就是如果不存在就set,如果返回1则表示加锁成功,如果返回0则表示其它线程先执行成功了。

释放锁:

通过delete操作来释放锁
这里有一个明显问题:如果过了时间,方法还没执行完,自然也不会释放锁,但是其他线程会读取时间来判断,会误认为已经释放锁了,会去竞争锁,这样可能会导致并发问题,所以有人提出了:基于 redisson 做分布式锁。redisson 是 redis 官方的分布式锁组件,它主要策略是:每获得一个锁时,只设置一个很短的超时时间,同时起一个线程在每次快要到超时时间时去刷新锁的超时时间。在释放锁的同时结束这个线程。

除了上述问题,存在一个问题:在我们的系统架构里存在一个单点故障,如果Redis的master节点宕机了怎么办呢?大多数人会选择加一个slave节点!做master-slave模式,但有人提出:

线程A在master节点拿到了锁。
master节点在把A创建的key写入slave之前宕机了。
slave变成了master节点。
线程B也得到了和A还持有的相同的锁。(因为原来的slave里面还没有A持有锁的信息)
如果这两个线程操作一个对象,则会出现问题,Martin 提出了Redlock方法,Redlock算法的主要思想是:假设我们有N个Redis master节点,这些节点都是完全独立的,我们可以运用前面的方案来对前面单个的Redis master节点来获取锁和解锁,如果我们总体上能在合理的范围内或者N/2+1个锁,那么我们就可以认为成功获得了锁。

但此方法引起了antirez的反驳,他的观点主要从超时时间出发,主要有两点:

FGC
如果一个线程A 的环境碰到了 FGC,且 FGC的时间超过了超时时间,另一个线程B就会获得锁, FGC过后,线程A、B都修改、提交了数据,这里就会有问题,IO或者网络的堵塞或波动都可能造成系统停顿,都会引发这个问题。
依赖系统时间
(1) Client 1 从 A、B、D、E五个节点中,获取了 A、B、C三个节点获取到锁,我们认为他持有了锁
(2) 由于 B 的系统时间比别的系统走得快,B就会先于其他两个节点优先释放锁。
(3) Clinet 2 可以从 B、D、E三个节点获取到锁。在整个分布式系统就造成 两个 Client 同时持有锁了
Redis优点与缺点
优点:Redis实现简单,性能对比ZK和Mysql较好,吞吐量十分客观,对于高并发情况应付自如,自带超时保护,对于网络抖动的情况也可以利用超时删除策略保证不会阻塞所有流程。

缺点:没有线程唤醒机制、网络抖动可能会引起锁删除失败,需要维护Redis集群,如果要实现RedLock那么需要维护更多的集群。

zookeeper
ZooKeeper是以Paxos算法为基础分布式应用程序协调服务,他的特性是可以创建有序、临时节点,利用这个节点可以来做一些事情,加锁流程:

创建一个锁节点:要求这个节点下面的节点类型都是 临时、有序的。
每一个线程过来都在锁节点下面注册一个节点,这个子节点就是临时、有序
每个子节点通过Watcher机制监听序号比自己小一个的节点,当这个节点消失了,自己就被唤醒
每次取序号最小的节点来获得锁
释放锁流程:

删除当前节点。
如果有重入锁要求,可以本地维护一个hash对象,里面存入线程信息,加锁的时候往里加信息,释放锁就删除信息。

zookeeper 优点与缺点
优点
ZK可以不需要关心锁超时时间,实现起来有现成的第三方包,比较方便,并且支持读写锁,ZK获取锁会按照加锁的顺序,所以其是公平锁。对于高可用利用ZK集群进行保证。
缺点
ZK需要额外维护,增加维护成本,性能和Mysql相差不大,依然比较差。
————————————————

分布式一致性问题

首先我们先来看一个小例子:

假设某商城有一个商品库存剩10个,用户A想要买6个,用户B想要买5个,在理想状态下,用户A先买走了6了,库存减少6个还剩4个,此时用户B应该无法购买5个,给出数量不足的提示;而在真实情况下,用户A和B同时获取到商品剩10个,A买走6个,在A更新库存之前,B又买走了5个,此时B更新库存,商品还剩5个,这就是典型的电商“秒杀”活动。

从上述例子不难看出,在高并发情况下,如果不做处理将会出现各种不可预知的后果。那么在这种高并发多线程的情况下,解决问题最有效最普遍的方法就是给共享资源或对共享资源的操作加一把锁,来保证对资源的访问互斥。在Java JDK已经为我们提供了这样的锁,利用ReentrantLcok或者synchronized,即可达到资源互斥访问的目的。但是在分布式系统中,由于分布式系统的分布性,即多线程和多进程并且分布在不同机器中,这两种锁将失去原有锁的效果,需要我们自己实现分布式锁——分布式锁。

 

分布式锁需要具备哪些条件

  1. 获取锁和释放锁的性能要好

  2. 判断是否获得锁必须是原子性的,否则可能导致多个请求都获取到锁

  3. 网络中断或宕机无法释放锁时,锁必须被清除,不然会发生死锁

  4. 可重入一个线程中可以多次获取同一把锁,比如一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁;

  5. 阻塞锁和非阻塞锁,阻塞锁即没有获取到锁,则继续等待获取锁;非阻塞锁即没有获取到锁后,不继续等待,直接返回锁失败。

 

分布式锁实现方式

一、数据库锁

一般很少使用数据库锁,性能不好并且容易产生死锁。

  1. 基于MySQL锁表

该实现方式完全依靠数据库唯一索引来实现,当想要获得锁时,即向数据库中插入一条记录,释放锁时就删除这条记录。这种方式存在以下几个问题:

(1) 锁没有失效时间,解锁失败会导致死锁,其他线程无法再获取到锁,因为唯一索引insert都会返回失败。

(2) 只能是非阻塞锁,insert失败直接就报错了,无法进入队列进行重试

(3) 不可重入,同一线程在没有释放锁之前无法再获取到锁

  1. 采用乐观锁增加版本号

根据版本号判断更新之前有没有其他线程更新过,如果被更新过,则获取锁失败。

二、缓存锁

具体实例可以参考我讲述Redis的系列文章,里面有完整的Redis分布式锁实现方案

这里我们主要介绍几种基于redis实现的分布式锁:

  1. 基于setnx、expire两个命令来实现

基于setnx(set if not exist)的特点,当缓存里key不存在时,才会去set,否则直接返回false。如果返回true则获取到锁,否则获取锁失败,为了防止死锁,我们再用expire命令对这个key设置一个超时时间来避免。但是这里看似完美,实则有缺陷,当我们setnx成功后,线程发生异常中断,expire还没来的及设置,那么就会产生死锁。

解决上述问题有两种方案

第一种是采用redis2.6.12版本以后的set,它提供了一系列选项

EX seconds – 设置键key的过期时间,单位时秒

PX milliseconds – 设置键key的过期时间,单位时毫秒

NX – 只有键key不存在的时候才会设置key的值

XX – 只有键key存在的时候才会设置key的值

第二种采用setnx(localkey,value),get(localkey),getset(localkey,value)实现,大体的实现过程如下:

(1) 线程Asetnx,值为超时的时间戳(t1),如果返回true,获得锁。

(2) 线程B用get 命令获取t1,与当前时间戳比较,判断是否超时,没超时false,如果已超时执行步骤3

(3) 计算新的超时时间t2,使用getset命令返回t3(这个值可能其他线程已经修改过),如果t1==t3,获得锁,如果t1!=t3说明锁被其他线程获取了

(4) 获取锁后,处理完业务逻辑,再去判断锁是否超时,如果没超时删除锁,如果已超时,不用处理(防止删除其他线程的锁)

一.redis命令讲解: setnx()命令: setnx的含义就是SET if Not Exists,其主要有两个参数 setnx(key, value)。

该方法是原子的,如果key不存在,则设置当前key成功,返回1;如果当前key已经存在,则设置当前key失败,返回0。

get()命令: get(key) 获取key的值,如果存在,则返回;如果不存在,则返回nil; getset()命令: 这个命令主要有两个参数 getset(key, newValue)。该方法是原子的,对key设置newValue这个值,并且返回key原来的旧值。 假设key原来是不存在的,那么多次执行这个命令,会出现下边的效果:

getset(key, "value1") 返回nil 此时key的值会被设置为value1

1.getset(key, "value2") 返回value1 此时key的值会被设置为value2

2.依次类推!

二.具体的使用步骤如下:

1.setnx(lockkey, 当前时间+过期超时时间) ,如果返回1,则获取锁成功;如果返回0则没有获取到锁,转向2。

  1. get(lockkey)获取值oldExpireTime ,并将这个value值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,转向3。

    1. 计算newExpireTime=当前时间+过期超时时间,然后getset(lockkey, newExpireTime) 会返回当前lockkey的值currentExpireTime。

    2. 判断currentExpireTime与oldExpireTime 是否相等,如果相等,说明当前getset设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。

  2. 在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行delete释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。

 

 

 

  1. RedLock算法

redlock算法是redis作者推荐的一种分布式锁实现方式,算法的内容如下:

(1) 获取当前时间;

(2) 使用setNx()尝试从5个相互独立redis客户端获取锁;

(3) 计算获取所有锁消耗的时间,当且仅当客户端从多数节点获取锁,并且获取锁的时间小于锁的有效时间,认为获得锁;

(4) 重新计算有效期时间,原有效时间减去获取锁消耗的时间;

(5) 删除所有实例的锁

redlock算法相对于单节点redis锁可靠性要更高,但是实现起来条件也较为苛刻。

(1) 必须部署5个节点才能让Redlock的可靠性更强

(2) 需要请求5个节点才能获取到锁,通过Future的方式,先并发向5个节点请求,再一起获得响应结果,能缩短响应时间,不过还是比单节点redis锁要耗费更多时间

然后由于必须获取到5个节点中的3个以上,所以可能出现获取锁冲突,即大家都获得了1-2把锁,结果谁也不能获取到锁,这个问题,redis作者借鉴了raft算法的精髓,通过冲突后从随机时间开始,可以大大降低冲突时间,但是这问题并不能很好的避免,特别是在第一次获取锁的时候,所以获取锁的时间成本增加了。

如果5个节点有2个宕机,此时锁的可用性会极大降低,首先必须等待这两个宕机节点的结果超时才能返回,另外只有3个节点,客户端必须获取到这全部3个节点的锁才能拥有锁,难度也加大了。

如果出现网络分区,那么可能出现客户端永远也无法获取锁的情况,介于这种情况,下面我们来看一种更可靠的分布式锁zookeeper锁。

 

三、zookeeper分布式锁

关于zookeeper的分布式锁实现在之前讲述zookeeper的时候已经介绍了。这里不再赘述、

首先我们来了解一下zookeeper的特性,看看它为什么适合做分布式锁,

zookeeper是一个为分布式应用提供一致性服务的软件它内部是一个分层的文件系统目录树结构,规定统一个目录下只能有一个唯一文件名。

数据模型:

永久节点:节点创建后,不会因为会话失效而消失

临时节点:与永久节点相反,如果客户端连接失效,则立即删除节点

顺序节点:(临时或者永久的顺序节点)与上述两个节点特性类似,如果指定创建这类节点时,zk会自动在节点名后加一个数字后缀,并且是有序的。

监视器(watcher):

当创建一个节点时,可以注册一个该节点的监视器,当节点状态发生改变时,watch被触发时,ZooKeeper将会向客户端发送且仅发送一条通知,因为watch只能被触发一次。

根据zookeeper的这些特性,我们来看看如何利用这些特性来实现分布式锁:

  1. 创建一个锁目录lock

  2. 希望获得锁的线程A就在lock目录下,创建临时顺序节点

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

  4. 线程B获取所有节点,判断自己不是最小节点,设置监听(watcher)比自己次小的节点(只关注比自己次小的节点是为了防止发生“羊群效应”)

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

 

小结

在分布式系统中,共享资源互斥访问问题非常普遍,而针对访问共享资源的互斥问题,常用的解决方案就是使用分布式锁,这里只介绍了几种常用的分布式锁,分布式锁的实现方式还有有很多种,根据业务选择合适的分布式锁,下面对上述几种锁进行一下比较:

数据库锁:

优点:直接使用数据库,使用简单

缺点:分布式系统大多数瓶颈都在数据库,使用数据库锁会增加数据库负担。

缓存锁:

优点:性能高,实现起来较为方便,在允许偶发的锁失效情况,不影响系统正常使用,建议采用缓存锁。

缺点:通过锁超时机制不是十分可靠,当线程获得锁后,处理时间过长导致锁超时,就失效了锁的作用。

zookeeper锁:

优点:不依靠超时时间释放锁;可靠性高;系统要求高可靠性时,建议采用zookeeper锁。

缺点:性能比不上缓存锁,因为要频繁的创建节点删除节点。

原文地址:https://www.cnblogs.com/hanease/p/15510170.html