一张图掌握zookeeper之NIOServerCnxnFactory

648 阅读5分钟

【原创作品:转载请注明出处】

zookeeper(zk)集群需要支撑大量客户端的接入,很多场景都是大量客户端同时接入,比如客户端集群重启、集群扩容等场景。zk管理客户端连接有两种方式可以选择:NIOServerCnxnFactory、NettyServerCnxnFactory

 static public ServerCnxnFactory createFactory() throws IOException {
        //获取配置
        String serverCnxnFactoryName =
                System.getProperty(ZOOKEEPER_SERVER_CNXN_FACTORY);
        //默认原生NIO
        if (serverCnxnFactoryName == null) {
            serverCnxnFactoryName = NIOServerCnxnFactory.class.getName();
        }
        try {
            //加载指定的类工厂
            ServerCnxnFactory serverCnxnFactory = (ServerCnxnFactory) Class.forName(serverCnxnFactoryName)
                    .getDeclaredConstructor().newInstance();
            LOG.info("Using {} as server connection factory", serverCnxnFactoryName);
            return serverCnxnFactory;
        } catch (Exception e) {
           //ignore
        }
    }

上面是创建ServerCnxnFactory的源码,默认会使用NIOServerCnxnFactory,想使用NettyServerCnxnFactory可以通过配置zookeeper.serverCnxnFactory实现。

NIOServerCnxnFactory是通过java原生的NIO实现的,下图是NIOServerCnxnFactory整体的工作流程

第一眼看到图可能会稍微有点复杂,稍加梳理就会发现非常简单和优雅。管理链接无非就以下几件事:

  • 创建ServerSocket(open)
  • 监听新连接的接入(accepet)
  • 从连接中读取/向连接中写入数据(selector)
  • 销毁连接(clean)

这个过程像极了很多客人来你家做客的的场景,首先你需要向客人敞开大门(open);客人来了你起码要迎接一下吧(accept);客人也不好意思空手来吧,你也不能不让客人吃点东西就走吧(selector);吃吃喝喝差不多,就该送客了,你就要清理战场了(clean)。

上图就是这上面的前三个流程,我们结合源码来看下具体的实现。

一、创建ServerSocket(open)

public void configure(InetSocketAddress addr, int maxcc, boolean secure) throws IOException {
       	....
        this.ss = ServerSocketChannel.open();
        ss.socket().setReuseAddress(true);
        LOG.info("binding to port " + addr);
        ss.socket().bind(addr);
        ss.configureBlocking(false);
        //创建appeptThread
        acceptThread = new AcceptThread(ss, addr, selectorThreads);
}

二、 监听socket接入(accepet)

zk使用单线程(AcceptThread)来处理新连接的接入,AcceptThread本身其实就是一个Selector,ServerSocketChannel的OP_ACCEPT事件注册在了AcceptThread上。看下AcceptThread的run方法,在循环select(就是一直站在门口等客人)

public void run() {
            try {
                while (!stopped && !acceptSocket.socket().isClosed()) {
                    try {
                        select();
                    } catch (RuntimeException e) {
                        LOG.warn("Ignoring unexpected runtime exception", e);
                    } catch (Exception e) {
                        LOG.warn("Ignoring unexpected exception", e);
                    }
                }
            } finally {
              ...
            }
        }

**select()方法, 关键的动作还在doAccept()**方法里

selector.select();
Iterator<SelectionKey> selectedKeys = selector.selectedKeys().iterator();
while (!stopped && selectedKeys.hasNext()) {
    SelectionKey key = selectedKeys.next();
    selectedKeys.remove();
    if (!key.isValid()) {
        continue;
    }
    if (key.isAcceptable()) {
        if (!doAccept()) {
           
            pauseAccept(10);
        }
    }
}

**doAccept()**方法,把代码贴出来,完全不需要额外的解释。接下来就看selectorThread的了,看图就知道核心的处理就在selectorThread中。

SocketChannel sc = null;
try {
    sc = acceptSocket.accept();
    InetAddress ia = sc.socket().getInetAddress();
  	//省略了一些限制连接数量的代码
    // 轮询选择一个selectorThread
    if (!selectorIterator.hasNext()) {
        selectorIterator = selectorThreads.iterator();
    }
    SelectorThread selectorThread = selectorIterator.next();
    //把socket分发给selectorThread
    if (!selectorThread.addAcceptedConnection(sc)) {
        throw new IOException();
    }
   
} catch (IOException e) {
   ...
}

三、 socket读写事件处理(selector)

其实重点也就是这个Selector模块,也就是数据交换部分,NIO通过Selector单个线程来管理多个socket(nio部分不多做解释)。我们重点关注zk的设计细节

  1. SelectorThread是多实例,zk认为监听数据的读写可能会成为性能的瓶颈,所以SelectorThread是多个实例。需要强调的是,SelectorThread只是负责socket的读写事件,具体的io处理会交给IOHandle线程池。

  2. Selector结构,两个socket队列,新接入的sockket队列是acceptedQueue ,处理IO之后的队列是updateQueue;一个大的循环事件、大循环中包含了三个子循环。我们对照源码看一下。

     /**
      * 新接入的socket队列,selector实例的属性,每个selector都有自己的属性
      */
     private final Queue<SocketChannel> acceptedQueue;
    
     private final Queue<SelectionKey> updateQueue;
     
     
     //run中的方法
     while (!stopped) {
         try {
             //处理注册的事件
             select();
             //处理新连接
             processAcceptedConnections();
             //处理兴趣集更新
             processInterestOpsUpdateRequests();
         } catch (RuntimeException e) {
             LOG.warn("Ignoring unexpected runtime exception", e);
         } catch (Exception e) {
             LOG.warn("Ignoring unexpected exception", e);
         }
     }
     //省略部分代码
    
  3. acceptedQueue队列的消费者。我们没有必要一行一行的看代码,不妨从数据的流转顺序来看 ,socket生产到了新接入的队列中,我们看看哪里消费了这个队列。从函数名很容易看出是processAcceptedConnections(),做的事情就是拉去队列中的socket、将socket的读事件注册到当前selector、分装socket。

这里有个细节,使用的是队列的非阻塞方法poll() 我们待会从整体看这里为什么可以这样设计

  private void processAcceptedConnections() {
      SocketChannel accepted;
      while (!stopped && (accepted = acceptedQueue.poll()) != null) {
          SelectionKey key = null;
          try {
              //注册读事件
              key = accepted.register(selector, SelectionKey.OP_READ);
              //分装socket
              NIOServerCnxn cnxn = createConnection(accepted, key, this);
              //绑定到key上
              key.attach(cnxn);
              //添加到连接map
              addCnxn(cnxn);
          } catch (IOException e) {
              // register, createConnection
              cleanupSelectionKey(key);
              fastCloseSock(accepted);
          }
      }
  }
  1. updateQueue的生产者。既然消费了新链接并且注册了读事件,那就一定要调用select()吧,正是循环事件1 select()方法,其实这里很简单,就是获取有读写事件的链接,然后交给handleIO()。但是我们注意下图中循环事件1,貌似还有生产socketChannel的操作,其实这部分是在handleIO 里,为啥还要把处理后的socket再次抛给一个新的队列?不着急,我们再看看谁在消费socketChannel队列不就完事了!
 try {
      selector.select();
      Set<SelectionKey> selected = selector.selectedKeys();
      ArrayList<SelectionKey> selectedList = new ArrayList<>(selected);
      //why shuffle?
      Collections.shuffle(selectedList);
      Iterator<SelectionKey> selectedKeys = selectedList.iterator();
      while (!stopped && selectedKeys.hasNext()) {
          SelectionKey key = selectedKeys.next();
          selected.remove(key);
          if (!key.isValid()) {
              cleanupSelectionKey(key);
              continue;
          }
          if (key.isReadable() || key.isWritable()) {
              handleIO(key);
          } else {
              LOG.warn("Unexpected ops in select " + key.readyOps());
          }
      }
  } catch (IOException e) {
      //ignore
  }
  1. updateQueue的消费者。我猜你一定想到了是processInterestOpsUpdateRequests()在消费。没错,就是重新订阅了socket的读写事件。等等... IOhandle把socket抛给一个新队列就是为了执行key.interestOps(cnxn.getInterestOps())这一个操作?有必要?不能在handleIO之后直接执行key.interestOps(cnxn.getInterestOps())?答案是可以的,但是zk设计者出于性能的考虑,而将这个操作异步化了。
  private void processInterestOpsUpdateRequests() {
      SelectionKey key;
      while (!stopped && (key = updateQueue.poll()) != null) {
          if (!key.isValid()) {
              cleanupSelectionKey(key);
          }
          NIOServerCnxn cnxn = (NIOServerCnxn) key.attachment();
          if (cnxn.isSelectable()) {
              key.interestOps(cnxn.getInterestOps());
          }
      }
  }
  1. 总结。 我相信现在再回头看这张图,一定清晰了很多。最后提一下Selector处理中的一些细节
  • 主循环的阻塞是依赖子循环1select()方法,而不是子循环2或者3的阻塞队列,思考一下是否可以使用阻塞队列。
  • 在轮询分发时,有个wakeUp()的调用,这里是让子循环1立刻返回。
public boolean addAcceptedConnection(SocketChannel accepted) {
   if (stopped || !acceptedQueue.offer(accepted)) {
       return false;
   }
   wakeupSelector();
   return true;
}