1 Zookeeper作为分布式锁的优缺点
优点
- 客户端可以持有锁任意长的时间,避免了基于Redis的锁对于有效时间(lock validity time)到底设置多长的两难问题.通过
Session
(心跳)来维持锁的持有状态的 - 基于ZooKeeper的锁支持在获取锁失败之后等待锁重新释放的事件。通过
watch
机制,让客户端对锁的使用更加灵活。 - 客户端可以指定 zk 创建一个
有序节点
,此节点将自动在客户端指定的节点名后面添加一个单调递增序号来确保多个客户端同时创建相同的节点名时能够创建成功,并且保障越早创建的节点的序号越小。利用该特性可以实现锁的互斥性和公平性,即同一时刻只有一个客户端能够成功获取到锁(序号最小的一个获取到锁),获取锁失败的节点可以按照创建顺序进行锁等待。
缺点 犹豫Watch机制 ,它会引发“herd effect”(羊群效应),降低获取锁的性能
2 基于ZooKeeper构建分布式锁
简单分布式锁
- 客户端尝试创建一个znode节点,比如/lock。那么第一个客户端就创建成功了,相当于拿到了锁;而其它的客户端会创建失败(znode已存在),获取锁失败。
- 持有锁的客户端访问共享资源完成后,将znode删掉,这样其它客户端接下来就能来获取锁了。
- znode应该被创建成ephemeral(
临时
的)的。这是znode的一个特性,它保证如果创建znode的那个客户端崩溃了,那么相应的znode会被自动删除。这保证了锁一定会被释放。
改进版分布式锁(避免羊群效应)
- 可以利用 ZooKeeper 提供的临时节点特性及顺序节点特性,创建临时有序节点。并判断创建成功的节点是否是顺序节点的最小序号节点
- 如果是则获取锁成功;否则则获取锁失败,获取锁失败的客户端可以利用 ZooKeeper 的 watcher 特性来注册比自己序号更小的节点的变更监听
- 如果有序号更小的节点释放锁(节点被主动删除或者 session 超时),注册了 watcher 监听的客户端会收到监听回调,此时再次判断当前节点是否是顺序节点的最小序号节点。
例子
basePath/
├──lock-0000000000(由client1创建,由于序号最小,所以获取到锁)
├──lock-0000000001(由client2创建,并监听lock-0000000000的变化)
└──lock-0000000002(由client3创建,并监听lock-0000000001的变化)
每个 client 在创建完临时顺序节点后,都会通过 getChildren() 方法获取到 basePath 下所有的顺序节点。如果发现当前节点是序号最小的节点,则获取到锁;否则注册一个 watcher 监听前一个顺序节点的节点变化;当前一个锁节点被释放时(节点被主动删除或者 session 超时),当前节点将收到监听回调事件,再次通过 getChildren() 方法获取所有顺序节点并判断是否满足获取锁的条件。
查看 curator这个开源项目提供的zookeeper分布式锁的实现源码。
在 Curator 中,尝试获取锁的具体实现在 LockInternals.attemptLock
方法中:
String attemptLock(long time, TimeUnit unit, byte[] lockNodeBytes) throws Exception
{
final long startMillis = System.currentTimeMillis();
final Long millisToWait = (unit != null) ? unit.toMillis(time) : null;
final byte[] localLockNodeBytes = (revocable.get() != null) ? new byte[0] : lockNodeBytes;
int retryCount = 0;
String ourPath = null;
boolean hasTheLock = false;
boolean isDone = false;
while ( !isDone )
{
isDone = true;
try
{
// 创建临时顺序节点,并返回创建节点的路径
ourPath = driver.createsTheLock(client, path, localLockNodeBytes);
// 内部循环尝试获取锁
hasTheLock = internalLockLoop(startMillis, millisToWait, ourPath);
}
catch ( KeeperException.NoNodeException e )
{
// gets thrown by StandardLockInternalsDriver when it can't find the lock node
// this can happen when the session expires, etc. So, if the retry allows, just try it all again
if ( client.getZookeeperClient().getRetryPolicy().allowRetry(retryCount++, System.currentTimeMillis() - startMillis, RetryLoop.getDefaultRetrySleeper()) )
{
isDone = false;
}
else
{
throw e;
}
}
}
if ( hasTheLock )
{
return ourPath;
}
return null;
}
在 LockInternals.internalLockLoop
方法中,通过 getChildren()
获取到所有顺序节点,判断当前创建的节点是否是最小序号节点,如果是则获取锁成功,否则获取锁失败;如果获取锁失败,则通过 watcher
监听前一个顺序节点的节点变化,如果收到 watcher 监听回调,则再次进入循环,通过 getChildren()
重新判断是否能够获取到锁:
private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath) throws Exception
{
boolean haveTheLock = false;
boolean doDelete = false;
try
{
...
while ( (client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock )
{
// 得到排序好的临时顺序节点列表
List<String> children = getSortedChildren();
String sequenceNodeName = ourPath.substring(basePath.length() + 1); // +1 to include the slash
// 判断是否能够成功获取锁,在获取锁失败的情况下,会同时返回需要watch的前一个顺序节点路径
PredicateResults predicateResults = driver.getsTheLock(client, children, sequenceNodeName, maxLeases);
if ( predicateResults.getsTheLock() )
{
haveTheLock = true;
}
else
{
// 获取锁失败,开始监听前一个顺序节点的节点变化,并等待超时或者watcher监听回调
String previousSequencePath = basePath + "/" + predicateResults.getPathToWatch();
synchronized(this)
{
try
{
// use getData() instead of exists() to avoid leaving unneeded watchers which is a type of resource leak
client.getData().usingWatcher(watcher).forPath(previousSequencePath);
if ( millisToWait != null )
{
millisToWait -= (System.currentTimeMillis() - startMillis);
startMillis = System.currentTimeMillis();
if ( millisToWait <= 0 )
{
doDelete = true; // timed out - delete our node
break;
}
// 等待超时或者收到watcher回调,如果收到回调,则会再次进入循环判断是否能够获取到锁
wait(millisToWait);
}
else
{
// 没有传递超时时间的情况下,会一直等待直到watcher回调或者触发异常
wait();
}
}
catch ( KeeperException.NoNodeException e )
{
// it has been deleted (i.e. lock released). Try to acquire again
}
}
}
}
}
catch ( Exception e )
{
ThreadUtils.checkInterrupted(e);
doDelete = true;
throw e;
}
finally
{
if ( doDelete )
{
deleteOurPath(ourPath);
}
}
return haveTheLock;
}
在 StandardLockInternalsDriver.getsTheLock
方法中判断是否能够获取到锁:
public PredicateResults getsTheLock(CuratorFramework client, List<String> children, String sequenceNodeName, int maxLeases) throws Exception
{
// 获取到当前客户端创建的节点在所有顺序节点中的index
int ourIndex = children.indexOf(sequenceNodeName);
validateOurIndex(sequenceNodeName, ourIndex);
// 可重入锁的场景下,maxLeases固定为1,所以只有当ourIndex==0时能够获取到锁(当前节点是第一个顺序节点)
boolean getsTheLock = ourIndex < maxLeases;
// 判断是否能获取到锁,如果获取不到,则取到前一个顺序节点的路径
String pathToWatch = getsTheLock ? null : children.get(ourIndex - maxLeases);
return new PredicateResults(pathToWatch, getsTheLock);
}
在 watcher
收到监听回调时,通过 LockInternals.notifyFromWatcher
方法唤醒正在 wait 的线程:
private final Watcher watcher = new Watcher()
{
@Override
public void process(WatchedEvent event)
{
notifyFromWatcher();
}
};
...
private synchronized void notifyFromWatcher()
{
notifyAll();
}
Curator 里面还有相关 重入锁 ,读写锁,信号量,重试机制 的 实现 有时间 可以再深入研究下
总结
以上,我们详细讲解了基于 Curator 实现的五种锁的实现原理。可以看到,五种锁实现都利用到了 ZooKeeper 的临时节点(解决死锁)、顺序节点(解决羊群效应)、watcher(监听通知机制) 这三大特性。并且通过 Curator 的封装,简化了业务层使用分布式锁的难度。