Java中的锁主要包括synchronized锁和JUC包中的锁,这些锁都是针对单个JVM实例上的锁,对于分布式环境如果我们需要加锁就显得无能为力。在单个JVM实例上,锁的竞争者通常是一些不同的线程,而在分布式环境中,锁的竞争者通常是一些不同的线程或者进程。如何实现在分布式环境中对一个对象进行加锁呢?答案就是分布式锁。
目前分布式锁的实现方案主要包括三种:
基于数据库实现分布式锁主要是利用数据库的唯一索引来实现,唯一索引天然具有排他性,这刚好符合我们对锁的要求:同一时刻只能允许一个竞争者获取锁。加锁时我们在数据库中插入一条锁记录,利用业务id进行防重。当第一个竞争者加锁成功后,第二个竞争者再来加锁就会抛出唯一索引冲突,如果抛出这个异常,我们就判定当前竞争者加锁失败。防重业务id需要我们自己来定义,例如我们的锁对象是一个方法,则我们的业务防重id就是这个方法的名字,如果锁定的对象是一个类,则业务防重id就是这个类名。
基于缓存实现分布式锁:理论上来说使用缓存来实现分布式锁的效率最高,加锁速度最快,因为Redis几乎都是纯内存 *** 作,而基于数据库的方案和基于Zookeeper的方案都会涉及到磁盘文件IO,效率相对低下。一般使用Redis来实现分布式锁都是利用Redis的 SETNX key value 这个命令,只有当key不存在时才会执行成功,如果key已经存在则命令执行失败。
基于Zookeeper:Zookeeper一般用作配置中心,其实现分布式锁的原理和Redis类似,我们在Zookeeper中创建瞬时节点,利用节点不能重复创建的特性来保证排他性。
在实现分布式锁的时候我们需要考虑一些问题,例如:分布式锁是否可重入,分布式锁的释放时机,分布式锁服务端是否有单点问题等。
上面已经分析了基于数据库实现分布式锁的基本原理:通过唯一索引保持排他性,加锁时插入一条记录,解锁是删除这条记录。下面我们就简要实现一下基于数据库的分布式锁。
id字段是数据库的自增id,unique_mutex字段就是我们的防重id,也就是加锁的对象,此对象唯一。在这张表上我们加了一个唯一索引,保证unique_mutex唯一性。holder_id代表竞争到锁的持有者id。
如果当前sql执行成功代表加锁成功,如果抛出唯一索引异常(DuplicatedKeyException)则代表加锁失败,当前锁已经被其他竞争者获取。
解锁很简单,直接删除此条记录即可。
是否可重入 :就以上的方案来说,我们实现的分布式锁是不可重入的,即是是同一个竞争者,在获取锁后未释放锁之前再来加锁,一样会加锁失败,因此是不可重入的。解决不可重入问题也很简单:加锁时判断记录中是否存在unique_mutex的记录,如果存在且holder_id和当前竞争者id相同,则加锁成功。这样就可以解决不可重入问题。
锁释放时机 :设想如果一个竞争者获取锁时候,进程挂了,此时distributed_lock表中的这条记录就会一直存在,其他竞争者无法加锁。为了解决这个问题,每次加锁之前我们先判断已经存在的记录的创建时间和当前系统时间之间的差是否已经超过超时时间,如果已经超过则先删除这条记录,再插入新的记录。另外在解锁时,必须是锁的持有者来解锁,其他竞争者无法解锁。这点可以通过holder_id字段来判定。
数据库单点问题 :单个数据库容易产生单点问题:如果数据库挂了,我们的锁服务就挂了。对于这个问题,可以考虑实现数据库的高可用方案,例如MySQL的MHA高可用解决方案。
使用Jedis来和Redis通信。
可以看到,我们加锁就一行代码:
jedisset(String key, String value, String nxxx, String expx, int time);
这个set()方法一共五个形参:
第一个为key,我们使用key来当锁,因为key是唯一的。
第二个为value,这里写的是锁竞争者的id,在解锁时,我们需要判断当前解锁的竞争者id是否为锁持有者。
第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set *** 作;若key已经存在,则不做任何 *** 作。
第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期时间的设置,具体时间由第五个参数决定;
第五个参数为time,与第四个参数相呼应,代表key的过期时间。
总的来说,执行上面的set()方法就只会导致两种结果:1当前没有锁(key不存在),那么久进行加锁 *** 作,并对锁设置一个有效期,同时value表示加锁的客户端。2已经有锁存在,不做任何 *** 作。
上述解锁请求中, SET_IF_NOT_EXIST (不存在则执行)保证了加锁请求的排他性,缓存超时机制保证了即使一个竞争者加锁之后挂了,也不会产生死锁问题:超时之后其他竞争者依然可以获取锁。通过设置value为竞争者的id,保证了只有锁的持有者才能来解锁,否则任何竞争者都能解锁,那岂不是乱套了。
解锁的步骤:
注意到这里解锁其实是分为2个步骤,涉及到解锁 *** 作的一个原子性 *** 作问题。这也是为什么我们解锁的时候用Lua脚本来实现,因为Lua脚本可以保证 *** 作的原子性。那么这里为什么需要保证这两个步骤的 *** 作是原子 *** 作呢?
设想:假设当前锁的持有者是竞争者1,竞争者1来解锁,成功执行第1步,判断自己就是锁持有者,这是还未执行第2步。这是锁过期了,然后竞争者2对这个key进行了加锁。加锁完成后,竞争者1又来执行第2步,此时错误产生了:竞争者1解锁了不属于自己持有的锁。可能会有人问为什么竞争者1执行完第1步之后突然停止了呢?这个问题其实很好回答,例如竞争者1所在的JVM发生了GC停顿,导致竞争者1的线程停顿。这样的情况发生的概率很低,但是请记住即使只有万分之一的概率,在线上环境中完全可能发生。因此必须保证这两个步骤的 *** 作是原子 *** 作。
是否可重入 :以上实现的锁是不可重入的,如果需要实现可重入,在 SET_IF_NOT_EXIST 之后,再判断key对应的value是否为当前竞争者id,如果是返回加锁成功,否则失败。
锁释放时机 :加锁时我们设置了key的超时,当超时后,如果还未解锁,则自动删除key达到解锁的目的。如果一个竞争者获取锁之后挂了,我们的锁服务最多也就在超时时间的这段时间之内不可用。
Redis单点问题 :如果需要保证锁服务的高可用,可以对Redis做高可用方案:Redis集群+主从切换。目前都有比较成熟的解决方案。
利用Zookeeper创建临时有序节点来实现分布式锁:
其基本思想类似于AQS中的等待队列,将请求排队处理。其流程图如下:
解决不可重入 :客户端加锁时将主机和线程信息写入锁中,下一次再来加锁时直接和序列最小的节点对比,如果相同,则加锁成功,锁重入。
锁释放时机 :由于我们创建的节点是顺序临时节点,当客户端获取锁成功之后突然session会话断开,ZK会自动删除这个临时节点。
单点问题 :ZK是集群部署的,主要一半以上的机器存活,就可以保证服务可用性。
Zookeeper第三方客户端curator中已经实现了基于Zookeeper的分布式锁。利用curator加锁和解锁的代码如下:
首先,分布式锁和我们平常讲到的锁原理基本一样,目的就是确保在多个线程并发时,只有一个线程在同一刻 *** 作这个业务或者说方法、变量。
在一个进程中,也就是一个jvm或者说应用中,我们很容易去处理控制,在 javautil 并发包中已经为我们提供了这些方法去加锁,比如 synchronized 关键字或者 Lock 锁,都可以处理。
但是如果在分布式环境下,要保证多个线程同时只有1个能访问某个资源,就需要用到分布式锁。这里我们将介绍用Redis的 setnx 命令来实现分布式锁。
其实目前通常所说的 setnx 命令,并非单指redis的 setnx key value 这条命令,这条命令可能会在后期redis版本中删除。
一般代指redis中对 set 命令加上 nx 参数进行使用, set 这个命令,目前已经支持这么多参数可选:
从 Redis 2612 版本开始, SET 命令的行为可以通过一系列参数来修改:
注入bean
这里同时启动5个线程并发往redis中存储 lock 这个key(key可以自定义,但需要一致),同时设置10秒的过期时间。
setIfAbsent 这个函数实现的功能与 setnx 命令一样,代表如果没有这个key则set成功获取到锁,否则set失败没有获取到锁。
获得锁后进行资源的 *** 作,最后释放锁。
执行效果 :
可以看到同时只有1个线程能够获取到锁。
使用 setnx 命令方式虽然 *** 作比较简单方便,但是会有如下问题:
可以在再次获取锁时,如果锁被占用就get值,判断值是否是当前线程存的随机值,如果是则再次执行 set 命令重新上锁;当然为了保证原子性这些 *** 作都要用 lua 脚本来执行。
可以使用 while 循环重复执行 setnx 命令,并设置一个超时时间退出循环。
可以尽量把锁自动过期的时间设的冗余一些。但也不能彻底解决。
可以在删除锁的时候先get值,判断值是否是当前线程存的随机值,只有相同才执行删锁的 *** 作;当然也要使用 lua 脚本执行来保证原子性。
分布式锁需要满足的特性
综上:使用 setnx 命令来实现分布式锁并不是一个很严谨的方案,如果是Java技术栈,我们可以使用 Redisson 库来解决以上问题,接下来的文章会介绍如何使用。
Redisson实现分布式锁
Redlock实现分布式锁
java reentrantlock是什么?一起来看下吧:
是一个可重入且独占式的锁,它具有与使用synchronized监视器锁相同的基本行为和语义,但与synchronized关键字相比,它更灵活、更强大,增加了轮询、超时、中断等高级功能。ReentrantLock,顾名思义,它是支持可重入锁的锁,是一种递归无阻塞的同步机制。除此之外,该锁还支持获取锁时的公平和非公平选择。
ReentrantLock的内部类Sync继承了AQS,分为公平锁FairSync和非公平锁NonfairSync。如果在绝对时间上,先对锁进行获取的请求你一定先被满足,那么这个锁是公平的,反之,是不公平的。公平锁的获取,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。ReentrantLock的公平与否,可以通过它的构造函数来决定。
事实上,公平锁往往没有非公平锁的效率高,但是,并不是任何场景都是以TPS作为唯一指标,公平锁能够减少“饥饿”发生的概率,等待越久的请求越能够得到优先满足。
ReentrantLock是通过自定义同步器来实现锁的获取与释放,我们以非公平锁(默认)实现为例,对锁的获取和释放进行详解。
获取锁:
public ReentrantLock() { sync = new NonfairSync(); }
即内部同步组件为非公平锁,获取锁的代码为:
public void lock() { synclock(); }
释放锁:
成功获取锁的线程在完成业务逻辑之后,需要调用unlock()来释放锁:
public void unlock() { syncrelease(1); }
unlock()调用NonfairSync类的release(int)方法释放锁,release(int)方法是定义在AQS中的方法:
public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null && hwaitStatus != 0) unparkSuccessor(h); return true; } return false; }
tryRelease(int)是子类需要实现的方法:
protected final boolean tryRelease(int releases) { // 计算新的状态值 int c = getState() - releases; // 判断当前线程是否是持有锁的线程,如果不是的话,抛出异常 if (ThreadcurrentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; // 新的状态值是否为0,若为0,则表示该锁已经完全释放了,其他线程可以获取同步状态了 if (c == 0) { free = true; setExclusiveOwnerThread(null); } // 更新状态值 setState(c); return free; }
如果该锁被获取n次,那么前(n-1)次tryRelease(int)方法必须返回false,只有同步状态完全释放了,才能返回true。可以看到,该方法将同步状态是否为0作为最终释放的条件,当状态为0时,将占有线程设为null,并返回true,表示释放成功。
在算法的分布式版本中,我们假设我们有N个Redis主节点。这些节点是完全独立的,因此我们不使用复制或任何其他隐式协调系统。我们已经描述了如何在单个实例中安全地获取和释放锁。我们理所当然地认为,算法将使用此方法在单个实例中获取和释放锁。在我们的示例中,我们设置了 N=5,这是一个合理的值,因此我们需要在不同的计算机或虚拟机上运行 5 个 Redis 主节点,以确保它们以一种基本独立的方式失败。
为了获取锁,客户端执行以下 *** 作:
该算法依赖于以下假设:虽然进程之间没有同步时钟,但每个进程中的本地时间以大致相同的速率更新,与锁的自动释放时间相比,误差幅度很小。这个假设与现实世界的计算机非常相似:每台计算机都有一个本地时钟,我们通常可以依靠不同的计算机来具有很小的时钟漂移。
在这一点上,我们需要更好地指定我们的互斥规则:只要持有锁的客户端在锁有效时间内(如步骤3中获得的那样)终止其工作,减去一些时间(只需几毫秒,以补偿进程之间的时钟漂移),它才能得到保证。
本文包含有关需要绑定 时钟漂移 的类似系统的更多信息: Leases:分布式文件缓存一致性的高效容错机制 。
当客户端无法获取锁时,它应该在随机延迟后重试,以便尝试取消同步多个客户端,尝试同时获取同一资源的锁(这可能会导致没有人获胜的裂脑情况)。此外,客户端在大多数 Redis 实例中尝试获取锁的速度越快,裂脑情况的窗口就越小(并且需要重试),因此理想情况下,客户端应尝试使用多路复用同时将 SET 命令发送到 N 个实例。
值得强调的是,对于未能获取大多数锁的客户端来说,尽快释放(部分)获取的锁是多么重要,这样就不需要等待密钥到期才能再次获取锁(但是,如果发生网络分区并且客户端不再能够与 Redis 实例通信, 在等待密钥过期时需要支付可用性罚款)。
释放锁很简单,无论客户端是否认为它能够成功锁定给定实例,都可以执行。
算法安全吗?让我们来看看在不同场景中会发生什么。
首先,我们假设客户端能够在大多数实例中获取锁。所有实例都将包含一个具有相同生存时间的密钥。但是,密钥是在不同的时间设置的,因此密钥也会在不同的时间过期。但是,如果在时间 T1(我们在联系第一台服务器之前采样之前采样的时间)将第一个键设置为最差,而在时间 T2(我们从最后一个服务器获得回复的时间)将最后一个键设置为最差,则我们确信集中第一个过期的密钥将至少存在 。所有其他密钥稍后将过期,因此我们确信至少这次将同时设置这些密钥。 MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT
在设置大多数密钥期间,另一个客户端将无法获取锁,因为如果 N/2+1 密钥已存在,则 N/2+1 SET NX *** 作无法成功。因此,如果获得了锁,则不可能同时重新获得它(违反互斥属性)。
但是,我们还希望确保尝试同时获取锁的多个客户端无法同时成功。
如果客户端锁定大多数实例的时间接近或大于锁定最大有效时间(我们基本上用于 SET 的 TTL),它将认为锁定无效并将解锁实例,因此我们只需要考虑客户端能够在小于有效时间的时间内锁定大多数实例的情况。在这种情况下,对于上面已经表达的参数,因为任何客户端都不应该能够重新获取锁。因此,仅当锁定大多数实例的时间大于 TTL 时间时,多个客户端才能同时锁定 N/2+1 个实例(“时间”是步骤 2 的结束),从而使锁定无效。 MIN_VALIDITY
系统活动性基于三个主要功能:
但是,我们支付的可用性损失等于网络分区上的 TTL 时间,因此,如果有连续分区,我们可以无限期地支付此罚款。每次客户端获取锁并在能够删除锁之前进行分区时,都会发生这种情况。
基本上,如果存在无限连续的网络分区,则系统可能会在无限长的时间内变得不可用。
许多使用 Redis 作为锁服务器的用户在获取和释放锁的延迟以及每秒可以执行的获取/释放 *** 作数方面都需要高性能。为了满足这一要求,与N Redis服务器对话以减少延迟的策略肯定是多路复用(将套接字置于非阻塞模式,发送所有命令,稍后读取所有命令,假设客户端和每个实例之间的RTT相似)。
但是,如果我们想以崩溃恢复系统模型为目标,则关于持久性还有另一个考虑因素。
基本上,为了看到这里的问题,让我们假设我们配置Redis时根本没有持久性。客户端在 5 个实例中的 3 个实例中获取锁。客户端能够获取锁的其中一个实例重新启动,此时我们可以为同一资源锁定3个实例,而另一个客户端可以再次锁定它,这违反了锁的独占性的安全属性。
如果我们启用AOF持久性,事情将会有所改善。例如,我们可以通过向服务器发送 SHUTDOWN 命令并重新启动它来升级服务器。由于 Redis 过期在语义上是实现的,因此当服务器关闭时,时间仍然会过去,因此我们所有的要求都很好。但是,只要它是干净关闭,一切都很好。停电怎么办?如果 Redis 配置为(默认情况下)每秒在磁盘上同步一次,则重新启动后,我们的密钥可能会丢失。从理论上讲,如果我们想在面对任何类型的实例重启时保证锁定安全,我们需要在持久性设置中启用。由于额外的同步开销,这将影响性能。 fsync=always
然而,事情比乍一看要好。基本上,只要实例在崩溃后重新启动,它就不再参与任何 当前活动的 锁定,算法安全性就会保留。这意味着实例重新启动时的当前活动锁定集都是通过锁定重新加入系统的实例以外的实例而获得的。
为了保证这一点,我们只需要在崩溃后使一个实例不可用,至少比我们使用的最大 TTL 多一点。这是实例崩溃时存在的有关锁的所有密钥变得无效并自动释放所需的时间。
使用 延迟重启 ,即使没有任何可用的Redis持久性,基本上也可以实现安全,但请注意,这可能会转化为可用性损失。例如,如果大多数实例崩溃,系统将全局不可用 TTL (此处全局意味着在此期间根本没有资源可锁定)。
如果客户端执行的工作由小步骤组成,则默认情况下可以使用较小的锁定有效期,并扩展实现锁定扩展机制的算法。基本上,如果客户端在计算过程中锁定有效性接近较低值,则可以通过将Lua脚本发送到所有实例来扩展锁定,该实例扩展密钥的TTL(如果密钥存在并且其值仍然是客户端在获取锁定时分配的随机值)。
只有当客户端能够将锁扩展到大多数实例中,并且在有效时间内(基本上要使用的算法与获取锁时使用的算法非常相似),才应考虑重新获取锁。
但是,这在技术上不会更改算法,因此应限制锁定重新获取尝试的最大次数,否则会违反其中一个活动属性。
前言:
在学习过程中,简单的整理了一些redis跟zookeeper实现分布式锁的区别,有需要改正跟补充的地方,希望各位大佬及时指出
Redis实现分布式锁思路
基于Redis实现分布式锁(setnx)setnx也可以存入key,如果存入key成功返回1,如果存入的key已经存在了,返回0
Zookeeper实现分布式锁思路
基于Zookeeper实现分布式锁 Zookeeper是一个分布式协调工具,在分布式解决方案中。
多个客户端(jvm),同时在zookeeper上创建相同的一个临时节点,因为临时节点路径是保证唯一,只要谁能够创建节点成功,谁就能够获取到锁,没有创建成功节点,就会进行等待,当释放锁的时候,采用事件通知给客户端重新获取锁的资源。
Redis实现分布式锁与Zookeeper实现分布式锁区别
相同点
实现分布式锁最终是通过什么方式?
在集群环境下,保证只允许有一个jvm进行执行。
不同点
从技术上分析
Redis 是nosql数据,主要特点缓存;
Zookeeper是分布式协调工具,主要用于分布式解决方案。
实现思路
核心通过获取锁、释放锁、死锁问题
获取锁
Zookeeper
多个客户端(jvm),会在Zookeeper上创建同一个临时节点,因为Zookeeper节点命名路径保证唯一,不允许出现重复,只要谁能够先创建成功,谁能够获取到锁。
Redis
多个客户端(jvm),会在Redis使用setnx命令创建相同的一个key,因为Redis的key保证唯一,不允许出现重复,只要谁能够先创建成功,谁能够获取到锁。
释放锁
Zookeeper使用直接关闭临时节点session会话连接,因为临时节点生命周期与session会话绑定在一块,如果session会话连接关闭的话,该临时节点也会被删除。
这时候客户端使用事件监听,如果该临时节点被删除的话,重新进入盗获取锁的步骤。
Redis在释放锁的时候,为了确保是锁的一致性问题,在删除的redis 的key时候,需要判断同一个锁的id,才可以删除。
共同特征:如何解决死锁现象问题
Zookeeper使用会话有效期方式解决死锁现象。
Redis 是对key设置有效期解决死锁现象
性能角度考虑
因为Redis是NoSQL数据库,相对比来说Redis比Zookeeper性能要好。
可靠性
从可靠性角度分析,Zookeeper可靠性比Redis更好。
因为Redis有效期不是很好控制,可能会产生有效期延迟;
Zookeeper就不一样,因为Zookeeper临时节点先天性可控的有效期,所以相对来说Zookeeper比Redis更好
总结下两者区别
Redis分布式锁,必须使用者自己间隔时间轮询去尝试加锁,当锁被释放后,存在多线程去争抢锁,并且可能每次间隔时间去尝试锁的时候,都不成功,对性能浪费很大。
Zookeeper分布锁,首先创建加锁标志文件,如果需要等待其他锁,则添加监听后等待通知或者超时,当有锁释放,无须争抢,按照节点顺序,依次通知使用者。
我在学习过程中整理了一些学习资料,可以分享给做java的工程师朋友们,相互交流学习,需要的可以私信 (资料) 即可免费获取Java架构学习资料(里面有高可用、高并发、高性能及分布式、Jvm性能调优、Spring源码,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多个知识点的架构资料)
其中覆盖了互联网的方方面面,期间碰到各种产品各种场景下的各种问题,很值得大家借鉴和学习,扩展自己的技术广度和知识面。 最后记得帮作者点个关注
以上就是关于高并发没锁可不行,三种分布式锁详解全部的内容,包括:高并发没锁可不行,三种分布式锁详解、Redis的Setnx命令实现分布式锁、java reentrantlock等相关内容解答,如果想了解更多相关内容,可以关注我们,你们的支持是我们更新的动力!
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)