Zookeeper的应用-分布式锁
参考:
https://blog.csdn.net/sunfeizhi/article/details/51926396
https://blog.csdn.net/crazymakercircle/article/details/85956246
https://www.cnblogs.com/zjfjava/p/10994004.html
基于zookeeper实现分布式锁
一、分布式锁介绍
分布式锁主要用于在分布式环境中保护跨进程、跨主机、跨网络的共享资源实现互斥访问,以达到保证数据的一致性。
二、架构介绍
在介绍使用Zookeeper实现分布式锁之前,首先看当前的系统架构图
解释: 左边的整个区域表示一个Zookeeper集群,locker是Zookeeper的一个持久节点,node_1、node_2、node_3是locker这个持久节点下面的临时顺序节点。client_1、client_2、client_n表示多个客户端,Service表示需要互斥访问的共享资源。
三、分布式锁获取思路
1.获取分布式锁的总体思路
在获取分布式锁的时候在locker节点下创建临时顺序节点,释放锁的时候删除该临时节点。客户端调用createNode方法在locker下创建临时顺序节点,
然后调用getChildren(“locker”)来获取locker下面的所有子节点,注意此时不用设置任何Watcher。客户端获取到所有的子节点path之后,如果发现自己在之
前创建的子节点序号最小,那么就认为该客户端获取到了锁。如果发现自己创建的节点并非locker所有子节点中最小的,说明自己还没有获取到锁,
此时客户端需要找到比自己小的那个节点,然后对其调用exist()方法,同时对其注册事件监听器。之后,让这个被关注的节点删除,则客户端的Watcher会
收到相应通知,此时再次判断自己创建的节点是否是locker子节点中序号最小的,如皋是则获取到了锁,如果不是则重复以上步骤继续获取到比自己小的一个
节点并注册监听。当前这个过程中还需要许多的逻辑判断。
2.获取分布式锁的核心算法流程
下面同个一个流程图来分析获取分布式锁的完整算法,如下:
解释:客户端A要获取分布式锁的时候首先到locker下创建一个临时顺序节点(node_n),然后立即获取locker下的所有(一级)子节点。
此时因为会有多个客户端同一时间争取锁,因此locker下的子节点数量就会大于1。对于顺序节点,特点是节点名称后面自动有一个数字编号,
先创建的节点数字编号小于后创建的,因此可以将子节点按照节点名称后缀的数字顺序从小到大排序,这样排在第一位的就是最先创建的顺序节点,
此时它就代表了最先争取到锁的客户端!此时判断最小的这个节点是否为客户端A之前创建出来的node_n,如果是则表示客户端A获取到了锁,
如果不是则表示锁已经被其它客户端获取,因此客户端A要等待它释放锁,也就是等待获取到锁的那个客户端B把自己创建的那个节点删除。
此时就通过监听比node_n次小的那个顺序节点的删除事件来知道客户端B是否已经释放了锁,如果是,此时客户端A再次获取locker下的所有子节点,
再次与自己创建的node_n节点对比,直到自己创建的node_n是locker的所有子节点中顺序号最小的,此时表示客户端A获取到了锁!
Zookeeper 分布式锁 - 图解
1.1. 分布式锁 简介
在我们进行单机应用开发,涉及并发同步的时候,我们往往采用synchronized或者Lock的方式来解决多线程间的代码同步问题。但当我们的应用是分布式集群工作的情况下,那么就需要一种更加高级的锁机制,来处理种跨机器的进程之间的数据同步问题。
这就是分布式锁。
1.1.1. 图解:公平锁和可重入锁 模型
分布式锁的概念和原理,比较抽象难懂。如果用一个简单的故事来类比,估计就简单多了。
很久以前,在一个村子有一口井,水质非常的好,村民们都抢着取井里的水。井就那么一口,村里的人很多,村民为争抢取水打架斗殴,甚至头破血流。
问题总是要解决,于是村长绞尽脑汁,最终想出了一个凭号取水的方案。井边安排一个看井人,维护取水的秩序。
说起来,秩序很简单,取水之前,先取号。号排在前面的,就可以先取水。先到的排在前面,那些后到的,没有排在最前面的人,一个一个挨着,在井边排成一队。取水示意图如下 :
这种排队取水模型,就是一种锁的模型。排在最前面的号,拥有取水权,就是一种典型的独占锁。另外,先到先得,号排在前面的人先取到水,取水之后就轮到下一个号取水,至少,看起来挺公平的,说明它是一种公平锁。
在公平独占锁的基础上,再进一步,看看可重入锁的模型。
假定,取水时以家庭为单位,哪个家庭任何人拿到号,就可以排号取水,而且如果一个家庭有一个人拿到号,其它家人这时候过来打水不用再取号。新的排号取水示意图如下 :
如上图的1号,老公有号,他的老婆来了,直接排第一个,妻凭夫贵。再看上图的2号,父亲正在打水,他的儿子和女儿也到井边了,直接排第二个,这个叫做子凭父贵。 等等,如果是同一个家庭,可以直接复用排号,不用重新取号从后面排起。
以上这个故事模型,就是可以重入锁的模型。只要满足条件,同一个排号,可以用来多次取水。在锁的模型中,相当于一把锁,可以被多次锁定,这就叫做可重入锁。
1.1.2. 图解: zookeeper分布式锁的原理
理解了锁的原理后,就会发现,Zookeeper 天生就是一副分布式锁的胚子。
首先,Zookeeper的每一个节点,都是一个天然的顺序发号器。
在每一个节点下面创建子节点时,只要选择的创建类型是有序(EPHEMERAL_SEQUENTIAL 临时有序或者PERSISTENT_SEQUENTIAL 永久有序)类型,那么,新的子节点后面,会加上一个次序编号。这个次序编号,是上一个生成的次序编号加一
比如,创建一个用于发号的节点“/test/lock”,然后以他为父亲节点,可以在这个父节点下面创建相同前缀的子节点,假定相同的前缀为“/test/lock/seq-”,在创建子节点时,同时指明是有序类型。如果是第一个创建的子节点,那么生成的子节点为/test/lock/seq-0000000000,下一个节点则为/test/lock/seq-0000000001,依次类推,等等。
其次,Zookeeper节点的递增性,可以规定节点编号最小的那个获得锁。
一个zookeeper分布式锁,首先需要创建一个父节点,尽量是持久节点(PERSISTENT类型),然后每个要获得锁的线程都会在这个节点下创建个临时顺序节点,由于序号的递增性,可以规定排号最小的那个获得锁。所以,每个线程在尝试占用锁之前,首先判断自己是排号是不是当前最小,如果是,则获取锁。
第三,Zookeeper的节点监听机制,可以保障占有锁的方式有序而且高效。
每个线程抢占锁之前,先抢号创建自己的ZNode。同样,释放锁的时候,就需要删除抢号的Znode。抢号成功后,如果不是排号最小的节点,就处于等待通知的状态。等谁的通知呢?不需要其他人,只需要等前一个Znode 的通知就可以了。当前一个Znode 删除的时候,就是轮到了自己占有锁的时候。第一个通知第二个、第二个通知第三个,击鼓传花似的依次向后。
Zookeeper的节点监听机制,可以说能够非常完美的,实现这种击鼓传花似的信息传递。具体的方法是,每一个等通知的Znode节点,只需要监听linsten或者 watch 监视排号在自己前面那个,而且紧挨在自己前面的那个节点。 只要上一个节点被删除了,就进行再一次判断,看看自己是不是序号最小的那个节点,如果是,则获得锁。
为什么说Zookeeper的节点监听机制,可以说是非常完美呢?
一条龙式的首尾相接,后面监视前面,就不怕中间截断吗?比如,在分布式环境下,由于网络的原因,或者服务器挂了或则其他的原因,如果前面的那个节点没能被程序删除成功,后面的节点不就永远等待么?
其实,Zookeeper的内部机制,能保证后面的节点能够正常的监听到删除和获得锁。在创建取号节点的时候,尽量创建临时znode 节点而不是永久znode 节点,一旦这个 znode 的客户端与Zookeeper集群服务器失去联系,这个临时 znode 也将自动删除。排在它后面的那个节点,也能收到删除事件,从而获得锁。
说Zookeeper的节点监听机制,是非常完美的。还有一个原因。
Zookeeper这种首尾相接,后面监听前面的方式,可以避免羊群效应。所谓羊群效应就是每个节点挂掉,所有节点都去监听,然后做出反映,这样会给服务器带来巨大压力,所以有了临时顺序节点,当一个节点挂掉,只有它后面的那一个节点才做出反映。
1.1.3. 分布式锁的基本流程
接下来就是基于zookeeper,实现一下分布式锁。
首先定义了一个锁的接口,很简单,一个加锁方法,一个解锁方法。
/**
* create by 尼恩 @ 疯狂创客圈
**/
public interface Lock {
boolean lock() throws Exception;
boolean unlock();
}
使用zookeeper实现分布式锁的算法流程,大致如下:
(1)如果锁空间的根节点不存在,首先创建Znode根节点。这里假设为“/test/lock”。这个根节点,代表了一把分布式锁。
(2)客户端如果需要占用锁,则在“/test/lock”下创建临时的且有序的子节点。
这里,尽量使一个有意义的子节点前缀,比如“/test/lock/seq-”。则第一个客户端对应的子节点为“/test/lock/seq-000000000”,第二个为 “/test/lock/seq-000000001”,以此类推。
如果前缀为“/test/lock/”,则第一个客户端对应的子节点为“/test/lock/000000000”,第二个为 “/test/lock/000000001” ,以此类推,也非常直观。
(3)客户端如果需要占用锁,还需要判断,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点。如果是则认为获得锁,否则监听前一个Znode子节点变更消息,获得子节点变更通知后重复此步骤直至获得锁;
(4)获取锁后,开始处理业务流程。完成业务流程后,删除对应的子节点,完成释放锁的工作。以便后面的节点获得分布式锁。
1.1.4. 加锁的实现
lock方法的具体算法是,首先尝试着去加锁,如果加锁失败就去等待,然后再重复。
代码如下:
@Override
public boolean lock() {
try {
boolean locked = false;
locked = tryLock();
if (locked) {
return true;
}
while (!locked) {
await();
if (checkLocked()) {
locked=true;
}
}
return true;
} catch (Exception e) {
e.printStackTrace();
unlock();
}
return false;
}
尝试加锁的tryLock方法是关键。做了两件重要的事情:
(1)创建临时顺序节点,并且保存自己的节点路径
(2)判断是否是第一个,如果是第一个,则加锁成功。如果不是,就找到前一个Znode节点,并且保存其路径到prior_path。
tryLock方法代码节选如下:
private boolean tryLock() throws Exception {
//创建临时Znode
List<String> waiters = getWaiters();
locked_path = ZKclient.instance
.createEphemeralSeqNode(LOCK_PREFIX);
if (null == locked_path) {
throw new Exception("zk error");
}
locked_short_path = getShorPath(locked_path);
//获取等待的子节点列表,判断自己是否第一个
if (checkLocked()) {
return true;
}
// 判断自己排第几个
int index = Collections.binarySearch(waiters, locked_short_path);
if (index < 0) { // 网络抖动,获取到的子节点列表里可能已经没有自己了
throw new Exception("节点没有找到: " + locked_short_path);
}
//如果自己没有获得锁,则要监听前一个节点
prior_path = ZK_PATH + "/" + waiters.get(index - 1);
return false;
}
创建临时顺序节点后,其完整路径存放在 locked_path 成员中。另外还截取了一个后缀路径,放在 locked_short_path 成员中。 这个后缀路径,是一个短路径,只有完整路径的最后一层。在和取到的远程子节点列表中的其他路径进行比较时,需要用到短路径。因为子节点列表的路径,都是短路径,只有最后一层。
然后,调用checkLocked方法,判断是否是锁定成功。如果是则返回。如果自己没有获得锁,则要监听前一个节点。找出前一个节点的路径,保存在 prior_path 成员中,供后面的await 等待方法,去监听使用。
在进入await等待方法的介绍前,先说下checkLocked 锁定判断方法。
在checkLocked方法中,判断是否可以持有锁。判断规则很简单:当前创建的节点,是否在上一步获取到的子节点列表的第一个位置:
如果是,说明可以持有锁,返回true,表示加锁成功;
如果不是,说明有其他线程早已先持有了锁,返回false。
checkLocked方法的代码如下:
private boolean checkLocked() {
//获取等待的子节点列表
List<String> waiters = getWaiters();
//节点按照编号,升序排列
Collections.sort(waiters);
// 如果是第一个,代表自己已经获得了锁
if (locked_short_path.equals(waiters.get(0))) {
log.info("成功的获取分布式锁,节点为{}", locked_short_path);
return true;
}
return false;
}
checkLocked方法比较简单,就是获取到所有子节点列表,并且从小到大根据节点名称进行排序,主要依靠后10位数字,因为前缀都是一样的。
排序的结果,如果自己的locked_short_path位置在第一个,代表自己已经获得了锁。
现在正式进入等待方法await的介绍。
等待方法await,表示在争夺锁失败以后的等待逻辑。那么此处该线程应该做什么呢?
private void await() throws Exception {
if (null == prior_path) {
throw new Exception("prior_path error");
}
final CountDownLatch latch = new CountDownLatch(1);
//订阅比自己次小顺序节点的删除事件
Watcher w = new Watcher() {
@Override
public void process(WatchedEvent watchedEvent) {
System.out.println("监听到的变化 watchedEvent = " + watchedEvent);
log.info("[WatchedEvent]节点删除");
latch.countDown();
}
};
client.getData().usingWatcher(w).forPath(prior_path);
latch.await(WAIT_TIME, TimeUnit.SECONDS);
}
首先添加一个watcher监听,而监听的地址正是上面一步返回的prior_path 成员。这里,仅仅会监听自己前一个节点的变动,而不是父节点下所有节点的变动。然后,调用latch.await,进入等待状态,等到latch.countDown()被唤醒。
一旦prior_path节点发生了变动,那么就将线程从等待状态唤醒,重新一轮的锁的争夺。
至此,关于加锁的算法基本完成。但是,上面还没有实现锁的可重入。
什么是可重入呢?
只需要保障同一个线程进入加锁的代码,可以重复加锁成功即可。
修改前面的lock方法,在前面加上可重入的判断逻辑。代码如下:
public boolean lock() {
synchronized (this) {
if (lockCount.get() == 0) {
thread = Thread.currentThread();
lockCount.incrementAndGet();
} else {
if (!thread.equals(Thread.currentThread())) {
return false;
}
lockCount.incrementAndGet();
return true;
}
}
//...
}
为了变成可重入,在代码中增加了一个加锁的计数器lockCount ,计算重复加锁的次数。如果是同一个线程加锁,只需要增加次数,直接返回,表示加锁成功。
1.1.5. 释放锁的实现
释放锁主要有两个工作:
(1)减少重入锁的计数,如果不是0,直接返回,表示成功的释放了一次;
(2)如果计数器为0,移除Watchers监听器,并且删除创建的Znode临时节点;
代码如下:
@Override
public boolean unlock() {
if (!thread.equals(Thread.currentThread())) {
return false;
}
int newLockCount = lockCount.decrementAndGet();
if (newLockCount < 0) {
throw new IllegalMonitorStateException("Lock count has gone negative for lock: " + locked_path);
}
if (newLockCount != 0) {
return true;
}
try {
if (ZKclient.instance.isNodeExist(locked_path)) {
client.delete().forPath(locked_path);
}
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
这里,为了尽量保证线程安全,可重入计数器的类型,不是int类型,而是Java并发包中的原子类型——AtomicInteger。
1.1.1. 分布式锁的应用场景
前面的实现,主要的价值是展示一下分布式锁的基础开发和原理。实际的开发中,如果需要使用到分布式锁,并不需要自己造轮子,可以直接使用curator客户端中的各种官方实现的分布式锁,比如其中的InterProcessMutex 可重入锁。
InterProcessMutex 可重入锁的使用实例如下:
@Test
public void testzkMutex() throws InterruptedException {
CuratorFramework client=ZKclient.instance.getClient();
final InterProcessMutex zkMutex =
new InterProcessMutex(client,"/mutex"); ;
for (int i = 0; i < 10; i++) {
FutureTaskScheduler.add(() -> {
try {
zkMutex.acquire();
for (int j = 0; j < 10; j++) {
count++;
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("count = " + count);
zkMutex.release();
} catch (Exception e) {
e.printStackTrace();
}
});
}
Thread.sleep(Integer.MAX_VALUE);
}
最后,总结一下Zookeeper分布式锁。
Zookeeper分布式锁,能有效的解决分布式问题,不可重入问题,实现起来较为简单。
但是,Zookeeper实现的分布式锁其实存在一个缺点,那就是性能并不太高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后Leader服务器还需要将数据同不到所有的Follower机器上。
所以,在高性能,高并发的场景下,不建议使用Zk的分布式锁。
目前分布式锁,比较成熟、主流的方案是基于redis及基于zookeeper的二种方案。这两种锁,应用场景不同。而 zookeeper只是其中的一种。Zk的分布式锁的应用场景,主要高可靠,而不是太高并发的场景下。
在并发量很高,性能要求很高的场景下,推荐使用基于redis的分布式锁。
Zookeeper和Redis实现分布式锁的可靠性分析
在分布式系统中,为保证同一时间只有一个客户端可以对共享资源进行操作,需要对共享资源加锁来实现,常见有三种方式:
- 基于数据库实现分布式锁
- 基于 Redis 实现分布式锁
- 基于 Zookeeper 实现分布式锁
高并发下数据库锁性能太差,本文不做探究。仅针对Redis 和 Zookeeper 实现的分布式锁进行分析。
实现一个分布式锁应该具备的特性:
- 高可用、高性能的获取锁与释放锁
- 在分布式系统环境下,一个方法或者变量同一时间只能被一个线程操作
- 具备锁失效机制,网络中断或宕机无法释放锁时,锁必须被删除,防止死锁
- 具备阻塞锁特性,即没有获取到锁,则继续等待获取锁
- 具备非阻塞锁特性,即没有获取到锁,则直接返回获取锁失败
- 具备可重入特性,一个线程中可以多次获取同一把锁,比如一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁
先上结论,Redis在锁时间限制和缓存一致性存在一定问题,Zookeeper在可靠性上强于Redis,只是效率相对较低,开发人员需要根据实际需求进行技术选型。
单机情况下:
1. Redis单机实现分布式锁
1.1 Redis加锁
//SET resource_name my_random_value NX PX 30000 String result = jedis.set(key, value, "NX", "PX", 30000); if ("OK".equals(result)) { return true; //代表获取到锁 } return false;
加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time)
,这个set()方法一共有五个形参:
-
第一个为key,使用key来当锁,因为key是唯一的。
-
第二个为value,是由客户端生成的一个随机字符串,相当于是客户端持有锁的标志。用于标识加锁和解锁必须是同一个客户端。
-
第三个为nxxx,传的是NX,意思是SET IF NOT EXIST,即当key不存在时,进行set操作;若key已经存在,则不做任何操作。
-
第四个为expx,传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
-
第五个为time,与第四个参数相呼应,代表key的过期时间,如上30000表示这个锁有一个30秒的自动过期时间。
1.2 Redis解锁
解锁时,为了防止客户端1获得的锁,被客户端2给释放,需要采用的Lua脚本来释放锁:
final Long RELEASE_SUCCESS = 1L; //采用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)); if (RELEASE_SUCCESS.equals(result)) { return true; } return false;
在执行这段Lua脚本的时候,KEYS[1]的值为 key,ARGV[1]的值为 value。原理就是先获取锁对应的value值,保证和客户端传进去的value值相等,这样就能避免自己的锁被其他人释放。另外,采取Lua脚本操作保证了原子性。如果不是原子性操作,则有了下述情况出现:
1.3 Redis加锁过期时间设置问题
理想情况是客户端Redis加锁后,完成一系列业务操作,顺利在锁过期时间前释放掉锁,这个分布式锁的设置是有效的。但是如果客户端在操作共享资源的过程中,因为长期阻塞的原因,导致锁过期,那么接下来访问共享资源就变得不再安全。
2. Zookeeper单机实现分布式锁
2.1 Curator实现Zookeeper加解锁
使用 Apache 开源的curator 可实现 Zookeeper 分布式锁。
可以通过调用 InterProcessLock接口提供的几个方法来实现加锁、解锁。
/** * 获取锁、阻塞等待、可重入 */ public void acquire() throws Exception; /** * 获取锁、阻塞等待、可重入、超时则获取失败 */ public boolean acquire(long time, TimeUnit unit) throws Exception; /** * 释放锁 */ public void release() throws Exception; /** * Returns true if the mutex is acquired by a thread in this JVM */ boolean isAcquiredInThisProcess();
2.2 Zookeeper加锁实现原理
Zookeeper的分布式锁原理是利用了临时节点(EPHEMERAL)的特性。其实现原理:
- 创建一个锁目录lock
- 线程A获取锁会在lock目录下,创建临时顺序节点
- 获取锁目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁
- 线程B创建临时节点并获取所有兄弟节点,判断自己不是最小节点,设置监听(watcher)比自己次小的节点(只关注比自己次小的节点是为了防止发生“羊群效应”)
- 线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是最小的节点,获得锁
由于节点的临时属性,如果创建znode的那个客户端崩溃了,那么相应的znode会被自动删除。这样就避免了设置过期时间的问题。
2.3 GC停顿导致临时节点释放问题
但是使用临时节点又会存在另一个问题:Zookeeper如果长时间检测不到客户端的心跳的时候(Session时间),就会认为Session过期了,那么这个Session所创建的所有的ephemeral类型的znode节点都会被自动删除。
如上图所示,客户端1发生GC停顿的时候,Zookeeper检测不到心跳,也是有可能出现多个客户端同时操作共享资源的情形。当然,你可以说,我们可以通过JVM调优,避免GC停顿出现。但是注意了,我们所做的一切,只能尽可能避免多个客户端操作共享资源,无法完全消除。
集群情况下:
3. Redis集群下分布式锁存在问题
3.1 集群Master宕机导致锁丢失
为了Redis的高可用,一般都会给Redis的节点挂一个slave,然后采用哨兵模式进行主备切换。但由于Redis的主从复制(replication)是异步的,这可能会出现在数据同步过程中,master宕机,slave来不及同步数据就被选为master,从而数据丢失。具体流程如下所示:
-
(1)客户端1从Master获取了锁。
-
(2)Master宕机了,存储锁的key还没有来得及同步到Slave上。
-
(3)Slave升级为Master。
-
(4)客户端1的锁丢失,客户端2从新的Master获取到了对应同一个资源的锁。
3.2 Redlock算法
为了应对这个情形, Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock。
antirez提出的redlock算法大概是这样的:
在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点(官方文档里将N设置成5,其实大等于3就行),同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。
为了取到锁,客户端应该执行以下操作:
-
(1)获取当前Unix时间,以毫秒为单位。
-
(2)依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
-
(3)客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
-
(4)如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
-
(5)如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。
redisson已经有对redlock算法封装,如下是调用代码示例:
Config config = new Config(); config.useSentinelServers().addSentinelAddress("127.0.0.1:6369","127.0.0.1:6379", "127.0.0.1:6389") .setMasterName("masterName") .setPassword("password").setDatabase(0); RedissonClient redissonClient = Redisson.create(config); // 还可以getFairLock(), getReadWriteLock() RLock redLock = redissonClient.getLock("REDLOCK_KEY"); boolean isLock; try { isLock = redLock.tryLock(); // 500ms拿不到锁, 就认为获取锁失败。10000ms即10s是锁失效时间。 isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS); if (isLock) { //TODO if get lock success, do something; } } catch (Exception e) { } finally { // 无论如何, 最后都要解锁 redLock.unlock(); }
3.3 Redlock未完全解决问题
Redlock算法细想一下还存在下面的问题:
节点崩溃重启,会出现多个客户端持有锁
假设一共有5个Redis节点:A, B, C, D, E。设想发生了如下的事件序列:
- (1)客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住)。
- (2)节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了。
- (3)节点C重启后,客户端2锁住了C, D, E,获取锁成功。
这样,客户端1和客户端2同时获得了锁(针对同一资源)。
为了应对节点重启引发的锁失效问题,redis的作者antirez提出了延迟重启的概念,即一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,等待的时间大于锁的有效时间。采用这种方式,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响。这其实也是通过人为补偿措施,降低不一致发生的概率。
时间跳跃问题
- (1)假设一共有5个Redis节点:A, B, C, D, E。设想发生了如下的事件序列:
- (2)客户端1从Redis节点A, B, C成功获取了锁(多数节点)。由于网络问题,与D和E通信失败。
- (3)节点C上的时钟发生了向前跳跃,导致它上面维护的锁快速过期。
- (4)客户端2从Redis节点C, D, E成功获取了同一个资源的锁(多数节点)。
- (5)客户端1和客户端2现在都认为自己持有了锁。
为了应对始终跳跃引发的锁失效问题,redis的作者antirez提出了应该禁止人为修改系统时间,使用一个不会进行“跳跃”式调整系统时钟的ntpd程序。这也是通过人为补偿措施,降低不一致发生的概率。
超时导致锁失效问题
RedLock算法并没有解决,操作共享资源超时,导致锁失效的问题。回忆一下RedLock算法的过程,如下图所示
如图所示,我们将其分为上下两个部分。对于上半部分框图里的步骤来说,无论因为什么原因发生了延迟,RedLock算法都能处理,客户端不会拿到一个它认为有效,实际却失效的锁。然而,对于下半部分框图里的步骤来说,如果发生了延迟导致锁失效,都有可能使得客户端2拿到锁。因此,RedLock算法并没有解决该问题。
4. Zookeeper集群下分布式锁可靠性分析
4.1 Zookeeper的写数据的原理
Zookeeper在集群部署中,Zookeeper节点数量一般是奇数,且一定大等于3。下面是Zookeeper的写数据的原理:
那么写数据流程步骤如下:
- (1)在Client向Follwer发出一个写的请求
- (2)Follwer把请求发送给Leader
- (3)Leader接收到以后开始发起投票并通知Follwer进行投票
- (4)Follwer把投票结果发送给Leader,只要半数以上返回了ACK信息,就认为通过
- (5)Leader将结果汇总后如果需要写入,则开始写入同时把写入操作通知给Leader,然后commit;
- (6)Follwer把请求结果返回给Client
还有一点,Zookeeper采取的是全局串行化操作。
4.2 集群模式下Zookeeper可靠性分析
下面列出Redis集群下分布式锁可能存在的问题,判断其在Zookeeper集群下是否会存在:
集群同步
- client给Follwer写数据,可是Follwer却宕机了,会出现数据不一致问题么?不可能,这种时候,client建立节点失败,根本获取不到锁。
- client给Follwer写数据,Follwer将请求转发给Leader,Leader宕机了,会出现不一致的问题么?不可能,这种时候,Zookeeper会选取新的leader,继续上面的提到的写流程。
总之,采用Zookeeper作为分布式锁,你要么就获取不到锁,一旦获取到了,必定节点的数据是一致的,不会出现redis那种异步同步导致数据丢失的问题。
时间跳跃问题
Zookeeper不依赖全局时间,不存在该问题。
超时导致锁失效问题
Zookeeper不依赖有效时间,不存在该问题。
5. 锁的其他特性比较
redis的读写性能比Zookeeper强太多,如果在高并发场景中,使用Zookeeper作为分布式锁,那么会出现获取锁失败的情况,存在性能瓶颈。
Zookeeper可以实现读写锁,Redis不行。
Zookeeper的watch机制,客户端试图创建znode的时候,发现它已经存在了,这时候创建失败,那么进入一种等待状态,当znode节点被删除的时候,Zookeeper通过watch机制通知它,这样它就可以继续完成创建操作(获取锁)。这可以让分布式锁在客户端用起来就像一个本地的锁一样:加锁失败就阻塞住,直到获取到锁为止。这套机制,redis无法实现
参考:
https://www.cnblogs.com/rjzheng/p/9310976.html
https://redis.io/topics/distlock
https://mp.weixin.qq.com/s/7ze2v9HQH07rvYoNpUTmzw
https://blog.csdn.net/fengxueersui/article/details/80139039
https://www.cnblogs.com/cjsblog/p/8367002.html
https://www.cnblogs.com/cjsblog/p/9831423.html
https://mp.weixin.qq.com/s/JLEzNqQsx-Lec03eAsXFOQ
https://blog.52itstyle.vip/archives/3202/
数据库、redis、Zookeeper三种实现分布式锁的方案比较
上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。
从理解的难易程度角度(从低到高)
数据库 > 缓存 > Zookeeper
从实现的复杂性角度(从低到高)
Zookeeper >= 缓存 > 数据库
从性能角度(从高到低)
缓存 > Zookeeper >= 数据库
从可靠性角度(从高到低)
Zookeeper > 缓存 > 数据库
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· Ollama——大语言模型本地部署的极速利器
· 使用C#创建一个MCP客户端
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· Windows编程----内核对象竟然如此简单?
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用