从Curator实现分布式锁的源码再到羊群效应

开发 前端 分布式
Curator是一款由Java编写的,操作Zookeeper的客户端工具,在其内部封装了分布式锁、选举等高级功能。

一、前言

Curator是一款由Java编写的,操作Zookeeper的客户端工具,在其内部封装了分布式锁、选举等高级功能。

今天主要是分析其实现分布式锁的主要原理,有关分布式锁的一些介绍或其他实现,有兴趣的同学可以翻阅以下文章:

我用了上万字,走了一遍Redis实现分布式锁的坎坷之路,从单机到主从再到多实例,原来会发生这么多的问题_阳阳的博客-CSDN博客

Redisson可重入与锁续期源码分析_阳阳的博客-CSDN博客

在使用Curator获取分布式锁时,Curator会在指定的path下创建一个有序的临时节点,如果该节点是最小的,则代表获取锁成功。

接下来,在准备工作中,我们可以观察是否会创建出一个临时节点出来。

二、准备工作

首先我们需要搭建一个zookeeper集群,当然你使用单机也行。

在这篇文章面试官:能给我画个Zookeeper选举的图吗?,介绍了一种使用docker-compose方式快速搭建zk集群的方式。

在pom中引入依赖:

  1. <dependency> 
  2.          <groupId>org.apache.curator</groupId> 
  3.          <artifactId>curator-recipes</artifactId> 
  4.          <version>2.12.0</version> 
  5.      </dependency> 

 Curator客户端的配置项:

  1. /** 
  2.  * @author qcy 
  3.  * @create 2022/01/01 22:59:34 
  4.  */ 
  5. @Configuration 
  6. public class CuratorFrameworkConfig { 
  7.  
  8.     //zk各节点地址 
  9.     private static final String CONNECT_STRING = "localhost:2181,localhost:2182,localhost:2183"
  10.     //连接超时时间(单位:毫秒) 
  11.     private static final int CONNECTION_TIME_OUT_MS = 10 * 1000; 
  12.     //会话超时时间(单位:毫秒) 
  13.     private static final int SESSION_TIME_OUT_MS = 30 * 1000; 
  14.     //重试的初始等待时间(单位:毫秒) 
  15.     private static final int BASE_SLEEP_TIME_MS = 2 * 1000; 
  16.     //最大重试次数 
  17.     private static final int MAX_RETRIES = 3; 
  18.  
  19.     @Bean 
  20.     public CuratorFramework getCuratorFramework() { 
  21.         CuratorFramework curatorFramework = CuratorFrameworkFactory.builder() 
  22.                 .connectString(CONNECT_STRING) 
  23.                 .connectionTimeoutMs(CONNECTION_TIME_OUT_MS) 
  24.                 .sessionTimeoutMs(SESSION_TIME_OUT_MS) 
  25.                 .retryPolicy(new ExponentialBackoffRetry(BASE_SLEEP_TIME_MS, MAX_RETRIES)) 
  26.                 .build(); 
  27.         curatorFramework.start(); 
  28.         return curatorFramework; 
  29.     } 
  30.      

 SESSION_TIME_OUT_MS参数则会保证,在某个客户端获取到锁之后突然宕机,zk能在该时间内删除当前客户端创建的临时有序节点。

测试代码如下:

  1. //临时节点路径,qcy是博主名字缩写哈 
  2.    private static final String LOCK_PATH = "/lockqcy"
  3.  
  4.    @Resource 
  5.    CuratorFramework curatorFramework; 
  6.  
  7.    public void testCurator() throws Exception { 
  8.        InterProcessMutex interProcessMutex = new InterProcessMutex(curatorFramework, LOCK_PATH); 
  9.        interProcessMutex.acquire(); 
  10.  
  11.        try { 
  12.            //模拟业务耗时 
  13.            Thread.sleep(30 * 1000); 
  14.        } catch (Exception e) { 
  15.            e.printStackTrace(); 
  16.        } finally { 
  17.            interProcessMutex.release(); 
  18.        } 
  19.    } 

 当使用接口调用该方法时,在Thread.sleep处打上断点,进入到zk容器中观察创建出来的节点。

使用 docker exec -it zk容器名 /bin/bash 以交互模式进入容器,接着使用 ./bin/zkCli.sh 连接到zk的server端。

然后使用 ls path 查看节点

这三个节点都是持久节点,可以使用 get path 查看节点的数据结构信息

若一个节点的ephemeralOwner值为0,即该节点的临时拥有者的会话id为0,则代表该节点为持久节点。

当走到断点Thread.sleep时,确实发现在lockqcy下创建出来一个临时节点

​到这里吗,准备工作已经做完了,接下来分析interProcessMutex.acquire与release的流程

三、源码分析

Curator支持多种类型的锁,例如

  • InterProcessMutex,可重入锁排它锁
  • InterProcessReadWriteLock,读写锁
  • InterProcessSemaphoreMutex,不可重入排它锁

今天主要是分析InterProcessMutex的加解锁过程,先看加锁过程

加锁

  1. public void acquire() throws Exception { 
  2.       if (!internalLock(-1, null)) { 
  3.           throw new IOException("Lost connection while trying to acquire lock: " + basePath); 
  4.       } 
  5.   } 

 这里是阻塞式获取锁,获取不到锁,就一直进行阻塞。所以对于internalLock方法,超时时间设置为-1,时间单位设置成null。

  1. private boolean internalLock(long time, TimeUnit unit) throws Exception { 
  2.        Thread currentThread = Thread.currentThread(); 
  3.        //通过能否在map中取到该线程的LockData信息,来判断该线程是否已经持有锁 
  4.        LockData lockData = threadData.get(currentThread); 
  5.        if (lockData != null) { 
  6.            //进行可重入,直接返回加锁成功 
  7.            lockData.lockCount.incrementAndGet(); 
  8.            return true
  9.        } 
  10.        //进行加锁 
  11.        String lockPath = internals.attemptLock(time, unit, getLockNodeBytes()); 
  12.        if (lockPath != null) { 
  13.            //加锁成功,保存到map中 
  14.            LockData newLockData = new LockData(currentThread, lockPath); 
  15.            threadData.put(currentThread, newLockData); 
  16.            return true
  17.        } 
  18.  
  19.        return false
  20.    } 

其中threadData是一个map,key线程对象,value为该线程绑定的锁数据。

LockData中保存了加锁线程owningThread,重入计数lockCount与加锁路径lockPath,例如

  1. /lockqcy/_c_c46513c3-ace0-405f-aa1e-a531ce28fb47-lock-0000000005 
  1. private final ConcurrentMap<Thread, LockData> threadData = Maps.newConcurrentMap(); 
  2.  
  3.     private static class LockData { 
  4.         final Thread owningThread; 
  5.         final String lockPath; 
  6.         final AtomicInteger lockCount = new AtomicInteger(1); 
  7.  
  8.         private LockData(Thread owningThread, String lockPath) { 
  9.             this.owningThread = owningThread; 
  10.             this.lockPath = lockPath; 
  11.         } 
  12.     } 

 进入到internals.attemptLock方法中

  1. String attemptLock(long time, TimeUnit unit, byte[] lockNodeBytes) throws Exception { 
  2.       //开始时间 
  3.       final long startMillis = System.currentTimeMillis(); 
  4.       //将超时时间统一转化为毫秒单位 
  5.       final Long millisToWait = (unit != null) ? unit.toMillis(time) : null
  6.       //节点数据,这里为null 
  7.       final byte[] localLockNodeBytes = (revocable.get() != null) ? new byte[0] : lockNodeBytes; 
  8.       //重试次数 
  9.       int retryCount = 0; 
  10.       //锁路径 
  11.       String ourPath = null
  12.       //是否获取到锁 
  13.       boolean hasTheLock = false
  14.       //是否完成 
  15.       boolean isDone = false
  16.  
  17.       while (!isDone) { 
  18.           isDone = true
  19.  
  20.           try { 
  21.               //创建一个临时有序节点,并返回节点路径 
  22.               //内部调用client.create().creatingParentContainersIfNeeded().withProtection().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(path); 
  23.               ourPath = driver.createsTheLock(client, path, localLockNodeBytes); 
  24.               //依据返回的节点路径,判断是否抢到了锁 
  25.               hasTheLock = internalLockLoop(startMillis, millisToWait, ourPath); 
  26.           } catch (KeeperException.NoNodeException e) { 
  27.               //在会话过期时,可能导致driver找不到临时有序节点,从而抛出NoNodeException 
  28.               //这里就进行重试 
  29.               if (client.getZookeeperClient().getRetryPolicy().allowRetry(retryCount++, System.currentTimeMillis() - startMillis, RetryLoop.getDefaultRetrySleeper())) { 
  30.                   isDone = false
  31.               } else { 
  32.                   throw e; 
  33.               } 
  34.           } 
  35.       } 
  36.       //获取到锁,则返回节点路径,供调用方记录到map中 
  37.       if (hasTheLock) { 
  38.           return ourPath; 
  39.       } 
  40.  
  41.       return null
  42.   } 

 接下来,将会在internalLockLoop中利用刚才创建出来的临时有序节点,判断是否获取到了锁。

  1. private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath) throws Exception { 
  2.        //是否获取到锁 
  3.        boolean haveTheLock = false
  4.        boolean doDelete = false
  5.        try { 
  6.            if (revocable.get() != null) { 
  7.                //当前不会进入这里 
  8.                client.getData().usingWatcher(revocableWatcher).forPath(ourPath); 
  9.            } 
  10.            //一直尝试获取锁 
  11.            while ((client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock) { 
  12.                //返回basePath(这里是lockqcy)下所有的临时有序节点,并且按照后缀从小到大排列 
  13.                List<String> children = getSortedChildren(); 
  14.                //取出当前线程创建出来的临时有序节点的名称,这里就是/_c_c46513c3-ace0-405f-aa1e-a531ce28fb47-lock-0000000005 
  15.                String sequenceNodeName = ourPath.substring(basePath.length() + 1); 
  16.                //判断当前节点是否处于排序后的首位,如果处于首位,则代表获取到了锁 
  17.                PredicateResults predicateResults = driver.getsTheLock(client, children, sequenceNodeName, maxLeases); 
  18.                if (predicateResults.getsTheLock()) { 
  19.                    //获取到锁之后,则终止循环 
  20.                    haveTheLock = true
  21.                } else { 
  22.                    //这里代表没有获取到锁 
  23.                    //获取比当前节点索引小的前一个节点 
  24.                    String previousSequencePath = basePath + "/" + predicateResults.getPathToWatch(); 
  25.  
  26.                    synchronized (this) { 
  27.                        try { 
  28.                            //如果前一个节点不存在,则直接抛出NoNodeException,catch中不进行处理,在下一轮中继续获取锁 
  29.                            //如果前一个节点存在,则给它设置一个监听器,监听它的释放事件 
  30.                            client.getData().usingWatcher(watcher).forPath(previousSequencePath); 
  31.                            if (millisToWait != null) { 
  32.                                millisToWait -= (System.currentTimeMillis() - startMillis); 
  33.                                startMillis = System.currentTimeMillis(); 
  34.                                //判断是否超时 
  35.                                if (millisToWait <= 0) { 
  36.                                    //获取锁超时,删除刚才创建的临时有序节点 
  37.                                    doDelete = true
  38.                                    break; 
  39.                                } 
  40.                                //没超时的话,在millisToWait内进行等待 
  41.                                wait(millisToWait); 
  42.                            } else { 
  43.                                //无限期阻塞等待,监听到前一个节点被删除时,才会触发唤醒操作 
  44.                                wait(); 
  45.                            } 
  46.                        } catch (KeeperException.NoNodeException e) { 
  47.                            //如果前一个节点不存在,则直接抛出NoNodeException,catch中不进行处理,在下一轮中继续获取锁 
  48.                        } 
  49.                    } 
  50.                } 
  51.            } 
  52.        } catch (Exception e) { 
  53.            ThreadUtils.checkInterrupted(e); 
  54.            doDelete = true
  55.            throw e; 
  56.        } finally { 
  57.            if (doDelete) { 
  58.                //删除刚才创建出来的临时有序节点 
  59.                deleteOurPath(ourPath); 
  60.            } 
  61.        } 
  62.        return haveTheLock; 
  63.    } 

 判断是否获取到锁的核心逻辑位于getsTheLock中

  1. public PredicateResults getsTheLock(CuratorFramework client, List<String> children, String sequenceNodeName, int maxLeases) throws Exception { 
  2.      //获取当前节点在所有子节点排序后的索引位置 
  3.      int ourIndex = children.indexOf(sequenceNodeName); 
  4.      //判断当前节点是否处于子节点中 
  5.      validateOurIndex(sequenceNodeName, ourIndex); 
  6.      //InterProcessMutex的构造方法,会将maxLeases初始化为1 
  7.      //ourIndex必须为0,才能使得getsTheLock为true,也就是说,当前节点必须是basePath下的最小节点,才能代表获取到了锁 
  8.      boolean getsTheLock = ourIndex < maxLeases; 
  9.      //如果获取不到锁,则返回上一个节点的名称,用作对其设置监听 
  10.      String pathToWatch = getsTheLock ? null : children.get(ourIndex - maxLeases); 
  11.  
  12.      return new PredicateResults(pathToWatch, getsTheLock); 
  13.  } 
  14.  
  15.  static void validateOurIndex(String sequenceNodeName, int ourIndex) throws KeeperException { 
  16.      if (ourIndex < 0) { 
  17.          //可能会由于连接丢失导致临时节点被删除,因此这里属于保险措施 
  18.          throw new KeeperException.NoNodeException("Sequential path not found: " + sequenceNodeName); 
  19.      } 
  20.  } 

 那什么时候,在internalLockLoop处于wait的线程能被唤醒呢?

在internalLockLoop方法中,已经使用

  1. client.getData().usingWatcher(watcher).forPath(previousSequencePath); 

给前一个节点设置了监听器,当该节点被删除时,将会触发watcher中的回调

  1. private final Watcher watcher = new Watcher() { 
  2.         //回调方法 
  3.         @Override 
  4.         public void process(WatchedEvent event) { 
  5.             notifyFromWatcher(); 
  6.         } 
  7.     }; 
  8.  
  9.     private synchronized void notifyFromWatcher() { 
  10.         //唤醒所以在LockInternals实例上等待的线程 
  11.         notifyAll(); 
  12.     } 

 到这里,基本上已经分析完加锁的过程了,在这里总结下:

首先创建一个临时有序节点

如果该节点是basePath下最小节点,则代表获取到了锁,存入map中,下次直接进行重入。

如果该节点不是最小节点,则对前一个节点设置监听,接着进行wait等待。当前一个节点被删除时,将会通知notify该线程。

解锁

解锁的逻辑,就比较简单了,直接进入release方法中

  1. public void release() throws Exception { 
  2.       Thread currentThread = Thread.currentThread(); 
  3.       LockData lockData = threadData.get(currentThread); 
  4.       if (lockData == null) { 
  5.           throw new IllegalMonitorStateException("You do not own the lock: " + basePath); 
  6.       } 
  7.  
  8.       int newLockCount = lockData.lockCount.decrementAndGet(); 
  9.       //直接减少一次重入次数 
  10.       if (newLockCount > 0) { 
  11.           return
  12.       } 
  13.       if (newLockCount < 0) { 
  14.           throw new IllegalMonitorStateException("Lock count has gone negative for lock: " + basePath); 
  15.       } 
  16.  
  17.       //到这里代表重入次数为0 
  18.       try { 
  19.           //释放锁 
  20.           internals.releaseLock(lockData.lockPath); 
  21.       } finally { 
  22.           //从map中移除 
  23.           threadData.remove(currentThread); 
  24.       } 
  25.   } 
  26.  
  27.   void releaseLock(String lockPath) throws Exception { 
  28.       revocable.set(null); 
  29.       //内部使用guaranteed,会在后台不断尝试删除节点 
  30.       deleteOurPath(lockPath); 
  31.   } 

 重入次数大于0,就减少重入次数。当减为0时,调用zk去删除节点,这一点和Redisson可重入锁释放时一致。

四、羊群效应

在这里谈谈使用Zookeeper实现分布式锁场景中的羊群效应

什么是羊群效应

首先,羊群是一种很散乱的组织,漫无目的,缺少管理,一般需要牧羊犬来帮助主人控制羊群。

某个时候,当其中一只羊发现前面有更加美味的草而动起来,就会导致其余的羊一哄而上,根本不管周围的情况。

所以羊群效应,指的是一个人在进行理性的行为后,导致其余人直接盲从,产生非理性的从众行为。

而Zookeeper中的羊群效应,则是指一个znode被改变后,触发了大量本可以被避免的watch通知,造成集群资源的浪费。

获取不到锁时的等待演化

sleep一段时间

如果某个线程在获取锁失败后,完全可以sleep一段时间,再尝试获取锁。

但这样的方式,效率极低。

sleep时间短的话,会频繁地进行轮询,浪费资源。

sleep时间长的话,会出现锁被释放但仍然获取不到锁的尴尬情况。

所以,这里的优化点,在于如何变主动轮询为异步通知。

watch被锁住的节点

所有的客户端要获取锁时,只去创建一个同名的node。

当znode存在时,这些客户端对其设置监听。当znode被删除后,通知所有等待锁的客户端,接着这些客户端再次尝试获取锁。

虽然这里使用watch机制来异步通知,可是当客户端的数量特别多时,会存在性能低点。

当znode被删除后,在这一瞬间,需要给大量的客户端发送通知。在此期间,其余提交给zk的正常请求可能会被延迟或者阻塞。

这就产生了羊群效应,一个点的变化(znode被删除),造成了全面的影响(通知大量的客户端)。

所以,这里的优化点,在于如何减少对一个znode的监听数量,最好的情况是只有一个。

watch前一个有序节点

如果先指定一个basePath,想要获取锁的客户端,直接在该路径下创建临时有序节点。

当创建的节点是最小节点时,代表获取到了锁。如果不是最小的节点,则只对前一个节点设置监听器,只监听前一个节点的删除行为。

这样前一个节点被删除时,只会给下一个节点代表的客户端发送通知,不会给所有客户端发送通知,从而避免了羊群效应。

​在避免羊群效应的同时,使得当前锁成为公平锁。即按照申请锁的先后顺序获得锁,避免存在饥饿过度的线程。

五、后语

本文从源码角度讲解了使用Curator获取分布式锁的流程,接着从等待锁的演化过程角度出发,分析了Zookeeper在分布式锁场景下避免羊群效应的解决方案。

这是Zookeeper系列的第二篇,关于其watch原理分析、zab协议等文章也在安排的路上了。

 

责任编辑:姜华 来源: 今日头条
相关推荐

2021-07-16 07:57:34

ZooKeeperCurator源码

2021-07-08 09:21:17

ZooKeeper分布式锁 Curator

2021-07-10 10:02:30

ZooKeeperCurator并发

2021-07-09 06:48:31

ZooKeeperCurator源码

2021-11-26 06:43:19

Java分布式

2021-07-06 08:37:29

Redisson分布式

2019-06-19 15:40:06

分布式锁RedisJava

2018-04-03 16:24:34

分布式方式

2017-04-13 10:51:09

Consul分布式

2022-04-08 08:27:08

分布式锁系统

2017-01-16 14:13:37

分布式数据库

2020-10-19 07:30:57

Java Redis 开发

2019-02-26 09:51:52

分布式锁RedisZookeeper

2021-02-28 07:49:28

Zookeeper分布式

2021-06-30 14:56:12

Redisson分布式公平锁

2021-07-02 08:51:09

Redisson分布式锁公平锁

2022-01-06 10:58:07

Redis数据分布式锁

2023-08-21 19:10:34

Redis分布式

2021-10-25 10:21:59

ZK分布式锁ZooKeeper

2023-03-01 08:07:51

点赞
收藏

51CTO技术栈公众号