一个项目部署多个节点会导致锁失效么_分布式锁,这篇文章可以帮到你

纸上得来终觉浅,绝知此事要躬行!

之前写过介绍Redis数据类型的文章(文末有链接),在介绍字符串数据类型的文章中,我们说过,其典型应用是分布式锁的应用。本篇我们就来介绍一下分布式锁。我们从两个方面来介绍:1、分布式锁是什么?为什么会有分布式锁?2、如何实现分布式锁?

77f064196d3c8bef7a7847cfabae7618.png

分布式锁是什么?解决了什么问题?

对于这两个问题,其实是一而二二而一的关系,知道了什么是分布式锁,也就知道了它是解决什么问题的。

在软件发展的初期,应用是部署在一个虚拟机中的,多个线程可以同时修改同一个变量,这种情况下,就会发生变量混乱的问题,为了防止这种问题的发生,我们需要对这个共享变量做同步,最初使用同步代码块进行同步,保证同一时刻只有一个线程可以修改该共享变量,随着JDK1.5的发布,增加了并发包,我们也可以是用锁来控制共享变量在线程间的共享问题。

随着软件系统的发展,单机应用已经不能满足日益发展的业务需求,所以,出现了分布式系统,相同的应用分别部署在两个甚至更多的虚拟机中,通常也是部署在多个物理机上、多个机架、多个机房中。应用的分布式部署,主要解决的是日益增长的业务量对应用的访问压力,利用多个应用分摊业务访问。因为应用的分布式部署,导致共享变量在多个虚拟机中同时存在,这时候,同步代码块和并发锁就没办法帮助我们了,因为他们只能保证在当前虚拟机中的同步。

所以说,分布式系统的出现,亟需一种能够保证应用间数据同步的手段,而这种手段就是分布式锁

分布式锁如何实现?

知道了分布式锁是什么,接下来我们就该考虑如何实现一个分布式锁。

首先,我们需要知道分布式锁需要具备哪些条件呢?它既然是一把锁,那么它必须满足锁的基本条件:

  1. 同一时刻,只有一个线程(同一个虚拟机中的线程或者不同虚拟机中的线程)能够获得该锁;
  2. 只有获的锁的线程,才能解锁;
  3. 具备锁失效机制,防止产生死锁;
  4. 锁的获取和释放必须高效;
  5. 提供分布式锁的服务必须是高可用的;

除了这几个基本条件,根据实际业务场景,可能还要求锁是可重入的(获取锁的线程再次获取锁)、阻塞的等等其他条件,但这些都是根据具体业务来定义的。接下来,我们讨论几种分布式锁的实现方式,分别来看一看各自的优劣。

(1)基于数据库表的实现方式

这是最简单的一种实现方式,我们创建一张表,当我们要锁住某个资源或者方法的时候,通过添加一条记录来加锁,想要释放锁的时候就删除这条记录。可以通过数据库的唯一性约束,保证只有一个线程创建记录成功,也就是只有一个线程能够获取锁,当需要释放锁的时候,该线程删除该记录即可。

这种简单的实现方式有几个问题:

  1. 强依赖数据库,如果数据库是单点的,那么一旦数据库挂掉,将导致业务系统不可用,为了避免这种情况,可能需要两个数据库做主从同步备份,一旦主挂掉,快速切换到备库;
  2. 这个锁没有失效时间,如果解锁失败,那么就会导致记录一直存在,也就是说,当前线程无法继续解锁,其他线程也没办法获取锁。可以通过添加定时任务定期扫描解决或者将解锁放在业务的整体事务中,但是这两种解决办法都不好,添加定时任务没办法确定扫描间隔,没办法判断记录是否是解锁失败的记录,而将解锁操作放在业务的事务中,侵入业务不说,如果因为解锁导致业务失败,更加得不偿失;
  3. 锁是非阻塞的,因为插入失败就返回获取锁失败了,未获取锁的线程不会在队里里等待然后继续竞争锁,对于某些业务来说,阻塞锁是有必要的。当然可以通过while循环来解决这问题,但并不好;
  4. 这把锁是不可重入的,或者说重入的代价比较大。如果可重入,需要在记录中添加线程的信息,当线程再次获取锁时根据线程信息判断;

(2)基于数据库的排他锁

基于Mysql的InnoDB存储引擎,在查询的语句的后边增加for update,数据库会在查询的时候使用排他锁,当某条记录(需要加锁的方法或者变量)被加上排他锁之后,其他线程无法再增加排他锁。所以,通过这种方法,可以把获得排他锁的线程认为是获得分布式锁的线程。可以使用connection.commit()来释放排他锁。

注意:我们都知道InnoDB是行级锁,但是InnoDB引擎在加锁的时候,是通过索引实现的行级锁,也就是说,当使用索引检索的时候,才会使用行级锁,否则就会使用表级锁,这里,我们需要使用行级锁,所以,需要对相关加锁信息(比如加锁方法、加锁变量等)添加唯一索引,还有一点需要注意的是,如果有重载方法, 需要把参数加上,否则,重载方法无法同时加锁

相比(1)使用数据库表的方式,这种方式有效的解决了无法释放和阻塞锁的问题,因为是基于数据库自身机制的,当应用宕机或其他原因和数据断开连接,那么数据库会自动释放排他锁(因为连接都不存在了),而当其他线程for update成功后,其他线程会一直阻塞在for update上,直到for update成功。

但是仍然会有数据库单点问题和重入的问题,解决方案如(1)中所述。而且该方法还有另外两个非常严重的问题,第一、虽然我们增加了唯一索引,使用索引执行for update,但是,数据库有其自己的解释执行计划,当其认为全表扫描更快时(对于比较小的表),将不会使用行级锁,会使用表级锁,这种情况就悲剧了;第二、如果线程加锁后,业务处理时间较长,那么当有多个线程需要对多个方法加锁时,将会占用大量连接,我们都知道,数据库连接是非常稀缺的资源,长时间大量的占用,会有很大隐患,如果连接持续增加,就会把数据库的连接撑爆。

(3)基于缓存实现分布式锁

相较于数据库的实现,基于缓存的实现在性能方面会更好,而且缓存一般都是集群部署,可以有效解决单点问题,常用的开源的成熟缓存产品有Redis和Memcache,当然也有一些大厂自研产品。

以使用Redis来实现分布式锁为例。

使用set命令加锁

// set key value [NX seconds] [PX milliseconds] [NX|XX]jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

这个是Redis的Java客户端Jedis的API,其中的五个参数分别表示:key表示的就是这把锁,它是唯一的,它可能对应到一个需要加锁的方法或者一个共享变量,我们对方法或者变量加锁,其实就是对key设置值;value就是值,一般该值用来标识一个线程,也就是加锁的线程,因为“解铃还须系铃人”,我们需要知道是谁加的锁,解锁的时候才有依据,保证加锁的线程才能解锁;nxxx传的是“NX”,该值表示key不存在的时候才设置值,key存在,则不作任何操作;expx传的是“PX”,表示给这个key加一个过期时间,具体的时间由最后一个参数决定;time即过期时间。

总的来说,执行这个set命令后,只有两种结果:key不存在set成功或者key存在不作任何操作。

使用lua脚本来解锁

解锁的时候因为需要判断当前锁的加锁线程,而获取value删除value是两个命令,这两个命令并不是原子操作,所以,我们使用Lua脚本来执行解锁操作。

String script = “if redis.call(‘get’, KEYS[1]) == ARGV[1] then return redis.call(‘del’, KEYS[1]) else return 0 end”;Object result = jedis.eval(script, Collections.singletonList(lockKey),Collections.singletonList(requestId));return "1".equals(result);

关于Lua脚本,这里简单解释一下,先使用get命令获取值,判断值是不是当前线程,如果是,执行del命令删除键,如果不是,就返回0。

针对此种方式,我们再来分析一下,它有哪些优点:

  1. 使用缓存实现是高效的,因为缓存是内存操作,相对于数据库的磁盘操作来说,是非常高效的;
  2. 缓存一般都是集群部署,解决单点的同时,使用更加方便;
  3. 增加了锁失效机制,过期自动失效,不会产生死锁问题;
  4. 使用Lua脚本加锁,可以实现锁重入,先判断键是否存在,不存在尝试设置值,如果存在,判断是否是当前线程;

那它有什么缺点呢?

  1. 虽然Redis增加了失效机制,防止死锁的产生,但是过期时间的设置也是一个难题,因为没办法确定加锁的业务逻辑执行的时间长短,而且这个时间的长短可能受外部其他因素的影响,比如网络原因等。如果时间设置的太短,那么有可能方法没执行完,就释放锁了,产生并发问题,如果设置的时间太长,那么在业务执行完到自动释放这一时间段内,其他线程只能空等。该问题在数据库实现中也存在(增加定时任务扫描)。

(4)使用Zookeeper实现

基于Zookeeper的临时顺序节点也可以实现分布式锁。

当多个客户端需要同时加锁时,实际上是在Zookeeper的相应节点下添加自己的临时顺序节点,添加成功后判断自己的节点是不是最小的节点,如果是,则表示获取了锁,如果释放锁,只需要删除临时节点即可。

使用Zookeeper实现的优点:

  1. 天然的锁是失效机制,因为是临时节点,当创建节点的客户端宕机或者Session断开时,节点自动删除,意味着释放锁,下一个节点即可获取锁;
  2. 利用Zookeeper机制可以实现阻塞锁,当客户端判断自己的节点不是最小节点的时候,可以创建一个监听器,监听其前一个节点的状态,当前一个节点删除后,发出通知,客户端就知道自己的节点时最小节点了,也就是获取锁,可以执行业务逻辑了;
  3. 通过在节点中添加客户端信息,可以实现锁的重入;
  4. Zookeeper本身就是集群部署,所以单点问题不存在;

使用Zookeeper实现的缺点:

  1. 天然的失效机制,存在概率很小的漏洞,当因为网络抖动,客户端与Zookeeper的Session断开,并且重试失败,则Zookeeper会将节点删除,这样,下一个节点就认为其他线程已经释放锁了,从而导致并发问题,当然了,这个概率非常低。
  2. 性能上步入缓存实现,因为加锁释放锁是通过节点的添加和删除实现的,而节点的添加删除只能由集群中的Leader节点执行,然后同步到Follower机器上。

总结

我们介绍了四种实现方式,其实也可以说是三种,分别是数据库实现、缓存实现、Zookeeper实现。

每一种方式都有各自的优势,也有各自的问题。根据具体的业务场景,选择最合适的才是王道。其中Zookeeper实现的可靠性最高,缓存实现的性能最好。

欢迎大家评论、转发、收藏~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值