写点什么

分布式锁相关探索

用户头像
PCMD
关注
发布于: 2021 年 06 月 23 日
分布式锁相关探索

分布式锁场景

我们在一些高并发的场景中,为了避免由于资源竞争和资源共享之间的一些问题,会引入 这个概念,在的基础上,延伸出来了 可重入锁、读写锁、独占锁、共享锁等,以及基于 AQS 实现的信号量,内存栅栏等。


当我们的服务架构,从单机时代,发展到了分布式架构的时候,以往的一些并发问题,也由单机的场景,衍生到了分布式的场景。


针对一些资源的共享、竞争场景,也就需要有分布式的机制,来去保证。目前在我们的实际业务场景中,有以下的一些场景


  • 商品的编辑, 同时间,只能有一个客户端,进行商品的编辑修改操作

  • 库存的编辑,同时间,也只允许一个客户端进行操作增量或者覆盖操作

  • 固定的资源竞争(信号量)


上述的一些场景,那么都需要有分布式的锁以及分布式的 AQS 实现,来满足一些正常的业务场景。为了更好地实现这样的的一些业务场景,就诞生了分布式锁 技术

分布式锁实现方案

通用描述

分布式锁没有特定或者固定的实现,只需要能满足 的分布式场景,且符合锁的特性即可,当然也得结合实现分布式锁技术中间件的一些特性。比如mysqlrediszookeeper 等实现的分布式锁,都会基于各自的一些特性,比如 redis 的 自动过期,zookeeper 的临时顺序节点,mysql 的唯一索引,拍他锁等。


目前业界常见和常用的 分布式锁的实现主要有 2 种,如下:


  • Redis

  • redis 的实现,有 redis 自带的 redLock 算法(避免主从复制,导致重复上锁),也有 Redisson 封装的 分布式锁,实现场景比较多样,稍后会在文中着重说明

  • Zookeeper

  • zookeeper 分布式锁的是实现,是基于 zk 的 临时顺序节点,来实现了各种锁,目前通用的是 curator 客户端封装的一些。

我看分布式锁- 该具备哪些特性?

先抛开各种分布式锁的实现机制,那么从我们使用的场景来看,一个具备生产级使用的分布式锁,该具备就那几点能力呢?


先看几个锁的常备特点:


  • 互斥性(不用说,锁的特性)

  • 单个线程 持有锁,其余线程加锁互斥

  • 锁模型(健壮)

  • 锁模型是健壮的,不随着 server 端的一些选主操作,让重复加锁

  • 防止死锁(长时间持有锁,不释放)

  • 业务逻辑未执行完,不释放锁。

  • 持有锁客户端宕机,或者丢失,锁自动释放,不永久持有

  • 高可用,容错(实现锁的 server 的高可用,redis,zk 等)

  • 实现锁服务的 高可用,不单点


在具备了上述的一些特性之后,就可以基于上述,来对锁玩出一些花样了,各种 JUC 包下的一些锁或者 AQS 实现,都可以进行实现了。比如:


  • 可重入锁

  • 公平锁

  • 读写锁

  • Semaphore

  • CountDownLatch


下边基于 redis 和 zk 这 2 中实现方案,来分别深入了解下 Redisson 和 curator 实现的分布锁内部源码,展开一些探讨和研究

分布式锁之 redis

在说 Redis 分布式锁之前,先回顾下 分布式锁所具备的几个特性,其中有一个 就是 高可用,说道高可用,离不开 redis 的部署架构,不同的部署架构,那么针对锁的高可用,是具有一些不同的处理,或者不支持高可用。那么先看下 在不同部署模式下,锁是否高可用,是否会存在一些问题


  • 基于 redis 的单实例

  • 优点:避免了 redis 主存复制,导致的重复加锁问题,可以天然避免

  • 缺点:不靠谱,会出现单点故障

  • 基于 redis 主从+ 哨兵 部署机制

  • 优点:主从部署,提高 server 的高可用机制,避免单点故障

  • 缺点:由于 redis 主从复制,是异步执行,会出现重复加锁问题(如果复制未完成,主节点宕机,导致重新选举,则就会出现重复加锁)

  • 基于 redis 的多 maste 集群部署模式

  • redis 实现了 redlock 算法,从一定程度上避免了 主从复制到来的重复加锁问题, >= N/2+1 个节点加锁成功,则加锁成功。

  • 但是 redlock 算法的实现过程和步骤太过于复杂,且算法并不健壮,相关细节问题,可以看看这篇文章 《基于Redis的分布式锁到底安全吗》,铁蕾大佬的博客。写的很透彻,感兴趣可以看看。这个算是 redis 实现分布式锁的弊端和问题,不管是最简单的 set nx px 还是 redisson 封装,都没有解决,redis 带来的天生问题


从上述的一些场景来看,redis 分布式 其实并不是很健壮,在某些场景中,满足我们的使用,

锁模型

redis

redis 最简单实现的分布式锁,这个在熟悉不过了


加锁命令:


set key value NX PX[毫秒]
复制代码


在 redis 中,就是一个 string 类型的存储,当前的锁锁模型为 string


这个算是我们接触最多的一种分 redis 布式锁 的实现了。这个锁 模型健壮吗?这个锁会死锁吗?这个锁会在没有执行完成的前提下释放锁吗。我们来模拟一下当前这种所得交互流程



其实从当前的这种锁的交互流程来看,有以下几个问题


  • 锁的过期时间设置,如何才算合理,具体业务流程能否执行完成

  • 锁的释放,线程 A 添加的锁,线程 B 可以进行释放吗?锁安全吗?

  • 持有锁线程挂掉了,锁会自动释放吗?

  • 多个竞争者,可以公平竞争吗?

  • 锁竞争,只能死循环尝试,可以被通知获取吗?


基于上边的这几个问题,我们细看看,可以是支持吗?

  1. 问题一,没办法解决,因为过期时间问题,不同业务不同时间,包括偶遇 stw 或者业务延迟,导致 rt 超时,那么整体的锁的过期时间,很难设置合理

  2. 如果 set key value nx pn 中 value 是固定的,那么任何一个线程或者客户端,都可以对锁进行释放,这种情况下,锁是不安全的。如果是采取 random_value ,可以解决这种问题我,但是该 random_value 得在业务上下文中传递,知道释放锁的节点,对整体侵入略高

  3. 如果持有锁挂掉了,则只能等待 到了 ttl 过期时间之后,锁自动释放,这段时间,其余客户端,只能损耗资源,循环尝试加锁

  4. 多个竞争者尝试加锁,只能抢占式,不能公平竞争,先到先得。且原子操作,只能加锁失败,则失败。如果要实现 tryAcquire 等待,得额外去封装实现

  5. 显然当前的实现中,是不能的


看了普通的 set nx px 实现方式和一些场景的分析,可以得到,这种模式,只能满足极少部分的场景,锁的模型是不健壮的,更不用说多样性的实现了。很难在一个企业级业务复杂场景中,被广泛使用。


既然 redis 目前也是作为分布式锁的一种成熟的实现技术体系,那么肯定有解决了上述几个问题的实现。接下来,看看 redisson 的是实现,针对分布式锁。

redisson(锁模型 )

Redisson ,一款 java 实现的 redis 客户端封装。非常强大的一个客户端, 文档介绍,如果没有了解过,可以点击看看具体文档


说到 redisson 实现的分布式锁,在业界会被广泛认可,那么它是如何实现了具备企业生产级的分布式锁呢?他的锁模型较之于 redis set nx px 的方式,究竟有何不同,做了怎么一样的一些改进,从而解决掉了上述的一些问题?


先来看看 redisson 的锁模型,是怎样的一种存储模型



对应的 redis 中,真实的数据结构,如下图所示。这个就是 redis 在 server 端的锁模型



针对锁模型和加锁交互,主要分下边 3 点


  • redisson 通过采取了 hash 的 map 结构,用来做 锁的承载模型。

  • 从上边的 锁模型图中,可以看到,通过 hash 结构中的 key 即链接 Id:加锁线程 Id 来实现了锁的对象私有,从而处理掉了之前说的,锁不安全的问题.并 通过 value 内的值,支持了 锁的可重入,这样也从而避免了内部循环调用,导致的死锁问题

  • 本地线程中的 watchdog

  • 在本地通过了加锁之后的一个 watchdog 的定时器模式(通过 netty 的 TimerTask 实现),实现了锁过期时间的续约功能,避免了设置超长时间死锁和锁的多客户端持有问题,默认锁 30s,每 10s 续约一次

  • 上一部分。关于锁过期时间设置,怎样才合理的问题? 通过内置的 watchdog 进行了解决。

    默认短时间 ttl 然后内部 定时任务续约,如果客户端宕机了,则通过自动过期,对锁进行释放。

  • lua 脚本,实现原子性操作

  • 然后通过 lua 脚本,实现了加锁逻辑的原子性,锁互斥,可重入,以及锁续约,具体可以先看下代码,稍后会在一个锁的具体加锁流程总,针对整体的 lua 脚本做一些注释说明



关于 lua 脚本,一些详细的描述,和 redis 中的用法 可以点击链接查看redis lua


再来回顾下,上边 set nx px 实现锁的一些维问题,是否都解决了

  • 锁的过期时间设置,如何才算合理,具体业务流程能否执行完成

  • watchdog 加锁成功之后的定时器,解决了这个问题,端时间,固定周期续约,直到完成

  • 锁的释放,线程 A 添加的锁,线程 B 可以进行释放吗?锁安全吗?

  • 锁 hash 结构中的 key 链接 Id:加锁线程 Id 实现了 那个线程加的锁,那个线程释放,解决了锁安全问题

  • 持有锁线程挂掉了,锁会自动释放吗?

  • watchdog 宕机之后,自然不续约了,锁自动过期 做多 30ms 释放锁

  • 多个竞争者,可以公平竞争吗?

  • 可以竞争,这个在后边的关于 公平锁缩模型中,会展开说明

  • 锁竞争,只能死循环尝试,可以被通知获取吗?

  • 不用死循环,redis 有发布订阅模式,支持锁删除时间的通知,解决通知问题

基于 redisson 的各种锁的实现

了解了 redisson 实现的锁模型之后,来看下 在 redisson 中,具体是怎么是实现的。


先看下 redisson 针对锁的一些工程结构


在下边的类图中可以看到,redission 中大部分的锁都是继承了RedissonBaseLock ,而 RedissonBaseLock 是对 jdk 中的 Lock接口的实现, 这样,在一些锁的应用的时候,和掉 jdk 的 lock 方式并无任何差别,在调用模型理解上,带来了很大的便利,这个也是 redisson 的一大特性,包括支持一些 java 原生对象 map,list ,分布式队列等




redisson 整体的代码设计很漂亮,建议可以看看 redisson 的源码。

可重入锁

RLock lock1 = redissonClient.getLock("lock1");lock.lock();lock.unlock();
复制代码


redisson 基础默认的锁实现,是可重入锁,调用也是非常简单


结合可重入锁的流程,一起来探索下 redisson 是如何是进行加锁和释放锁的。


先不看具体代码实现,来一张流程图,看下整体的一个实现步骤,然后在基于代码,一步步深入了解下 redisson 的加锁原理


可重入锁加锁 逻辑



原图可能看不太清楚,这边可以看放大图 大图传送门


从上边的流程可以看到,redisson 上锁流程,基本分以下一些步骤


  • 获取锁 & 根据名称取模

  • 主要是为了构建 RLock() 的实现对象,内部包含了一系列后续操作流程中,所需要具备的一些东西。包括连接器,配置模式(多 master 集群,或者主从) 后续的通过 key 计算 slot ,进行 node 资源的选择,发布订阅实现等

  • 加锁

  • redisson 加锁,通过 lua 脚本进行实现,下边是关于加锁的具体 lua 实现(最基础的 lua 脚本)

  • 主要理解其中的一些 keys,argvs 数组各自代表的含义


  实际加锁 lua 脚本   针对 lua 脚本,先看一些 各种乱七八糟的 key,argv 等        KEYS[1] 加锁的 key 名称 getRawName()    ARGV[1] 锁时间  unit.toMillis(leaseTime)    ARGV[2]  线程 + hash  getLockName(threadId)      <T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {          return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,                  "if (redis.call('exists', KEYS[1]) == 0) then " + // 如果 key 不存在                          "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + // 设置 lock key 这个 hash 结构中,当前线程 的可重入数为 1 ,即                                // logkey:{dea8ab75-570b-45f5-bead-eeb748352cd1:1  :1}                          "redis.call('pexpire', KEYS[1], ARGV[1]); " + // 设置lock key 的超时时间                           "return nil; " + // 返回 nil                          "end; " +                          "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + // 如果 key 存在,则说明 锁已经存在 且 是当前线程 dea8ab75-570b-45f5-bead-eeb748352cd1:1   加的锁的话                            "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +  // 可重入数 +1                          "redis.call('pexpire', KEYS[1], ARGV[1]); " + // 重新刷新当前 key 的过期时间                          "return nil; " +                          "end; " +                          "return redis.call('pttl', KEYS[1]);", // 如果都不满足,则说明锁存在,且不是当前线程所持有的,则加锁失败,返回锁存在的 剩余 ttl 时间                  Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));      }
复制代码


lua 脚本 单独看的话,的确比较眼花缭乱,各种 avg[1] keys[1] ,很容易迷失,建议看的时候,把 对应的 key,argv 等,数组 具象化正对应的值,会看起来更加清新


  • 加锁成功

  • 在执行完具体的加锁流程之后,在外层会针对加锁之后的结果,做一些处理,最终返回出去是加锁成功,或者失败。

  • 如果加锁成功,且没有指定 锁的过期时间。会启动一个 watchdog 来进行对当前锁的续约。 默认锁的过期时间为 30s,且 watchdog 会没间隔 10s 进行一次续约刷新,维持 锁的存活时间,相当于是用了一种心跳机制,来进行锁续约。



  • 加锁失败

  • 如果加锁失败,会进入死循环,在尝试的时间之内,无限制的进行锁的获取。当然也不是通过很 low 的无限制循环,或者自旋来一直尝试获取锁。而是基于 redis 发布订阅模式,等待锁释放的消息

  • 从上述流程可以看到,如果 redis 加锁失败,会订阅当前 redisson_lock__channel:{lock1}的事件消息。关于资源的竞争,是通过本地信号量 来实现,在订阅的时候设置本地信号量

  • 尝试获取流程中,获取锁失败,会竞争信号量,这个时候,会进入等待状态。

  • 一旦 锁 key 被删除,会发送 删除时间,监听器监听到 UNLOCK_MESSAGE 之后,对信号量资源 释放,从而实现了 通知订阅,客户端加锁的机制。

  • 那么这种发布订阅模式,解决掉了之前说的 锁释放,没办法通知等待线程。

    但是,这种机制,会有另外的问题吗?

    会有的,这种情况下,会给 n 个客户端,都进行通知,导致了**惊群** 效应,会导致客户端在瞬间,都去竞争锁资源,是一种非公平抢占式 的加锁方式

    不过这个问题,在后边要说的公平锁的视线中,很好地解决了这个问题



  • 锁释放

  • 释放锁,整体流程比较简单


  • 可以看下具体的 释放锁的脚本:



protected RFuture<Boolean> unlockInnerAsync(long threadId) { return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " + "return nil;" + "end; " + "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " + "if (counter > 0) then " + // 针对锁的可重入处理 "redis.call('pexpire', KEYS[1], ARGV[2]); " + "return 0; " + "else " + "redis.call('del', KEYS[1]); " + // 锁释放 "redis.call('publish', KEYS[2], ARGV[1]); " + // 发布事件 释放锁消息 LockPubSub.UNLOCK_MESSAGE "return 1; "+ "end; " + "return nil;", Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
复制代码


从当前的加锁和释放锁流程看下来,整体还是略负载,整体链路上得有几个组件,来进行辅助,最终实现了加锁,释放锁的流程,模型比 set nx px 健壮不少,过程也边的复杂

公平锁

之前说,非公平竞争锁,会在锁删除通知后,发生争抢惊群 现象,公平锁很好地解决这个问题


针对公平锁而言,是有一个先来先得的这样的一个机制,那么要实现公平锁,势必得有一个等待队列,进行排队获取。如果分布式锁要实现公平锁,其实也是需要这样的一个等待队列,来对竞争锁的资源进行排队等待。


下边来看看 redisson 总,针对公平锁的锁模型,是如何实现的



可以看到,针对 redisson 的实现,是通过 2 个辅助模型,来完成了整体的公平锁的锁模型:


  • 锁(hash) 这个和最初的锁模型一致,通过 hash 方式存储,支持可重入。

  • redisson_lock_queue:{lock4} (list) 放入了对当前锁资源的竞争者 ,后续的公平持有锁,就通过后续的这 2 个队列来实现的

  • redisson_lock_timeout:{lock4} (zset) 以竞争当前锁资源的竞争者的超时间(偏移量),来作为有序集合的排序 score ,用有序队列,时间的判断,实现排队等待线程的超时失效剔除


梳理了下整体公平锁锁的内部加锁流程,如下图所示大图链接



关于公平锁,redisson 的实现,也是在一定的时间范围之内,实现了顺序性公平竞争,并非严格的顺序一致。

每一次 有客户端尝试加锁的时候,都会针对 redisson_lock_timeout 有序集合中的分数。然后基于有序集合中的 score(与时间对比的偏移量) 与当前时间进行对比,如果预期过去时间 < 当前时间,则这个时候,会进行队列重排 剔除 过期的客户端。一旦过期则只能重新排队,顺序发生了变化

这个没办法避免,只要客户端和 redis 的网络通信 出现问题,或者延迟,都会出现这种问题,但是在很大程度上,这中问题,是可以接受的


针对公平锁的释放,如流程如下图所示



公平锁的释放,获取锁的发布事件,只有队列中第一个 加锁线程,可以获取到,然后进行加锁,通过这种方式,避免了非公平锁的问题,也就是所,不会出现惊群效应

redisson 针对公平锁的加锁实现流程,还是得依赖于 加锁线程的调用,加锁流程在 redis 中, 是一次性操作,不做额外的处理

具体的 lua 脚本,就不贴出来展开说明了,其实上边的流程图,就是根据 lua 脚本来整理的,

读写锁

读写锁 锁模型入下所示



可以看到,在读写锁的锁模型中,会有一个 mode 来进行标记 ,在看内部代码逻辑实现中,其实就是更具这个 mode 值,来进行对读写锁之间的加锁互斥逻辑处理


流程比较简单,就不画流程图了,简单描述如下:


加读锁


  • 如果不存在,则直接加读锁

  • 如果存在读锁,或者当前线程加的写锁,则锁中加入一条持有锁的对象,且新构建一套过期的 key ,如下图代码所示



释放读锁


因为读锁不互斥,多个客户端度可以加锁,且可以支持重入,读锁的释放流程如下:


  • 如果是可重入的,则可重入数-1,重新续约 目前所有持有读锁的对象剩余时间中,最长的时间

  • 如果可重入数为 0,则删除 锁中,当前线程对象的持有 ,且删除当前对象的过期 key,重新续约 目前所有持有读锁的对象剩余时间中,最长的时间

  • 如果没有其余持有者,则之间删除当前锁,并发布 锁释放事件



加写锁


  • 如果不存在,直接加写锁

  • 如果存在,且当前线程持有锁对象,重入+1 否则,加锁失败



释放写锁


  • 释放正常流程一致,如果可以重入,直接减重入数,然后重新续约 30s

  • 如果是存在自己线程加的读锁,则将锁 mode 修改为 read 写锁改读锁



加锁失败之后,通用的流程,死循环等待,监听取消锁的事情,重新进行尝试加锁

Semaphore 信号量

redisson 对于信号量的实现,是通过 在 redis 中维护的 一个 string 结构,通过发布订阅模式,来维护信号量的个数,从而进行信号量内可竞争资源的增减,比较简单


Redis 分布式锁总结

其实纵观 redissson 中各种锁的是实现,除了内部的 lua 脚本和 锁模型有区别之外,外部流程框架都是同一套流程,总结大概如下:


加锁步骤



在最初也有提到,redis 实现的分布式锁,避免不了极端场景下锁被重复持有的情况,因为 redis 为了高性能,在做主从同步的时候,采取了异步方式,进行复制。少不了 复制不过去宕机丢失的问题。redlock 算法,只是在某种成都上,解决了大部分问题,具体详细原因分析,在前文也有链接,可以深入研究。

当我们在一些场景中使用的时候,依据业务场景,从而确定当前的这种分布锁,是否我们业务可接受,从而进行选择。

分布式锁之 zookeeper

ZK 实现的分布式锁,也是比较老生长谈的实现方案了。相比于 redis ,在健壮性上,zk 有着天然的优势。


  • zk 的设计定位 ,保证 CP,天然的强一致性,不会出现类似于 Redis 的某些极端情况的下不一致问题。

  • zk 锁模型健壮,简单易用。基于临时顺序节点,从 server 的设计上,避免了一些问题


关于 zk,一些基础的特性,就不过多赘述。主要来看下与要实现分布式锁先关的东西。


zk 的原理,我们都清楚 同一路径下的节点名称不能重复 ,正式由于这个原因,天然的具备了锁的特性,不重复。节点唯一。


zk 节点,分为 4 中 持久节点持久顺序节点临时节点临时顺序节点,我们要实现锁,那么针对这 4 中节点,我们采取那种节点来作为锁的创建呢?


从分布式具备的特性,来看看


  • 互斥性(不用说,锁的特性)

  • 同一路径下的节点名称不能重复,天然支持

  • 锁模型(健壮)

  • server 保证 CP 天然强一致性

  • 防止死锁(长时间持有锁,不释放)

  • 临时节点

  • 公平竞争

  • 通过临时顺序节点,实现公平锁竞争

  • 高可用,容错(实现锁的 server 的高可用,redis,zk 等)

  • 实现锁服务的 高可用,不单点

看下来之后,其实,只能采取临时顺序节点,来做为 zk 分布式锁的锁模型


zk 也有很多优秀的开源客户端框架,curator 就是其中之一。


curator apache 基金会的顶级项目之一,由 netflix 开源的客户端。也是目前使用场景最多的一种 zk 开源 客户端


下边就来看下 curator 关于 zk 的锁模型,是如何设计的。

锁模型

Zk 锁模型示意图



如上图所示,zk 中的锁,有 2 部分组成,


  • 一部分是 属于在 zookeeper 中的临时顺序节点,即 zk 锁的实际实现。之所以是临时顺序节点,


![](/Users/p.c.m.d/Library/Application Support/typora-user-images/image-20210622053517838.png)


如图中红色所示,2 个客户端线程对 zk 执行了创建了临时顺序节点路径,其实相当于是 zk 的锁。但是会有那个线程加锁成功呢?
zk 会在创建好 path 之后进行判断,判断本次创建的顺序节点,根据不同锁的特性,判断,是否需要加锁成功。比如:
复制代码


互斥锁 判断 list 内的排序后的第一个元素,是否是当前路径,如果是,则加锁成功


读写锁 读锁 则判断 node index 是否在 Integer.MAX_VALUE 之内,如果在,加锁成功


用顺序节点,有 2 个好处


  1. 公平加锁,通过顺序节点进行排序,当第一个加锁持有线程释放之后,会通知第二个线程,进行加锁,不需要客户端争抢竞争。和 redis 的实现不同(这样就避免了惊群效应)

  2. 基于客户端链接断开是(宕机等)会进行节点的删除,即锁的释放,不会造成死锁,长时间持有锁


  • 还有一部分,是属于在本地内存中的一个 map,存储了当前线程,以及对应的锁的元数据,锁的可重入,就是通过这个本地 map 来实现的。避免频繁的和 zk server 发生交互


总结一下,关于 ZK 的锁实现,三部分:


临时顺序节点 用来放不同的线程对锁的持有对象,实现锁的加锁,发布,通知加锁等


加锁成功业务判断 锁的多样性,比如互斥锁,读写锁等


本地锁 map 实现锁的可重入


再来回顾下,上边 set nx px 实现锁的一些问题,从而来看看,用怎么样性质的节点

  • 锁的过期时间设置,如何才算合理,具体业务流程能否执行完成

  • 锁的释放,线程 A 添加的锁,线程 B 可以进行释放吗?锁安全吗?

  • 不可以,节点内,有与加锁线程对应的 uuid 锁是安全的

  • 持有锁线程挂掉了,锁会自动释放吗?

  • 会的,基于 zk 的机制,会维持 sesson,一旦宕机,或者丢失,会自动的删掉这个节点

  • 多个竞争者,可以公平竞争吗?

  • 可以公平,临时顺序节点,模型天然保证公平性,也不需要类似于 redisson 中,维护 2 个队列集合,来实现公平锁机制

  • 锁竞争,只能死循环尝试,可以被通知获取吗?

  • 不用死循环,zk 的 watcher 机制,支持锁删除事件的监听


这个锁模型,看起来也是比较清爽的,较之于 redssion 的实现,简单了不少,下边基于 curator 中针对一些锁的实现,然后一步步的来深入了解下他的一些设计

基于 cuator 实现的 zk 各种分布式锁实现

cuator 锁文档


在文档中,也可以看到,和 redssion 一样 实现了 锁(公平,读写,多锁),队列,计数器,信号量等,在应用场景来说,也已经很完备


在上边,我们已经分析了具体对应的 zk 的锁模型,以及如何进行的实现。下边来看看 curator 中,对锁的代码的具体实现。



可以看到,目前 cuator 实现的 lock 有几种 互斥锁,多锁,信号量,读写锁。下面来基于互斥锁 InterProcessMutex,


针对互斥锁,看下加锁,释放锁逻辑


####互斥锁 InterProcessMutex


/**
 * A re-entrant mutex that works across JVMs. Uses Zookeeper to hold the lock. All processes in all JVMs that
 * use the same lock path will achieve an inter-process critical section. Further, this mutex is
 * "fair" - each user will get the mutex in the order requested (from ZK's point of view)
 */
 在 InterProcessMutex 源码的注释中,可以看到,zk 实现的 InterProcessMutex 是可重入,公平的锁


zk 可重入锁加锁逻辑



zk 的加锁逻辑,大体流程一致。


  • 加锁之前,判断本地锁 map 中是否存在 当前锁,从在重入 + 1 加锁成功

  • 构建并创建当前线程 的锁的 path,并获取当前 lock 下是否已经有节点,并按照顺序排序

  • 业务逻辑执行,判断是否加锁成功

  • 采取的是临时顺序节点,关于加锁是是否成功的判断,放在了写 path 之后的业务代码逻辑中,就是上图紫色的部分,这部分,不同的锁,有不同的实现方式。

  • 加锁成功,放入本地线程锁 对象中,实现可重入,避免多次的 与 zk 的网络交互

  • 加锁失败,添加对排序之后的前序节点的 watcher

  • 当判断获取锁失败 时,会注册一个监听器,监听 当前节点的前序节点 ,一旦锁释放,每一个节点的删除,只会通知注册了当前节点的监听器,这样就做到了锁的公平竞争。这样的就避免了 统一把锁释放之后导致的惊群效应

    然后进入 wait ,根据当前的锁是否设置了获取等待超时时间,进行长时间,或者固定时间的等待


zk 可重入锁 释放锁逻辑



关于锁的释放,zk 的做法也是比较简单,这个取决于 zk 自身的特性。通过删除 path,然后在通过 ,watcher 的监听模式,然后实现通知。


  • 可重入-1 移除

  • 先移除自己针对当前锁注册的监听器(可能没有,如果第一次加锁成功)

  • 删除 当前锁 path

  • 从本地 threadData 中移除当前线程对象。

  • 对应的下一个加锁线程的监听器,监听到,进行加锁

其余各种锁的实现 & 源码解读

其实在分析中可的值,curator 关于 zk 的锁 实现,加锁流程是一致的,只不过区别在于写 path 之后,判断锁是否加锁成功逻辑,下边对这几个,来看下是如何判断的。


互斥锁 & 写锁


    public PredicateResults getsTheLock(CuratorFramework client, List<String> children, String sequenceNodeName, int maxLeases) throws Exception    {        int             ourIndex = children.indexOf(sequenceNodeName);        validateOurIndex(sequenceNodeName, ourIndex);
boolean getsTheLock = ourIndex < maxLeases; // maxLeases == 1 String pathToWatch = getsTheLock ? null : children.get(ourIndex - maxLeases);
return new PredicateResults(pathToWatch, getsTheLock); }
复制代码


其实互斥锁或者写锁,针对加锁成功的判断,是判断,当前加锁 节点,是不是是锁逻辑下的第一个,如果是,则加锁成功。并且,如果加锁失败,则返回需要监听的 path


读锁


 private PredicateResults readLockPredicate(List<String> children, String sequenceNodeName) throws Exception    {        // 判断 写锁 是不是 当前线程加的读锁,如果是,直接返回        if ( writeMutex.isOwnedByCurrentThread() )        {//            直接返回            return new PredicateResults(null, true);        }
int index = 0; // 给定 integer 的最大值,作为 第一个 写的 index int firstWriteIndex = Integer.MAX_VALUE; int ourIndex = -1; // 循环遍历 所有当前 path 下的 子节点 for ( String node : children ) {
// 如果节点中包含 写锁 if ( node.contains(WRITE_LOCK_NAME) ) { // 在 默认 index 和 写锁的 index 中取最小 firstWriteIndex = Math.min(index, firstWriteIndex); } // 如果节点包含 当前的序列节点名称,则ourIndex 替换为0; else if ( node.startsWith(sequenceNodeName) ) { ourIndex = index; break; }
++index; }
StandardLockInternalsDriver.validateOurIndex(sequenceNodeName, ourIndex); // 通过 index 的比较,判断是否 获取了锁 其实就死通过 index 是否比 Integer.MAX_VALUE 小。 针对读锁 boolean getsTheLock = (ourIndex < firstWriteIndex); String pathToWatch = getsTheLock ? null : children.get(firstWriteIndex); return new PredicateResults(pathToWatch, getsTheLock); }
复制代码


读锁的加锁成功判断,略显复杂,如果已经加了写锁,且不是当前线程所加,则直接加锁失败。


如果是读锁,则直接加锁 OK < Integer.MAX_VALUE


只是在获取节点之后。判断是否加锁成功的条件相关。

redis 锁 和 zk 锁的实现对比

在上边看了 redisson 和 curator 实现的分布式锁 技术,各有优点,下边做一个简单对比总结



针对锁的使用建议:

看具体的是使用场景,如果对强一致性要求不高,且锁竞争 激烈,建议用 redisson 实现的 分布式锁

如果是 有强一致性要求,且 竞争不激烈的场景,建议用 curator 实现的 zk 锁


PS:遗漏之处,在所难免,欢迎指出,一起进步

发布于: 2021 年 06 月 23 日阅读数: 148
用户头像

PCMD

关注

我看青山多妩媚 2020.02.07 加入

还未添加个人简介

评论

发布
暂无评论
分布式锁相关探索