目录
curator的InterProcessMutex 可重入锁
ZooKeeper分布式锁的基本实现
接下来就是基于ZooKeeper,实现一下分布式锁。首先,定义了一个锁的接口Lock,很简单,仅仅两个抽象方法:一个加锁方法,一个解锁方法。Lock接口的代码如下:
package com.crazymakercircle.zk.distributedLock;
/**
* create by 尼恩 @ 疯狂创客圈
**/
public interface Lock {
/**
* 加锁方法
*
* @return 是否成功加锁
*/
boolean lock() throws Exception;
/**
* 解锁方法
*
* @return 是否成功解锁
*/
boolean unlock();
}
使用 ZooKeeper
实现分布式锁的算法,有以下几个要点:
- 一把分布式锁通常使用一个 Znode 节点表示;如果锁对应的 Znode 节点不存在,首先创建Znode 节点。这里假设为 “/test/lock”,代表了一把需要创建的分布式锁。
-
抢占锁的所有客户端,使用锁的 Znode 节点的子节点列表来表示;如果某个客户端需要占用锁,则在 “/test/lock” 下创建一个临时有序的子节点。
-
这里,所有临时有序子节点,尽量共用一个有意义的子节点前缀。
-
比如,如果子节点的前缀为“/test/lock/seq-” ,则第一次抢锁对应的子节点为 “/test/lock/seq- 000000000” ,第二次抢锁对应的子节点为 “/test/lock/seq-000000001”,以此类推。
-
再比如,如果子节点前缀为“/test/lock/” ,则第一次抢锁对应的子节点 “/test/lock/000000000” ,第二次抢锁对应的子节点为 “/test/lock/000000001” ,以此类推,也非常直观。
-
- 如果判定客户端是否占有锁呢?
- 很简单,客户端创建子节点后,需要进行判断:自己创建的子节点,是否为当前子节点列表中序号最小的子节点。如果是,则认为加锁成功;如果不是,则监听前一个Znode子节点变更消息,等待前一个节点释放锁。
-
一旦队列中的后面的节点,获得前一个子节点变更通知,则开始进行判断,判断自己是否为当前子节点列表中序号最小的子节点,如果是,则认为加锁成功;如果不是,则持续监听,一直到获得锁。
-
获取锁后,开始处理业务流程。完成业务流程后,删除自己的对应的子节点,完成释放锁的工作,以方面后继节点能捕获到节点变更通知,获得分布式锁。
实战:加锁的实现
Lock接口中加锁的方法是
lock
()。
lock
()方法的大致流程是:首先尝试着去加锁,如果加锁失败就去等待,然后再重复。
lock()方法的实现代码
package com.crazymakercircle.zk.distributedLock;
import com.crazymakercircle.zk.ZKclient;
import lombok.extern.slf4j.Slf4j;
import org.apache.curator.framework.CuratorFramework;
import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
@Slf4j
public class ZkLock implements Lock {
//ZkLock的节点链接
private static final String ZK_PATH = "/test/lock";
private static final String LOCK_PREFIX = ZK_PATH + "/";
private static final long WAIT_TIME = 1000;
//Zk客户端
CuratorFramework client = null;
private String locked_short_path = null;
private String locked_path = null;
private String prior_path = null;
final AtomicInteger lockCount = new AtomicInteger(0);
private Thread thread;
public ZkLock() {
ZKclient.instance.init();
synchronized (ZKclient.instance) {
if (!ZKclient.instance.isNodeExist(ZK_PATH)) {
ZKclient.instance.createNode(ZK_PATH, null);
}
}
client = ZKclient.instance.getClient();
}
@Override
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;
}
}
try {
boolean locked = false;
//首先尝试着去加锁
locked = tryLock();
if (locked) {
return true;
}
//如果加锁失败就去等待
while (!locked) {
await();
//获取等待的子节点列表
List<String> waiters = getWaiters();
//判断,是否加锁成功
if (checkLocked(waiters)) {
locked = true;
}
}
return true;
} catch (Exception e) {
e.printStackTrace();
unlock();
}
return false;
}
//...省略其他的方法
}
tryLock()尝试加锁
尝试加锁的tryLock
方法是关键,做了两件重要的事情:
(1)创建临时顺序节点,并且保存自己的节点路径
(2)判断是否是第一个,如果是第一个,则加锁成功。如果不是,就找到前一个Znode
节点,并且保存其路径到prior_path
。
尝试加锁的tryLock
方法,其实现代码如下
/**
* 尝试加锁
* @return 是否加锁成功
* @throws Exception 异常
*/
private boolean tryLock() throws Exception {
//创建临时Znode
locked_path = ZKclient.instance
.createEphemeralSeqNode(LOCK_PREFIX);
//然后获取所有节点
List<String> waiters = getWaiters();
if (null == locked_path) {
throw new Exception("zk error");
}
//取得加锁的排队编号
locked_short_path = getShortPath(locked_path);
//获取等待的子节点列表,判断自己是否第一个
if (checkLocked(waiters)) {
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;
}
private String getShortPath(String locked_path) {
int index = locked_path.lastIndexOf(ZK_PATH + "/");
if (index >= 0) {
index += ZK_PATH.length() + 1;
return index <= locked_path.length() ? locked_path.substring(index)
: "";
}
return null;
}
- 创建临时顺序节点后,其完整路径存放在 locked_path 成员中;另外还截取了一个后缀路径,放在 locked_short_path 成员中,后缀路径是一个短路径,只有完整路径的最后一层。为什么要单独保存短路径呢?
- 因为,在获取的远程子节点列表中的其他路径返回结果时,返回的都是短路径,都只有最后一层路径。所以为了方便后续进行比较,也把自己的短路径保存下来。
- 创建了自己的临时节点后,调用 checkLocked 方法,判断是否是锁定成功。如果锁定成功,则返回 true;如果自己没有获得锁,则要监听前一个节点,此时需要找出前一个节点的路径,并保存在 prior_path 成员中,供后面的 await()等待方法去监听使用。在进入await()等待方法的介绍前,先说下 checkLocked 锁定判断方法。
checkLocked()检查是否持有锁
在checkLocked
()方法中,判断是否可以持有锁。判断规则很简单:当前创建的节点,是否在上一步获取到的子节点列表的第一个位置:
(1)如果是,说明可以持有锁,返回true
,表示加锁成功;
(2)如果不是,说明有其他线程早已先持有了锁,返回false
。
checkLocked()方法的代码如下:
private boolean checkLocked(List<String> waiters) {
//节点按照编号,升序排列
Collections.sort(waiters);
// 如果是第一个,代表自己已经获得了锁
if (locked_short_path.equals(waiters.get(0))) {
log.info("成功的获取分布式锁,节点为{}", locked_short_path);
return true;
}
return false;
}
- checkLocked 方法比较简单,将参与排队的所有子节点列表,从小到大根据节点名称进行排序。排序主要依靠节点的编号,也就是后 Znode 路径的10位数字,因为前缀都是一样的。排序之后,做判断,如果自己的 locked_short_path 编号位置排在第一个,如果是,则代表自己已经获得了锁。如果不是,则会返回 false。
-
如果 checkLocked ()为 false ,外层的调用方法,一般来说会执行 await()等待方法,执行夺锁失败以后的等待逻辑。
await()监听前一个节点释放锁
await()也很简单,就是监听前一个ZNode节点(prior_path成员)的删除事件,代码如下:
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);
/*
//订阅比自己次小顺序节点的删除事件
TreeCache treeCache = new TreeCache(client, prior_path);
TreeCacheListener l = new TreeCacheListener() {
@Override
public void childEvent(CuratorFramework client, TreeCacheEvent event) throws Exception {
ChildData data = event.getData();
if (data != null) {
switch (event.getType()) {
case NODE_REMOVED:
log.debug("[TreeCache]节点删除, path={}, data={}",
data.getPath(), data.getData());
latch.countDown();
break;
default:
break;
}
}
}
};
treeCache.getListenable().addListener(l);
treeCache.start();
*/
latch.await(WAIT_TIME, TimeUnit.SECONDS);
}
- 首先添加一个 Watcher 监听,而监听的节点,正是前面所保存在 prior_path 成员的前一个节点的路径。这里,仅仅去监听自己前一个节点的变动,而不是其他节点的变动,提升效率。完成监听之后,调用 latch.await(),线程进入等待状态,一直到线程被监听回调代码中的latch.countDown() 所唤醒,或者等待超时。
- 上面的代码中,监听前一个节点的删除,可以使用两种监听方式:
- Watcher 订阅;
-
TreeCache 订阅。
- 两种方式的效果,都差不多。但是这里的删除事件,只需要监听一次即可,不需要反复监听,所以使用的是 Watcher 一次性订阅。而 TreeCache 订阅的代码在源码工程中已经被注释,仅仅供大家参考。
- 一旦前一个节点 prior_path 节点被删除,那么就将线程从等待状态唤醒,重新一轮的锁的争夺,直到获取锁,并且完成业务处理。
- 至此,分布式 Lock 加锁的算法,还差一点就介绍完成。这一点,就是实现锁的可重入。
可重入的实现代码
什么是可重入呢?只需要保障同一个线程进入加锁的代码,可以重复加锁成功即可。
修改前面的
lock
方法,在前面加上可重入的判断逻辑。代码如下:
@Override
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 ,计算重复加锁的次数。如果是同一个线程加锁,只需要增加次数,直接返回,表示加锁成功。
至此,lock
()方法已经介绍完成,接下来,就是去释放锁。
释放锁的实现
Lock接口中的
unLock
()方法,表示释放锁,释放锁主要有两个工作:
(1)减少重入锁的计数,如果最终的值不是0
,直接返回,表示成功的释放了一次;
(2)如果计数器为0
,移除
Watchers
监听器,并且删除创建的
Znode
临时节点。
unLock()方法的代码如下:
@Override
public boolean unlock() {
//只有加锁的线程,能够解锁
if (!thread.equals(Thread.currentThread())) {
return false;
}
//减少可重入的计数
int newLockCount = lockCount.decrementAndGet();
//计数不能小于0
if (newLockCount < 0) {
throw new IllegalMonitorStateException("Lock count has gone negative
for lock: " + locked_path);
}
//如果计数不为0,直接返回
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
。
实战:分布式锁的使用
写一个用例,测试一下ZLock的使用,代码如下:
@Test
public void testLock() throws InterruptedException {
for (int i = 0; i < 10; i++) {
FutureTaskScheduler.add(() -> {
//创建锁
ZkLock lock = new ZkLock();
lock.lock();
//每条线程,执行10次累加
for (int j = 0; j < 10; j++) {
//公共的资源变量累加
count++;
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("count = " + count);
//释放锁
lock.unlock();
});
}
Thread.sleep(Integer.MAX_VALUE);
}
- 以上代码是10个并发任务,每个任务累加10次,执行以上用例,会发现结果会是预期的和100,如果不使用锁,结果可能就不是100,因为上面的 count 是一个普通的变量,不是线程安全的。
-
原理上一个 Zlock 实例代表一把锁,并需要占用一个 Znode 永久节点,如果需要很多分布式锁,则也需要很多的不同的Znode 节点。以上代码,如果要扩展为多个分布式锁的版本,还需要进行简单改造,这种改造留给各位自己去练习和实现吧。
curator的InterProcessMutex 可重入锁
分布式锁 Zlock
自主实现主要的价值:学习一下分布式锁的原理和基础开发,仅此而已。实际的开发中,如果需要使用到分布式锁,并建议去自己造轮子,建议直接使用 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分布式锁(如InterProcessMutex),能有效的解决分布式问题,不可重入问题,使用起来也较为简单。
- 缺点:ZooKeeper实现的分布式锁,性能并不太高。为啥呢?
- 因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。大家知道,ZK中创建和删除节点只能通过Leader服务器来执行,然后Leader服务器还需要将数据同不到所有的 Follower 机器上,这样频繁的网络通信,性能的短板是非常突出的。
- 总之,在高性能,高并发的场景下,不建议使用ZooKeeper的分布式锁。而由于ZooKeeper的高可用特性,所以在并发量不是太高的场景,推荐使用ZooKeeper的分布式锁。
- 在目前分布式锁实现方案中,比较成熟、主流的方案有两种:
- 基于 Redis 的分布式锁
- 基于 ZooKeeper 的分布式锁
- 两种锁,分别适用的场景为:
- 基于 ZooKeeper 的分布式锁,适用于高可靠(高可用)而并发量不是太大的场景;
- 基于 Redis 的分布式锁,适用于并发量很大、性能要求很高的、而可靠性问题可以通过其他方案去弥补的场景。
- 总之,这里没有谁好谁坏的问题,而是谁更合适的问题。
最后对本章的内容做个总结:在分布式系统中,ZooKeeper
是一个重要的协调工具
。
本章介绍了分布式命名服务、分布式锁的原理以及基于 ZooKeeper 的参考实现。本章的那些实战案例,建议大家自己去动手掌握,无论是应用实际开始、还是大公司面试,都是非常有用的。
另外,主流的分布式协调中间件,也不仅仅只有Zookeeper,还有非常著名的
Etcd
中间件。但是从学习的层面来说,二者之间的功能设计、核心原理都是差不多的,掌握了 Zookeeper
,
Etcd
的上手使用也是很容易的。