分布式锁相关探索
[TOC]
分布式锁场景
我们在一些高并发的场景中,为了避免由于资源竞争和资源共享之间的一些问题,会引入锁
这个概念,在锁
的基础上,延伸出来了 可重入锁、读写锁、独占锁、共享锁等,以及基于 AQS 实现的信号量,内存栅栏等。
当我们的服务架构,从单机时代,发展到了分布式架构的时候,以往的一些并发问题,也由单机的场景,衍生到了分布式的场景。
针对一些资源的共享、竞争场景,也就需要有分布式的机制,来去保证。目前在我们的实际业务场景中,有以下的一些场景
- 商品的编辑, 同时间,只能有一个客户端,进行商品的编辑修改操作
- 库存的编辑,同时间,也只允许一个客户端进行操作增量或者覆盖操作
- 固定的资源竞争(信号量)
上述的一些场景,那么都需要有分布式的锁以及分布式的AQS实现,来满足一些正常的业务场景。为了更好地实现这样的的一些业务场景,就诞生了分布式锁
技术
分布式锁实现方案
通用描述
分布式锁
没有特定或者固定的实现,只需要能满足锁
的分布式场景,且符合锁的特性即可,当然也得结合实现分布式锁技术中间件的一些特性。比如mysql
、redis
、zookeeper
等实现的分布式锁,都会基于各自的一些特性,比如 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 可以进行释放吗?锁安全吗?
- 持有锁线程挂掉了,锁会自动释放吗?
- 多个竞争者,可以公平竞争吗?
- 锁竞争,只能死循环尝试,可以被通知获取吗?
基于上边的这几个问题,我们细看看,可以是支持吗?
- 问题一,没办法解决,因为过期时间问题,不同业务不同时间,包括偶遇 stw 或者业务延迟,导致 rt 超时,那么整体的锁的过期时间,很难设置合理
- 如果 set key value nx pn 中 value 是固定的,那么任何一个线程或者客户端,都可以对锁进行释放,这种情况下,锁是不安全的。如果是采取 random_value ,可以解决这种问题我,但是该 random_value 得在业务上下文中传递,知道释放锁的节点,对整体侵入略高
- 如果持有锁挂掉了,则只能等待 到了 ttl 过期时间之后,锁自动释放,这段时间,其余客户端,只能损耗资源,循环尝试加锁
- 多个竞争者尝试加锁,只能抢占式,不能公平竞争,先到先得。且原子操作,只能加锁失败,则失败。如果要实现 tryAcquire 等待,得额外去封装实现
- 显然当前的实现中,是不能的
看了普通的 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 锁的实际实现。之所以是临时顺序节点,

如图中红色所示,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 各种分布式锁实现
在文档中,也可以看到,和 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的加锁逻辑,大体流程一致。
-
加锁之前,判断本地锁 map 中是否存在 当前锁,从在重入 + 1 加锁成功
-
构建并创建当前线程 的锁的 path,并获取当前lock 下是否已经有节点,并按照顺序排序
-
业务逻辑执行,判断是否加锁成功
采取的是临时顺序节点,关于加锁是是否成功的判断,放在了写 path 之后的业务代码逻辑中,就是上图紫色的部分,这部分,不同的锁,有不同的实现方式。
-
加锁成功,放入本地线程锁 对象中,实现可重入,避免多次的 与 zk 的网络交互
-
加锁失败,添加对排序之后的前序节点的 watcher
当判断获取锁失败 时,会注册一个监听器,监听
当前节点的前序节点
,一旦锁释放,每一个节点的删除,只会通知注册了当前节点的监听器,这样就做到了锁的公平竞争。这样的就避免了 统一把锁释放之后导致的惊群效应
然后进入 wait ,根据当前的锁是否设置了获取等待超时时间,进行长时间,或者固定时间的等待
关于锁的释放,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实现的分布式锁 技术,各有优点,下边做一个简单对比总结
锁的特点 | redission(redis) | cuator(zk) |
---|---|---|
锁模型实现 | 全程 基于 redis server实现 | zk server + jvm 内存 map(lockData |
锁过期时间 | 过期时间 不过期 (默认时间)+ 续约(watchdog) |
默认不过期 自行释放,或者依赖 session 过期 |
是否只是公平锁 | 默认实现非公平锁 通过** 队列 和有序集合 **实现 公平锁的锁模型,发布订阅实现锁的竞争,避免惊群 |
默认 公平可重入临时顺序节点 ,watcher 监听上个加锁节点。天然支撑 |
可重入实现 | redis hash 维护 | 本地内存 map 实现 可重入 |
锁的多样性 | 公平锁,可重入锁,信号量等 都支持 | 公平锁,可重入锁,信号量等 都支持 |
针对锁的使用建议:
看具体的是使用场景,如果对强一致性要求不高,且锁竞争 激烈,建议用redisson 实现的 分布式锁
如果是 有强一致性要求,且 竞争不激烈的场景,建议用 curator 实现的 zk 锁
PS:遗漏之处,在所难免,欢迎指出,一起进步