zookeeper服务端通讯模型

前言

zookeeper服务端通讯模型的实现主要有两种方式,一种是原生的java nio ,另一种则是通过netty3构建,默认情况下使用原生nio,因此,我们这里只会分析nio的实现方式,后者也比较简单,有兴趣的读者可以自行分析。

zookeeper服务端通讯模型_第1张图片
1-1

总结一下服务端具体流程:
整个zookeeper服务端的处理模型就是类似于netty的reactor线程模型。首先,接着就是筛选出所有准备就绪的事件;然后在处理就绪事件,该步骤主要是处理连接、读写事件;最后在根据上一步所构造的完整事件,处理zk对应的事件任务。

接下来就是分析如何实现:
我们先看核心抽象类NIOServerCnxnFactory的初始化方式:

   public void configure(InetSocketAddress addr, int maxcc) throws IOException {
        configureSaslLogin();
        //
        thread = new Thread(this, "NIOServerCxn.Factory:" + addr);
        thread.setDaemon(true);
        maxClientCnxns = maxcc;
        this.ss = ServerSocketChannel.open();
        ss.socket().setReuseAddress(true);
        LOG.info("binding to port " + addr);
        ss.socket().bind(addr);
        ss.configureBlocking(false);
        ss.register(selector, SelectionKey.OP_ACCEPT);
    }

我们来解读一下上面的配置方式
首先是configureSaslLogin(),这里是在我们指定使用简单安全认证的情况下才生效,以后会单独分析,这里先不深入。

new Thread(this, "NIOServerCxn.Factory:" + addr),接着是构造reactor线程,this就是实现了Runnable方法的reactor流程。

然后就是典型的ServerSocketChannel方式,先通过工厂方法open()创建实例,接着设置setReuseAddress(true),这里单独说一下配置参数SO_REUSEADDR的含义(以下内容摘自《Unix网络编程》卷一):

1、当有一个有相同本地地址和端口的socket1处于TIME_WAIT状态时,而你启动的程序的socket2要占用该地址和端口,你的程序就要用到该选项。
2、SO_REUSEADDR允许同一port上启动同一服务器的多个实例(多个进程)。但每个实例绑定的IP地址是不能相同的。在有多块网卡或用IP Alias技术的机器可以测试这种情况。
3、SO_REUSEADDR允许单个进程绑定相同的端口到多个socket上,但每个socket绑定的ip地址不同。这和2很相似,区别请看UNPv1。
4、SO_REUSEADDR允许完全相同的地址和端口的重复绑定。但这只用于UDP的多播,不用于TCP。

接着就是bind(addr),绑定监听的端口,并通过ss.configureBlocking(false)设置为非阻塞模式,这个设置很重要,否值就是同步的方式了。

最后就是register(selector, SelectionKey.OP_ACCEPT)selector注册连接事件。

接着就是通过下断代码启动reactor反应堆了:

    @Override
    public void start() {
        // ensure thread is started once and only once
        if (thread.getState() == Thread.State.NEW) {
            thread.start();
        }
    }

好,到这里,我们直接到run方法分析:

    public void run() {
        while (!ss.socket().isClosed()) {
            try {
                //轮询有效事件
                selector.select(1000);
                Set selected;
                synchronized (this) {
                    //获取准备就绪的事件
                    selected = selector.selectedKeys();
                }
                ArrayList selectedList = new ArrayList(
                        selected);
                //就绪事件乱序
                Collections.shuffle(selectedList);
                for (SelectionKey k : selectedList) {
                    if ((k.readyOps() & SelectionKey.OP_ACCEPT) != 0) {
                        SocketChannel sc = ((ServerSocketChannel) k.channel()).accept();
                        InetAddress ia = sc.socket().getInetAddress();
                        int cnxncount = getClientCnxnCount(ia);
                        if (maxClientCnxns > 0 && cnxncount >= maxClientCnxns){
                            sc.close();
                        } else {
                            sc.configureBlocking(false);
                            SelectionKey sk = sc.register(selector, SelectionKey.OP_READ);
                            NIOServerCnxn cnxn = createConnection(sc, sk);
                            sk.attach(cnxn);
                            addCnxn(cnxn);
                        }
                    } else if ((k.readyOps() & (SelectionKey.OP_READ | SelectionKey.OP_WRITE)) != 0) {
                        NIOServerCnxn c = (NIOServerCnxn) k.attachment();
                        c.doIO(k);
                    } else {
                        if (LOG.isDebugEnabled()) {
                            ...
                        }
                    }
                }
                //https://stackoverflow.com/questions/7132057/why-the-key-should-be-removed-in-selector-selectedkeys-iterator-in-java-ni
                selected.clear();
            }  catch (Exception e) {
                LOG.warn("Ignoring exception", e);
            }
        }
        closeAll();
    }

这里,是nio比较经典的使用场景,我们直接从 for (SelectionKey k : selectedList)遍历就绪事件开始吧。

首先,先判断该就绪事件是连接事件还是读写事件,如果是(k.readyOps() & SelectionKey.OP_ACCEPT) != 0即连接事件,则SocketChannel sc = ((ServerSocketChannel) k.channel()).accept() 获取与客户端通讯渠道,在通过getClientCnxnCount(ia)获取当前的客户端连接数(连接成功后,存放于ipMap中),如果超过了设置的客户端最大连接数,则直接断开连接,否则,通过 NIOServerCnxn cnxn = createConnection(sc, sk),创建一个业务连接抽象类:

    protected NIOServerCnxn createConnection(SocketChannel sock,
            SelectionKey sk) throws IOException {
        return new NIOServerCnxn(zkServer, sock, sk, this);
    }

    public NIOServerCnxn(ZooKeeperServer zk, SocketChannel sock,
            SelectionKey sk, NIOServerCnxnFactory factory) throws IOException {
        this.zkServer = zk;
        this.sock = sock;
        this.sk = sk;
        this.factory = factory;
        if (this.factory.login != null) {
            this.zooKeeperSaslServer = new ZooKeeperSaslServer(factory.login);
        }
        if (zk != null) { 
          
            outstandingLimit = zk.getGlobalOutstandingLimit();
        }
        sock.socket().setTcpNoDelay(true);
       
        sock.socket().setSoLinger(false, -1);
        InetAddress addr = ((InetSocketAddress) sock.socket()
                .getRemoteSocketAddress()).getAddress();
        authInfo.add(new Id("ip", addr.getHostAddress()));
        //监听可读事件
        sk.interestOps(SelectionKey.OP_READ);
    }

这里在简单说一下几个设置,首先是outstandingLimit,对于zk服务端来说,会将所有客户端的请求都放在一个队列里,换言之,该限制即为未处理请求个数的最大值,当服务端当前未处理的请求个数的最大值大于outstandingLimit该限制值时,就会取消对读事件的监听。

然后就是setTcpNoDelay(true),这里也在简单说一下,应用程序向内核递交的每个数据包都会立即发送出去。

接着就是setSoLinger(false, -1),当调用lose()关闭TCP连接时的行为时,会立即关闭该连接,至于发送缓冲区中如果有未发送完的数据,则丢弃。即强制关闭。

最后就是sk.interestOps(SelectionKey.OP_READ),监听读事件。

我们继续,回到外层调用的for循环中,addCnxn(cnxn),则把建立的业务连接抽象类加入ipMap缓存中,这里就不再进入分析。

接着,如果是读写事件:
((k.readyOps() & (SelectionKey.OP_READ | SelectionKey.OP_WRITE)) != 0),继续分析如何处理读写事件c.doIO(k)


    void doIO(SelectionKey k) throws InterruptedException {
        try {
            if (isSocketOpen() == false) {
                return;
            }
            if (k.isReadable()) {
                doRead(k);
            }
            if (k.isWritable()) {

                doWrite(k);
            }
        } catch (Exception e) {
         
            close();
        }
    }

这里,为了更好的排版展示与实现流程分析,我对源码稍微进行了调整,调整后,整个doIO(...)流程就比较清晰了,每次进行操作前,先通过isSocketOpen()判断当前连接是否关闭,如果连接保持,则在进行读或写操作,因此接下来,在分两个小流程来分析读写事件,过程中,又是如何解决粘包和拆包问题。

1、doRead(k):

    private void doRead(SelectionKey k) {
        int rc = sock.read(incomingBuffer);
        if (rc < 0) {
            throw new EndOfStreamException(...);
        }
        if (incomingBuffer.remaining() == 0) {
            boolean isPayload;
            if (incomingBuffer == lenBuffer) { // start of next request
                incomingBuffer.flip();
                isPayload = readLength(k);
                incomingBuffer.clear();
            } else {

                isPayload = true;  // continuation
            }
            if (isPayload) { // not the case for 4letterword
                readPayload();
            }
                
        }
    }

这里总体描述一下读流程是如何解决粘包拆包问题的,方案比较经典,就是通过头部定长的方式,先读取rc = sock.read(incomingBuffer)读取四字节的消息长度:

zookeeper服务端通讯模型_第2张图片
定长字节

结合上图,连接业务抽象类NIOServerCnxn在初始化或读完一条完整的消息后,incomingBuffer的内容就是lenBuffer,而判断incomingBuffer.remaining() == 0则说明已经读取完整消息头或消息体了,当
incomingBuffer == lenBuffer则进一步说明目前已读取完整的消息头,我们在跟进isPayload = readLength(k)

    private boolean readLength(SelectionKey k) throws IOException {
        // Read the length, now get the buffer
        int len = lenBuffer.getInt();
        if (!initialized && checkFourLetterWord(sk, len)) {
            return false;
        }
        if (len < 0 || len > BinaryInputArchive.maxBuffer) {
            throw new IOException("Len error " + len);
        }
        if (zkServer == null) {
            throw new IOException("ZooKeeperServer not running");
        }
        incomingBuffer = ByteBuffer.allocate(len);
        return true;
    }

通过由于已读取完整的消息头,我们可以通过int len = lenBuffer.getInt()获取消息头的内容,即消息体的长度。而checkFourLetterWord(sk, len)主要判断该次请求是否为客户端命令,这个以后在专门分析。最后在通过ByteBuffer.allocate(len)重新分配incomingBuffer,换言之,接下来就要读取消息体内容了。

回到readLength(SelectionKey k)的上层调用,因为incomingBuffer != lenBuffer,并且isPayload ==true,这时通过readPayload(),读取消息体内容:

    private void readPayload() throws IOException, InterruptedException {
        if (incomingBuffer.remaining() != 0) { // have we read length bytes?
            int rc = sock.read(incomingBuffer); // sock is non-blocking, so ok
            if (rc < 0) {
                throw new EndOfStreamException(...);
            }
        }

        if (incomingBuffer.remaining() == 0) { // have we read length bytes?
            packetReceived();
            incomingBuffer.flip();
            if (!initialized) {
                readConnectRequest();
            } else {
                readRequest();
            }
            lenBuffer.clear();
            incomingBuffer = lenBuffer;
        }
    }


首先,通过int rc = sock.read(incomingBuffer),将消息体内容读到incomingBuffer中,当incomingBuffer.remaining() == 0时,则说明读取完整的消息体了,而packetReceived()仅仅为监控服务器状态,这里就是每次读取完整的一个消息内容,都会由相对应的计数器加一。

然后通过标志位initialized判断当前连接是否建立起对应的session信息,该信息,该session主要用于维持心跳,而readConnectRequest()就是对应建立session的逻辑,这里是同步处理,并且同步响应客户端,这里以后在详细分析。

如果initialized为true,说明已建立session连接,接下来的请求都是相关的业务请求处理了,我们跟进readRequest()

  private void readRequest() throws IOException {
        zkServer.processPacket(this, incomingBuffer);
    }

  public void processPacket(ServerCnxn cnxn, ByteBuffer incomingBuffer) throws IOException {
        // 反序列化请求
        InputStream bais = new ByteBufferInputStream(incomingBuffer);
        BinaryInputArchive bia = BinaryInputArchive.getArchive(bais);
        RequestHeader h = new RequestHeader();
        h.deserialize(bia, "header");
        
        incomingBuffer = incomingBuffer.slice();
        if (h.getType() == OpCode.auth) {
              ...
            return;
        } else {
            if (h.getType() == OpCode.sasl) {
                Record rsp = processSasl(incomingBuffer,cnxn);
                ReplyHeader rh = new ReplyHeader(h.getXid(), 0, KeeperException.Code.OK.intValue());
                cnxn.sendResponse(rh,rsp, "response"); // not sure about 3rd arg..what is it?
            }
            else {
                //这里才才是一般的业务请求处理入口。
                Request si = new Request(cnxn, cnxn.getSessionId(), h.getXid(),
                  h.getType(), incomingBuffer, cnxn.getAuthInfo());
                si.setOwner(ServerCnxn.me);
                submitRequest(si);
            }
        }
        cnxn.incrOutstandingRequests(h);
    }

zk使用序列化框架jute,原理其实挺简单的,这里以后在专门分析;RequestHeader通过反序列化以后,获取具体的请求内容,接下来就是判断当前请求为哪一类请求,而每一类请求都有对应的处理器,有可能异步处理,也有可能同步处理,这里每一类请求处理器都可以单独拿出来分析的,在以后的章节中在分析。其实到这里,想必读者已经比较清楚整个读流程操作了,以及处理模型。


2、doWrite(k):


    private void doWrite(SelectionKey k) {
        if (outgoingBuffers.size() > 0) {
            ByteBuffer directBuffer = factory.directBuffer;
            directBuffer.clear();

            for (ByteBuffer b : outgoingBuffers) {
                //该判断表明当前的directBuffer容量不足以存放一条完整响应请求字节内容
                if (directBuffer.remaining() < b.remaining()) {
                    //这里确保下面directBuffer.put(b)不会益处
                    b = (ByteBuffer) b.slice().limit(
                            directBuffer.remaining());
                }
                //记录b 处理前的position
                int p = b.position();
                //将b的字节内容放往directBuffer中
                directBuffer.put(b);
                //这里重新设置posistion的位置,主要是为下面判断当前消息是否发送完整做处理。
                b.position(p);
                //这里说明directBuffer已满,可以跳出for循环
                if (directBuffer.remaining() == 0) {
                    break;
                }
            }

            directBuffer.flip();

            int sent = sock.write(directBuffer);
            ByteBuffer bb;

            // Remove the buffers that we have sent
            while (outgoingBuffers.size() > 0) {
                //获取写队列第一个字节内容
                bb = outgoingBuffers.peek();
                if (bb == ServerCnxnFactory.closeConn) {
                    throw new CloseRequestException("close requested");
                }
                int left = bb.remaining() - sent;
                //判断当前请求是否发送完整
                if (left > 0) {
                    //代码走到这里,说明bb并为发送完整,需要重新设置position,留待下次doWrite的时候在把剩下的字节写完整。
                    bb.position(bb.position() + sent);
                    break;
                }
                //代码走到这里说明该响应请求一发送完整
                packetSent();
                //sent的大小需要减去该次剩下的字节长度
                sent -= bb.remaining();
                //移除已发送完整的消息请求
                outgoingBuffers.remove();
            }

        }

        synchronized(this.factory){
            if (outgoingBuffers.size() == 0) {
                //防御式判断
                if (!initialized
                        && (sk.interestOps() & SelectionKey.OP_READ) == 0) {
                    throw new CloseRequestException("responded to info probe");
                }
                //取消写事件的监听
                sk.interestOps(sk.interestOps()
                        & (~SelectionKey.OP_WRITE));
            } else {
                sk.interestOps(sk.interestOps()
                        | SelectionKey.OP_WRITE);
            }
        }
    }

接下来就是写流程处理了,首先通过outgoingBuffers.size() > 0判断,目前写队列里是否有响应结果,如果有的话,先往directBuffer填充响应客户端的字节内容,而for (ByteBuffer b : outgoingBuffers)循环里,主要是不断往
directBuffer填充字节数据,直到outgoingBuffers遍历完成,或者directBuffer先填满。

接着通过int sent = sock.write(directBuffer)往tcp sendbuffer 里写字节内容,对于java应用来说,就相当于响应客户端。而sent记录的是写往tcp sendbuffer的字节大小。

while (outgoingBuffers.size() > 0)循环里主要是将已发送完整的响应请求移除,对于非完整的请求,则留待下次把剩下的内容发送完整。

接着,在通过outgoingBuffers.size() == 0当前是否已经没有写请求,如果没有的话,则取消写事件的监听,否则重新监听写事件。

这里读者可以思考一下为什么需要当没有写请求时,取消写事件的监听?
其实原因很简单,倘若不取消的情况下,则说明tcp send buffer 是非空的,在这种情况下,底层操作系统会产生缓冲区非满,导致selector.select(1000)会一只获取到可写事件,而实际上由没有任何内容可写,进而导致cpu 100%的现象。


到这里,想必读者已经可以清晰的了解了zk服务端通讯模型。

你可能感兴趣的:(zookeeper服务端通讯模型)