Zookeeper实现分布式锁

我在一个简单的例子聊分布式锁中留了一个小尾巴,就是用Zookeeper(以下简称zk)实现分布式锁,今天就扫清这个尾巴。

实现原理

关于zk的知识点可以参考这篇文章:Zookeeper的功能以及工作原理,这里不做过多的介绍。这里介绍一下zk的涉及分布式锁的相关概念。

相关概念

有序节点:顾名思义就是有顺序的节点。zk会在生成节点时根据现有的节点数量添加整数序号。比如已经存在节点/lock/node-0000000000,下一个节点就是/lock/node-0000000001。

临时节点:临时节点只在zk会话期间存在,会话结束或超时时会被zk自动删除。

事件监听:通过zk的事件监听机制可以让客户端收到节点状态变化。主要的事件类型有节点数据变化、节点的删除和创建。

实现步骤

了解完上面的三个概念,下面介绍具体实现。

算法流程如下:

1、每个客户端创建临时有序节点

2、客户端获取节点列表,判断自己是否列表中的第一个节点,如果是就获得锁,如果不是就监听自己前面的节点,等待前面节点被删除。

3、如果获取锁就进行正常的业务流程,执行完释放锁。

上述步骤2中,有人可能担心如果节点发现自己不是序列最小的节点,准备添加监听器,但是这个时候前面节点正好被删除,这时候添加监听器是永远不起作用的,其实zk的API可以保证读取和添加监听器是一个原子操作。

为什么要监听前一个节点而不是所有的节点呢?这是因为如果监听所有的子节点,那么任意一个子节点状态改变,其它所有子节点都会收到通知(羊群效应),而我们只希望它的后一个子节点收到通知。

Zookeeper 实现分布式锁的示意图如下:

image.png

上图中左边是Zookeeper集群, lock是数据节点,node_1到node_n表示一系列的顺序临时节点,右侧client_1到client_n表示要获取锁的客户端。Service是互斥访问的服务。

代码实现

下面的源码是根据Zookeeper的开源客户端Curator实现分布式锁。采用zk的原生API实现会比较复杂,所以这里就直接用Curator这个轮子,采用Curator的acquire和release两个方法就能实现分布式锁。

import org.apache.curator.RetryPolicy;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.apache.curator.retry.ExponentialBackoffRetry;
public class CuratorDistributeLock {
public static void main(String[] args) {
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
CuratorFramework client = CuratorFrameworkFactory.newClient("111.231.83.101:2181",retryPolicy);
client.start();
CuratorFramework client2 = CuratorFrameworkFactory.newClient("111.231.83.101:2181",retryPolicy);
client2.start();
//创建分布式锁, 锁空间的根节点路径为/curator/lock
InterProcessMutex mutex = new InterProcessMutex(client,"/curator/lock");
final InterProcessMutex mutex2 = new InterProcessMutex(client2,"/curator/lock");
try {
mutex.acquire();
} catch (Exception e) {
e.printStackTrace();
}
//获得了锁, 进行业务流程
System.out.println("clent Enter mutex");
Thread client2Th = new Thread(new Runnable() {
@Override
public void run() {
try {
mutex2.acquire();
System.out.println("client2 Enter mutex");
mutex2.release();
System.out.println("client2 release lock");
}catch (Exception e){
e.printStackTrace();
}
}
});
client2Th.start();
//完成业务流程, 释放锁
try {
Thread.sleep(5000);
mutex.release();
System.out.println("client release lock");
client2Th.join();
} catch (Exception e) {
e.printStackTrace();
}
//关闭客户端
client.close();
}
}

上述代码的执行结果如下:

image.png

可以看到client客户端首先拿到锁再执行业务,然后再轮到client2尝试获取锁并执行业务。

源码分析

一直追踪acquire()的加锁方法,可以追踪到加锁的核心函数为attemptLock。

String attemptLock(long time, TimeUnit unit, byte[] lockNodeBytes) throws Exception
{
.....
while ( !isDone )
{
isDone = true;
try
{
//创建临时有序节点
ourPath = driver.createsTheLock(client, path, localLockNodeBytes);
//判断自己是否最小序号的节点,如果不是添加监听前面节点被删的通知
hasTheLock = internalLockLoop(startMillis, millisToWait, ourPath);
}
}
//如果获取锁返回节点路径
if ( hasTheLock )
{
return ourPath;
}
....
}

深入internalLockLoop函数源码:

private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath) throws Exception
{
.......
while ( (client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock )
{
//获取子节点列表按照序号从小到大排序
List children = getSortedChildren();
String sequenceNodeName = ourPath.substring(basePath.length() + 1); // +1 to include the slash
//判断自己是否是当前最小序号节点
PredicateResults predicateResults = driver.getsTheLock(client, children, sequenceNodeName, maxLeases);
if ( predicateResults.getsTheLock() )
{
//成功获取锁
haveTheLock = true;
}
else
{
//拿到前一个节点
String previousSequencePath = basePath + "/" + predicateResults.getPathToWatch();
//如果没有拿到锁,调用wait,等待前一个节点删除时,通过回调notifyAll唤醒当前线程
synchronized(this)
{
try
{
//设置监听器,getData会判读前一个节点是否存在,不存在就会抛出异常从而不会设置监听器
client.getData().usingWatcher(watcher).forPath(previousSequencePath);
//如果设置了millisToWait,等一段时间,到了时间删除自己跳出循环
if ( millisToWait != null )
{
millisToWait -= (System.currentTimeMillis() - startMillis);
startMillis = System.currentTimeMillis();
if ( millisToWait <= 0 )
{
doDelete = true; // timed out - delete our node
break;
}
//等待一段时间
wait(millisToWait);
}
else
{
//一直等待下去
wait();
}
}
catch ( KeeperException.NoNodeException e )
{
//getData发现前一个子节点被删除,抛出异常
}
}
}
}
}
.....
}

总结

采用zk实现分布式锁在实际应用中不是很常见,需要一套zk集群,而且频繁监听对zk集群来说也是有压力,所以不推荐大家用。不能去面试的时候,能具体说一下使用zk实现分布式锁,我想应该也是一个加分项 🙂。

PS:距离上一篇技术文章间隔很久了,一方面是最近一直在忙着工作中的事情,另一方面还要准备找工作面试,所以已经很久没有更新技术文章。 原来我计划每个月写两篇,没想到落下这么多,忙完这上面的两件事,一定抓紧补上。