ZooKeeper - 分布式锁
与Master选举区别:
分布式锁:分布式集群服务并行,防止并发问题,同步控制 例如时间戳。(选择序号最小的)
Master选举:分布式集群种选择一个提供服务,例如 双击热备方式。(抢主)
在分布式环境中,为了保证数据的一致性,经常在程序的某个运行点(例如,减库存操作或流水号生成等)需要进行同步控。以一个“流水号生成”的场景为例,普通的后台应用通常都是使用时间戳方式来生成流水号,但是在用户量非常大的情况下,可能会出现并发问题:
public static void main(String[] args) throws Exception { final CountDownLatch down = new CountDownLatch(1); for(int i = 0; i < 10; i++){ new Thread(new Runnable() { public void run() { try { down.await(); } catch ( Exception e ) { } SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss|SSS"); String orderNo = sdf.format(new Date()); System.err.println("生成的订单号是 : "+orderNo); } }).start(); } down.countDown(); }
上述是一个简单的时间戳生成的并发问题,订单号不少是重复的,这显然没有满足我们的业务需求。究其原因,就是因为在没有进行同步的情况下出现了并发问题,下例使用Curator实现分布式锁功能:
//使用Curator实现分布式锁功能 public class Recipes_Lock { static String lock_path = "/curator_recipes_lock_path"; static CuratorFramework client = CuratorFrameworkFactory.builder() .connectString("127.0.0.1:2181") .retryPolicy(new ExponentialBackoffRetry(1000, 3)).build(); public static void main(String[] args) throws Exception { client.start(); final InterProcessMutex lock = new InterProcessMutex(client,lock_path); final CountDownLatch down = new CountDownLatch(1); for(int i = 0; i < 30; i++){ new Thread(new Runnable() { public void run() { try { down.await(); lock.acquire(); } catch ( Exception e ) {} SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss|SSS"); String orderNo = sdf.format(new Date()); System.out.println("生成的订单号是 : "+orderNo); try { lock.release(); } catch ( Exception e ) {} } }).start(); } down.countDown(); } }
下面描述使用zookeeper实现分布式锁的算法流程,假设锁空间的根节点为/lock:
客户端连接zookeeper,并在/lock下创建临时的且有序的子节点,第一个客户端对应的子节点为/lock/lock-0000000000,第二个为/lock/lock-0000000001,以此类推。 客户端获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁,否则监听/lock的子节点变更消息,获得子节点变更通知后重复此步骤直至获得锁; 执行业务代码; 完成业务流程后,删除对应的子节点释放锁。
步骤1中创建的临时节点能够保证在故障的情况下锁也能被释放,考虑这么个场景:假如客户端a当前创建的子节点为序号最小的节点,获得锁之后客户端所在机器宕机了,客户端没有主动删除子节点;如果创建的是永久的节点,那么这个锁永远不会释放,导致死锁;由于创建的是临时节点,客户端宕机后,过了一定时间zookeeper没有收到客户端的心跳包判断会话失效,将临时节点删除从而释放锁。
另外细心的朋友可能会想到,在步骤2中获取子节点列表与设置监听这两步操作的原子性问题,考虑这么个场景:客户端a对应子节点为/lock/lock-0000000000,客户端b对应子节点为/lock/lock-0000000001,客户端b获取子节点列表时发现自己不是序号最小的,但是在设置监听器前客户端a完成业务流程删除了子节点/lock/lock-0000000000,客户端b设置的监听器岂不是丢失了这个事件从而导致永远等待了?这个问题不存在的。因为zookeeper提供的API中设置监听器的操作与读操作是原子执行的,也就是说在读子节点列表时同时设置监听器,保证不会丢失事件。
最后,对于这个算法有个极大的优化点:假如当前有1000个节点在等待锁,如果获得锁的客户端释放锁时,这1000个客户端都会被唤醒,这种情况称为“羊群效应”;在这种羊群效应中,zookeeper需要通知1000个客户端,这会阻塞其他的操作,最好的情况应该只唤醒新的最小节点对应的客户端。应该怎么做呢?在设置事件监听时,每个客户端应该对刚好在它之前的子节点设置事件监听,例如子节点列表为/lock/lock-0000000000、/lock/lock-0000000001、/lock/lock-0000000002,序号为1的客户端监听序号为0的子节点删除消息,序号为2的监听序号为1的子节点删除消息。
所以调整后的分布式锁算法流程如下:
-
客户端连接zookeeper,并在/lock下创建临时的且有序的子节点,第一个客户端对应的子节点为/lock/lock-0000000000,第二个为/lock/lock-0000000001,以此类推;
-
客户端获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁,否则监听刚好在自己之前一位的子节点删除消息,获得子节点变更通知后重复此步骤直至获得锁;
-
执行业务代码;
-
完成业务流程后,删除对应的子节点释放锁。
Curator的源码分析
下面来分析下获取锁的源码实现。acquire的方法如下:
/** * Acquire the mutex - blocking until it's available. Note: the same thread * can call acquire re-entrantly. Each call to acquire must be balanced by a call * to {@link #release()} * * @throws Exception ZK errors, connection interruptions */ @Override public void acquire() throws Exception { if ( !internalLock(-1, null) ) { throw new IOException("Lost connection while trying to acquire lock: " + basePath); } }
这里有个地方需要注意,当与zookeeper通信存在异常时,acquire会直接抛出异常,需要使用者自身做重试策略。代码中调用了internalLock(-1, null),参数表明在锁被占用时永久阻塞等待。internalLock的代码如下:
private boolean internalLock(long time, TimeUnit unit) throws Exception { /* Note on concurrency: a given lockData instance can be only acted on by a single thread so locking isn't necessary */ Thread currentThread = Thread.currentThread(); LockData lockData = threadData.get(currentThread); if ( lockData != null ) { // re-entering lockData.lockCount.incrementAndGet(); return true; } //这里才真正去zookeeper中获取锁 String lockPath = internals.attemptLock(time, unit, getLockNodeBytes()); if ( lockPath != null ) {
//获得锁之后,记录当前的线程获得锁的信息,在重入时只需在LockData中增加次数统计即可 LockData newLockData = new LockData(currentThread, lockPath); threadData.put(currentThread, newLockData); return true; } //在阻塞返回时仍然获取不到锁,这里上下文的处理隐含的意思为zookeeper通信异常 return false; }
下面来看下zookeeper获取锁的具体实现:
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; }
上面代码中主要有两步操作:
-
driver.createsTheLock:创建临时且有序的子节点,里面实现比较简单不做展开,主要关注几种节点的模式:1)PERSISTENT(永久);2)PERSISTENT_SEQUENTIAL(永久且有序);3)EPHEMERAL(临时);4)EPHEMERAL_SEQUENTIAL(临时且有序)。
-
internalLockLoop:阻塞等待直到获得锁。
看下internalLockLoop是怎么判断锁以及阻塞等待的,这里删除了一些无关代码,只保留主流程:
//自旋直至获得锁 while ((client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock) { //获取所有的子节点列表,并且按序号从小到大排序 List<String> 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()接口而不是checkExists()是因为,如果前一个子节点已经被删除了那么会抛出异常而且不会设置事件监听器,而checkExists虽然也可以获取到节点是否存在的信息但是同时设置了监听器,这个监听器其实永远不会触发,对于zookeeper来说属于资源泄露 client.getData().usingWatcher(watcher).forPath(previousSequencePath); //如果设置了阻塞等待的时间 if (millisToWait != null) { millisToWait -= (System.currentTimeMillis() - startMillis); startMillis = System.currentTimeMillis(); if (millisToWait <= 0) { doDelete = true; // 等待时间到达,删除对应的子节点 break; } //等待相应的时间 wait(millisToWait); } else { //永远等待 wait(); } } catch (KeeperException.NoNodeException e) { //上面使用getData来设置监听器时,如果前一个子节点已经被删除那么会抛出NoNodeException,只需要自旋一次即可,无需额外处理 } } } }
具体逻辑见注释,不再赘述。代码中设置的事件监听器,在事件发生回调时只是简单的notifyAll唤醒当前线程以重新自旋判断,比较简单不再展开。
参考:https://blog.csdn.net/qiangcuo6087/article/details/79067136
posted on 2018-08-23 17:30 TrustNature 阅读(6) 评论(0) 编辑 收藏 举报