参考文档安装教程
什么是分布式锁
分布式锁呢,就是在分布式系统中,控制不同系统服务访问以及操作相同资源的方式,实现方式有很多种,比如zk
以及redis
或者是数据库
这里记录的是使用zookeeper 实现分布式锁的方法;
zookeeper实现分布式锁的原理
Zookeeper 是基于临时顺序节点以及 Watcher 监听器机制实现分布式锁的。
【1】ZooKeeper 的每一个节点都是一个天然的顺序发号器。
在每一个节点下面创建临时顺序节点(EPHEMERAL_SEQUENTIAL)类型,新的子节点后面会加上一个次序编号,而这个生成的次序编号是上一个生成的次序编号加一。
例如:
有一个用于发号的节点 “/test/lock”
为父节点,可以在这个父节点下面创建相同前缀的临时顺序子节点,假定相同的前缀为 “/test/lock/seq-”
。第一个创建的子节点基本上应该为 /test/lock/seq-0000000001
,
下一个节点则为 /test/lock/seq-0000000002
,
依次类推。
【2】ZooKeeper 节点的递增有序性可以确保锁的公平。
一个 ZooKeeper 分布式锁,首先需要创建一个父节点
,尽量是持久节点
(PERSISTENT 类型),然后每个要获得锁的线程都在这个节点下创建一个临时顺序节点
,该节点是按照创建的次序
依次递增
的。
为了确保公平,可以简单的规定:编号最小的那个节点表示获得了锁。所以,每个线程在尝试占用锁之前,首先判断自己是序号是不是当前最小
,如果是则获取锁。先到先得嘛
【3】ZooKeeper 的节点监听机制,可以保障占有锁的传递有序而且高效
1.每个线程抢占锁之前,先尝试创建自己的 ZNode。
2.同样,释放锁的时候需要删除创建的 Znode。
3.创建成功后,如果不是序号最小的节点,就处于等待通知的状态。
4.每一个等待通知的 Znode 节点,需要监视(watch)序号在自己前面的那个 Znode.
5.以获取其删除事件。只要上一个节点被删除了,就进行再一次判断,看看自己是不是序号最小的那个节点,如果是,自己就获得锁。就这样不断地通知后一个 ZNode 节点。
另外,ZooKeeper 的内部优越的机制,能保证由于网络异常或者其他原因,集群中占用锁的客户端失联时锁能够被有效释放。
机制呢,就是临时顺序节点
。一旦占用 Znode 锁的客户端与 ZooKeeper 集群服务器失去联系,这个临时 Znode 也将自动删除。排在它后面的那个节点,也能收到删除事件,从而获得锁。
也正是这个原因,zk 中不需要像 redis 那样考虑锁可能出现的无法释放的问题了,因为当客户端挂了
,节点也挂了
,锁也释放了
。
【4】ZooKeeper 的节点监听机制,能避免羊群效应。
ZooKeeper 这种首尾相接、后面监听前面的方式,可以避免羊群效应
。
所谓羊群效应就是一个节点挂掉,所有节点都去监听,然后做出反应,这样会给服务器带来巨大压力。
有了临时顺序节点以及节点监听机制,当一个节点挂掉,只有它后面的那一个节点才做出反应。
具体流程
- 一把分布式锁通常使用一个
Znode
节点表示;如果锁对应的 Znode 节点不存在,首先创建 Znode 节点。这里假设为/test/lock
,代表了一把需要创建的分布式锁。 - 抢占锁的所有客户端,使用
这把锁
的Znode 节点
的子节点列表
来表示;
如果某个客户端需要占用锁,则在/test/lock
下创建一个临时顺序的子节点。
比如,如果子节点的前缀为/test/lock/saveUser-
则第一次抢锁对应的子节点为/test/lock/saveUser-000000001
,第二次抢锁对应的子节点为/test/lock/saveUser-000000002
,以此类推。 - 当客户端创建子节点后,需要进行判断:
自己创建的子节点,是否为当前子节点列表中序号最小
的子节点。
如果是,则加锁成功;如果不是,则监听前一个 Znode 子节
点变更消息,等待前一个节点释放锁。 - 一旦队列中的后面的节点,获得前一个子节点变更通知,则开始进行判断,判断自己是否为当前子节点列表中序号最小的子节点,如果是,则认为加锁成功;如果不是,则持续监听,一直到获得锁。(刚才已经加过锁了,但是还不是执行的时候,现在前一个客户端释放锁了,那就轮到洒家上场了!)
- 获取锁后,开始处理业务流程。完成业务流程后,
删除自己的对应的子节点
,完成释放锁的工作,以方面后继节点能捕获到节点变更通知,获得分布式锁
代码的实现
Curator 是Netflix公司开源的一套 ZooKeeper Java客户端框架,相比于 Zookeeper 自带的客户端 zookeeper 来说,Curator 的封装更加完善,各种 API 都可以比较方便地使用。
这里使用 Curator
作为 Zookeeper
的客户端实现。需要先导入依赖:
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-framework</artifactId>
<version>5.2.1</version>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>5.2.1</version>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-client</artifactId>
<version>5.2.1</version>
</dependency>
Lock接口
public interface Lock {
boolean lock() throws Exception;
boolean unlock() throws Exception;
}
Lock实现
相较于参考资料的demo增加了获取锁失败以后删除刚才在上面添加的节点;
失败以后不删除的情况下:
优点:这个锁可以被这个线程二次调用
缺点:可能会造成无效节点的堆积,当这个线程再次获取锁的时候如果从新获取了节点,就不会去使用原来的锁;
需要搭配重试机制使用,如果不重试,用户第二次请求如果不是这个线程执行,将会使用新的节点
删除
有点:不会造成锁的堆积,既然都等待超时了,直接响应超时即可
缺点:不可以重入
public class ZkLock implements Lock{
private String zkPath; //分布式锁节点,如"/test/lock"
private String lockPrefix; //子节点前缀,如"/test/lock/seq-"
private long waitTime; //超时等待
CuratorFramework zkClient; //ZK客户端
private Thread thread; //当前线程
private String lockPath; //当前加锁节点
private String waitPath; //前一个等待节点
final AtomicInteger lockCount = new AtomicInteger(0); //重入计数器
// 设置锁构造器
public ZkLock(String zkPath) throws Exception {
this.zkPath = zkPath;
this.lockPrefix = zkPath + "/seq-";
this.waitTime = 0L;
this.zkClient = ZkClientFactory.getClient();
try {
// 检查锁的主节点,如果不存在这个节点就加锁,否则跳过
if (zkClient.checkExists().forPath(zkPath) == null) {
zkClient.create().creatingParentsIfNeeded().forPath(zkPath);
}
} catch (Exception e) {
e.printStackTrace();
}
}
// 设置锁构造器,含超时时间
public ZkLock(String zkPath, long waitTime) {
this.zkPath = zkPath;
this.lockPrefix = zkPath + "/seq-";
this.waitTime = waitTime;
this.zkClient = ZkClientFactory.getClient();
try {
if (zkClient.checkExists().forPath(zkPath) == null) {
zkClient.create().creatingParentsIfNeeded().forPath(zkPath);
}
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public boolean lock() throws Exception {
//可重入
synchronized (this) {
// 如果是第一次进入,设置thread = 当前线程,并且进入次数+1
if (lockCount.get() == 0) {
thread = Thread.currentThread();
lockCount.incrementAndGet();
} else {
// 如果不是第一次进入,看看锁是不是当前线程的,如果锁不是当前线程的返回错误
if (!thread.equals(Thread.currentThread())) {
return false;
}
// 不是第一次进入,锁是当前用户的,则重入次数+1,返回获取锁成功
lockCount.incrementAndGet();
return true;
}
}
// 是第一次进入,上面设置完参数以后,尝试获取锁
return tryLock();
}
@Override
public boolean unlock() throws Exception {
// 释放锁,如果当前记录的锁不是当前线程的,返回false释放失败
if (!thread.equals(Thread.currentThread())) {
return false;
}
//
int newLockCount = lockCount.decrementAndGet();
if (newLockCount < 0) {
throw new Exception("解锁异常");
} else if (newLockCount > 0) {
return true;
} else {
try {
if (zkClient.checkExists().forPath(lockPath) != null) {
zkClient.delete().forPath(lockPath);
}
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
}
/**
* 尝试获取锁
*/
private boolean tryLock() throws Exception {
List<String> nodeInt = zkClient.getChildren().forPath(zkPath);
// 使用传入模式的方式创建,这里使用EPHEMERAL_SEQUENTIAL可以在客户端断开的时候释放锁
lockPath = zkClient.create().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(lockPrefix);
// 获取子节点,传入主锁查询所有子节点列表
List<String> childList = zkClient.getChildren().forPath(zkPath);
// 如果列表里只有一个刚才加入的节点,直接获取锁成功
if (childList.size() == 1) {
return true;
} else {
// 将节点排序
Collections.sort(childList);
// 取出获取到的锁路径,截取节点名称
String curNode = lockPath.substring(zkPath.length() + 1);
// 将子节点列表排序,看看第一个(min)是不是自己,(查看索引位置-1为当前锁不在子节点中,0为第一)
int index = childList.indexOf(curNode);// 返回匹配到的节点
if (index < 0) {
throw new Exception("加锁异常");
} else if (index == 0) {
//第一个节点,加锁成功
return true;
} else {
// 不是第一个就监听前一个节点
waitPath = zkPath + "/" + childList.get(index - 1); // 父节点+当前节点前一个节点的名称获得完整节点名称进行监听
final CountDownLatch waitLatch = new CountDownLatch(1);
// 定义监听流程
Watcher w = new Watcher() {
@Override
public void process(WatchedEvent watchedEvent) {
// 监听节点的所有事件,如果事件是删除并且是本节点监听的事件,则结束监听继续下一步
if (watchedEvent.getType() == Event.EventType.NodeDeleted &&
watchedEvent.getPath().equals(waitPath)) {
System.out.println("监听到节点删除事件:" + watchedEvent);
waitLatch.countDown(); // 结束监听
}
}
};
// 执行监听,客户端.使用监视器.指定监听节点
zkClient.getData().usingWatcher(w).forPath(waitPath);
// 监听到了节点删除事件,如果等待时间等于0,
if (waitTime == 0L) {
waitLatch.await();
return true;
} else {
boolean await = waitLatch.await(waitTime, TimeUnit.SECONDS);
if (!await){
// 如果等待超时,删除刚才添加的节点返回失败
System.out.println("等待超时,开始释放刚才添加的节点"+Thread.currentThread().getName());
unlock();
}
return await;
}
}
}
}
}