Zookeeper Leader选举网络通信

Leader 选举过程中怎么把票发出去的?发出去后其他节点是怎么收到票的? 这两个问题的答案说简单点那肯定是通过网络传输,那问题又来了:节点之间是怎么建立连接的?

先来分析下 Leader 选举发起投票以及接收投票这部分内容的网络通信原理以及简易架构图,然后再对照着思想进行源码剖析。话不多说,让我们进入正题。

一、核心架构及源码

首先我们在前面几篇就反复提到了 ZooKeeper 的投票机制是异步的,并不是立即将票通过网络传输给接收者了,异步二字大家并不陌生了,那如何实现异步呢?大家可能立即就想到了:先放到队列里,然后开线程异步消费进行发送。 没错, ZooKeeper 也是这么玩的。

接下来一起看看 ZooKeeper 的异步传输原理以及它的核心组件源码。

刚也已经说到了,ZooKeeper 也是先把消息放到队列里,然后开线程异步消费进行发送。 所以我们需要搞一个发送者队列:

fianl BlockingQueue<ByteBuffer> sendQueue;

这样就行了吗?明显不够,我们并不知道这条消息是发给谁的,所以我们需要换成 Map 结构,key 来标记 sid,value 是这个队列,如下:

final ConcurrentHashMap<Long, BlockingQueue<ByteBuffer>> queueSendMap;

好了,到目前为止,第一步先把消息放到队列里的数据结构已经设计完成了,接下来我们分析第二步开线程异步消费进行发送。 这个是不是也通俗易懂,直接搞个 Thread 去消费这个队列就完了,怎么消费?太简单不过了,阻塞队列有poll()方法可以弹出数据,这不就是消费嘛。因此,我们需要设计一个线程类:

class SendWorker extends ZooKeeperThread {
    public void run() {
        // 消费
    }
}

看起来也很完美,但是别忘了我们消息是存到 Map 里的,key 是 sid,也就是代表某个 ZooKeeper 节点,那我们这个线程消费 Map 里的哪份数据?因此我们也需要设计一个 Map 类型的数据结构,还是以 sid 为 key,value 变为SendWorker,也就是发送线程。也就是自己线程消费自己数据,数据结构和伪代码如下:

final ConcurrentHashMap<Long, SendWorker> senderWorkerMap;

class SendWorker extends ZooKeeperThread {
    public void run() {
        // 消费
        BlockingQueue<ByteBuffer> bq = queueSendMap.get(sid);
        ByteBuffer b = bq.poll(timeout, unit);
        send(b);
    }
}

我们用一张图总结一下:

Leader选举-network-1.png

现在发送者的数据结构和核心逻辑已经设计完了,那消息发出去后肯定要有接收者呀,接收完消息后存到哪呢?所以我们还需要一个队列,那就是接收者队列,接收者需要知道是谁发来的吗?完全不需要,你发给我了,我拿着消息做处理就行了,所以不用 Map 结构,直接用一个队列来接收即可:

public final BlockingQueue<Message> recvQueue;

问题又来了:我只是设计了一个接收消息队列,也就是接收完消息存储的地方,那怎么接收?老规矩,肯定还是一个线程去异步地收消息,然后放到recvQueue,我们称这个线程为RecvWorker

class RecvWorker extends  ZooKeeperThread {
    public void run() {
        // 读消息
        msg = din.readFully(msgArray, 0, length);
        // 放到recvQueue
        recvQueue.offer(msg)
    }
}

到目前为止,我们设计完了发送者的存储结构和工作线程以及接收者的存储结构和工作线程,我们画个架构图来总结下,这样更易于理解,轻松易懂。

Leader选举-network-2.png

以上内容就是 ZooKeeper 在 Leader 选举时网络通信组件的架构设计,既然我称它为组件,那自然要把它封装到一个类里面,我们这里有两个线程,一个发送消息的工作线程SendWorker,另一个是接收消息的工作线程RecvWorker,它们其实都是监听队列,然后放入或弹出数据,所以我们将它们放到网络通信组件里,如下:

public class QuorumCnxManager {
    // 发送者Worker
    final ConcurrentHashMap<Long, SendWorker> senderWorkerMap;
    // 发送队列
    final ConcurrentHashMap<Long, BlockingQueue<ByteBuffer>> queueSendMap;
    // 接收队列
    public final BlockingQueue<Message> recvQueue;
    
    class SendWorker extends  ZooKeeperThread {
        // ... 省略
    }
    
    class RecvWorker extends  ZooKeeperThread {
        // ... 省略
    }
}

Leader选举-network-3.png

至此,网络管理组件的大体架构已经设计好了,但是我们还有很多疑问,比如:

  1. 我们知道queueSendMap是发送队列且以 sid 为 key,那么这个队列里的数据是谁放进来的?
  2. 我们知道recvQueue是接收队列,接收的消息来自queueSendMap,那么接收完消息后干嘛了?

接下来,我们就一个一个地攻破这些疑问,彻底掌握 Leader 选举网络通信相关的底层原理以及源码。我们先看第一个疑问:queueSendMap队列里的数据是谁放进来的?

Leader 选举第一步就是投票给自己并异步通过网络发送出去,那这时候它要发给哪些节点?当然是全部节点,每个节点都有自己的 sid,因此这里以接收者的 sid 作为 key,投票给自己的消息体作为 value 存储到queueSendMap当中

我们在上一篇讲解了 Leader 选举的核心源码,主方法是lookForLeader(),但是我们留了一个方法没讲解:sendNotifications();,我们只说这个是异步将投票信息发给其他节点。接下来我们就看下这个方法到底干了什么:

private void sendNotifications() {
    // for循环,给每一个sid进行发送。
    for (long sid : self.getCurrentAndNextConfigVoters()) {
        ToSend notmsg = new ToSend(/*省略拼凑消息体*/);
        // 放到queue里
        sendqueue.offer(notmsg);
    }
}

我们发现这个方法很简单,就是 for 循环遍历每个节点,然后拼凑消息体,将消息体放到一个全新的队列里sendqueue。这时候我们就该想到:既然我们把消息已经放到了sendqueue里面,那我们肯定有地方从这里取数据,那怎么获取呢?我们sendqueue的数据结构是阻塞队列LinkedBlockingQueue<ToSend>,阻塞队列有poll()方法可以弹出数据,那这就好办了,我们搞个死循环,一直监听数据,有的话就将消息弹出来进行处理,然后以接收者的 sid 为 key 将消息放到queueSendMap不就好了吗?所以太简单啦:

while (!stop) {
    try {
        // 拿到消息
        ToSend m = sendqueue.poll(3000, TimeUnit.MILLISECONDS);
        // 其实我觉得这个判断就是防止“假死”的情况,如果这么久还没消息就执行下一轮循环重新调用下poll方法。
        if (m == null) {
            continue;
        }
		// 进行处理
        process(m);
    } catch (InterruptedException e) {
        break;
    }
}

void process(ToSend m) {
    // 拼凑消息体
    ByteBuffer requestBuffer = buildMsg(m.state.ordinal(), m. Leader , m.zxid, m.electionEpoch, m.peerEpoch, m.configData);
    // 构造BlockingQueue,然后以sid为key,将queue放到queueSendMap里
    BlockingQueue<ByteBuffer> bq = queueSendMap.computeIfAbsent(m.sid, serverId -> new CircularBlockingQueue<>(SEND_CAPACITY));
    // 将消息体加入到BlockingQueue里,这样queueSendMap的数据就构造完成了。
    // 有的人问:bq.offer()了,不需要重新queueSendMap.put()一下吗? 大哥,你先了解下Java是值传递还是引用传递哈。
	bq.offer(requestBuffer);
}

我们先简单总结下这个方法的主要流程:

  • Leader 选举的时候,每个节点会先投自己一票,且广播给每个节点(放到sendqueue里)。
  • 有个方法会从sendqueue里弹出数据,然后以 sid 为 key,将消息体放到BlockingQueue里,然后以BlockingQueue作为 value 放到queueSendMap当中。

我们刚分析了有个方法会从sendqueue里弹出数据,那谁负责弹出来呢?所以我们也单独搞个线程最为合适:

class WorkerSender extends  ZooKeeperThread {
    public void run() {
        while (!stop) {
			// 省略
        }
    }
}

Leader选举-network-4.png

我们接着看第二个遗留的问题:recvQueue是接收队列,接收的消息来自queueSendMap,那么接收完消息后干嘛了?

你说干嘛了?肯定是投票用的呀,也就是说会有一个线程不断地从recvQueue里拿数据做一些处理。大概是下面这样子:

class WorkerReceiver extends  ZooKeeperThread {
    public void run() {
        while (!stop) {
            recvQueue.poll(3000, TimeUnit.MILLISECONDS);
        }
    }
}

接下来我们回想下上一篇接收投票的时候是怎么做的?我把代码贴到下面回忆下:

// 获取其他节点的投票信息
Notification n = recvqueue.poll(notTimeout, TimeUnit.MILLISECONDS);

recvqueue是哪来的呢?其实这个是我们上面WorkerReceiverrecvQueue里弹出数据处理完业务后又重新放到recvqueue里的,代码如下:

class WorkerReceiver extends  ZooKeeperThread {
    public void run() {
        while (!stop) {
            // 弹出
            Message n = recvQueue.poll(3000, TimeUnit.MILLISECONDS);
            // 放回
            recvqueue.offer(n);
        }
    }
}

Leader选举-network-5.png

那我们的WorkerSenderWorkerReceiver应该属于哪个组件呢?这两个线程是不是都和选举算法有关,一个投票,另一个接收票据进行选举对比,因此它们属于选举算法组件,我们称之为FastLeaderElection。由于选举算法类包含太多内容了,因此额外多包一层,叫Messenger,专门封装收发消息相关,如下图:

Leader选举-network-6.png

到目前为止,发起投票和接收投票的交互应该很清晰了,只是我们又产生了几个新的疑问:

  1. 选举算法组件里的WorkerSenderWorkerReceiver两个工作线程是什么时候开始工作的?
  2. 网络通信组件里的SendWorkerRecvWorker两个工作线程是什么时候开始工作的?

老规矩,我们依然逐个攻破,我们来看第一个疑问:WorkerSenderWorkerReceiver两个工作线程是什么时候开始工作的?

先来看第一个WorkerSender线程何时工作?首先它的工作是监听sendqueue投票队列,不断取出数据发给网络通信组件的queue,而我们sendqueue投票队列又是在进行选举发起投票的时候放入数据的。那我们WorkerSender是不是可以在节点启动的时候就开始工作,然后开启死循环一直获取数据?因此我们第一个线程 SendWorker的工作时机就是随着节点启动一起启动。

再来看第二个WorkerReceiver线程何时工作?这个线程是不断接收网络组件返回的投票信息,然后将消息加入到自己选举算法的 queue 中,那这个线程也一样需要在节点启动的时候随之启动,节点启动就一直监听即可。

那代码要怎么实现呢?我们先贴一下目前我们已经知道的代码结构:

public class FastLeaderElection {
    protected class Messenger { 
        // 省略
        class WorkerSender extends  ZooKeeperThread {}
        class WorkerReceiver extends  ZooKeeperThread {}
    }
}

那要怎么启动这两个内部线程呢?正常情况是我们new WorkerSender().start(),但是现在没地方可以去 new,因此我们可以在Messenger里声明两个方法:一个是实例化的方法,另一个是启动线程的方法。

实例化方法如下:

public class FastLeaderElection {
    protected class Messenger { 
        WorkerSender ws;
        WorkerReceiver wr;
        Thread wsThread = null;
        Thread wrThread = null;

        // 实例化两个线程
        Messenger(QuorumCnxManager manager) {
            this.ws = new WorkerSender(manager);
            this.wsThread = new Thread(this.ws, "WorkerSender[myid=" + self.getId() + "]");
            this.wsThread.setDaemon(true);

            this.wr = new WorkerReceiver(manager);
            this.wrThread = new Thread(this.wr, "WorkerReceiver[myid=" + self.getId() + "]");
            this.wrThread.setDaemon(true);
        }
    }
}

线程启动方法如下:

public class FastLeaderElection {
    
    // 实例化
    public FastLeaderElection() {
       this.messenger = new Messenger(manager);
    }
    
    // 启动
    private void start() {
         this.messenger.start();
    }
    
    protected class Messenger { 
        // 启动两个线程
        void start() {
            this.wsThread.start();
            this.wrThread.start();
        }
    }
}

有了这两个方法,我们的事情就变得很简单了,我们直接在节点启动那里调用new Messenger()start()这两个方法就好了。伪代码如下:

// 节点启动方法
main() {
    // 实例化
    FastLeaderElection election = new FastLeaderElection();
    // 启动
    election.start();
}

到目前为止又清晰了一点,我们知道选举算法里面的消息是何时何地发出的以及何时何地接收的。我们先继续完善下架构图,然后再分析第二个疑问。

Leader选举-network-7.png

接着看第二个疑问:网络通信组件里的SendWorkerRecvWorker两个工作线程是什么时候开始工作的?

其实这个问题很简单,这两个线程都有个前提条件,那就是:这两个线程在什么情况下才能正常工作?一个是通过网络发消息,另一个通过网络接收消息。那很明显了,这两个线程需要提前建立好网络连接后才可以正常工作,比如节点 1 和节点 2 已经正常建立了连接,那么这时候节点 1 才能给节点 2 通过网络发消息。

假设现在触发网络连接了,那么这两个线程启动的伪代码逻辑如下:

private void handleConnection(Socket sock, DataInputStream din) {
    // 实例化两个线程
    SendWorker sw = new SendWorker(sock, sid);
    RecvWorker rw = new RecvWorker(sock, din, sid, sw);
   	// 初始化、赋值
    senderWorkerMap.put(sid, sw);
    queueSendMap.putIfAbsent(sid, new CircularBlockingQueue<>(SEND_CAPACITY));
	// 启动两个线程
    sw.start();
    rw.start();
}

看起来很简单,但是我们又产生了三个新的疑问:

  1. 节点之间是如何建立连接的?
  2. SendWorker是如何将消息通过网络发出去的?
  3. RecvWorker又是如何通过网络接收消息的?

先分析第一个:节点之间是如何建立连接的? 这个也常被作为面试题,小伙伴们肯定很多人会猜测是通过 netty 来建立网络连接的,今天我们一探究竟。直奔主题,不涉及太多理论铺垫,因为网络连接无非是 socket,所以直接看下底层实现到底是采取的什么技术手段。

public void initiateConnection(final MultipleAddresses electionAddr, final Long sid) {
    // 1. 搞一个socket
    Socket sock = self.getX509Util().createSSLSocket();
    // 2. connect address
    sock.connect(electionAddr.getReachableOrOne(), cnxTO);
    // 3. 连接核心,比如:网络输入输出流的设置等
	startConnection(sock, sid);   
}

// 可以发现就是普通的io流
private boolean startConnection(Socket sock, Long sid) throws IOException {
         din = null;
    BufferedOutputStream buf = new BufferedOutputStream(sock.getOutputStream());
    DataOutputStream dout = new DataOutputStream(buf);
    // ... 省略
    dout.write(addr_bytes);
	dout.flush();
	// ... 省略
    DataInputStream din = new DataInputStream(new BufferedInputStream(sock.getInputStream()));
}

ZooKeeper 的 Leader 选举网络通信就是这么简单,直接采取 Socket + IO 流的方式进行网络通信。那我们接下来的疑问:SendWorker是如何将消息通过网络发出去的?RecvWorker又是如何通过网络接收消息的? 就不攻自破了,前面有了 Socket,那直接通过这个 Socket 进行网络发送和接收就好了。

我们先来看SendWorker

class SendWorker extends  ZooKeeperThread {
    SendWorker(Socket sock, Long sid) {
        // 搜噶,这里用的socket的流
        dout = new DataOutputStream(sock.getOutputStream());
    }
    
    public void run() {
        // 消费
        BlockingQueue<ByteBuffer> bq = queueSendMap.get(sid);
        ByteBuffer b = bq.poll(timeout, unit);
        send(b);
    }
}

// 这段代码实在没啥好解释的,Socket的最基础知识,类似于HelloWorld
synchronized void send(ByteBuffer b) throws IOException {
    byte[] msgBytes = new byte[b.capacity()];
    try {
        b.position(0);
        b.get(msgBytes);
    } catch (BufferUnderflowException be) {
        return;
    }
    dout.writeInt(b.capacity());
    dout.write(b.array());
    dout.flush();
}

这段代码实在没啥好解释的,就是设置 Socket 流,然后 flush 类似于发出去,这是 Socket 的最基础知识,类似于 HelloWorld。那RecvWorker又是如何通过网络接收消息的呢?直接读出 Socket 的输入流就好了。

// sock.getInputStream()
din = new DataInputStream(new BufferedInputStream(sock.getInputStream()));
RecvWorker rw = new RecvWorker(sock, din, sid, sw);

class RecvWorker extends  ZooKeeperThread {
    public void run() {
        // 读消息
        final byte[] msgArray = new byte[length];
        msg = din.readFully(msgArray, 0, length);
        // 放到recvQueue
        recvQueue.offer(msg)
    }
}

这部分内容很简单,都是 Socket 最基础最基础的知识,就不做过多解释了。到目前为止我们已经剖析完 Leader 选举网络相关的全部核心内容了,我们继续补充下我们的架构图:

Leader选举-network-8.png

我们思考一个问题:我们已经知道 Leader 选举网络通信这部分的架构设计以及核心源吗了,那么这部分设计是如何避免两台机器重复建立 tcp 连接的?比如,节点 A 发请求和节点 B 进行通信建立连接,恰巧节点 B 也发起了和节点 A 建立连接的请求,那么节点 A 和节点 B 是如何处理的?

这里 ZooKeeper 的处理方式采取的是只能和比自己 myid 小的节点进行连接,那如果收到比自己大的 myid 发起建立连接的请求该怎么办呢?直接忽略此次请求,也就是将发来的请求给关掉(close())。

举个例子:现有节点 A(myid=1)、节点 B(myid=2)、节点 C(myid=3),如果节点 A 收到节点 B 发来的请求,那么节点 A 判断收到建立连接的请求者 myid 是 2,比自己的大,那直接不处理,将此次请求给 close 掉。如果节点 C 接收到节点 A 和节点 B 发来的请求,那么节点 C 判断收到建立连接的请求者 myid 是 1 和 2,都比自己的小,那就正常建立连接。这样就规避掉了重复建立连接的情况。

原理搞懂了,那么代码实现自然就很简单了:

private boolean startConnection(Socket sock, Long sid) throws IOException {
    if (sid > self.getId()) {
    	// 忽略此次请求,也就是将此次请求close掉。
        closeSocket(sock);
    } else {
    	// ... 启动工作线程等逻辑。 
    }
}

二、总结

本篇采取大量图文结合的方式进行剖析,应该已经很清晰明了了,简单来讲就是如下四个队列以及这四个队列的交互时机和方式:

Leader选举-network-8.png

为什么设计得这么复杂,要用四个队列?其实可以想象成不同模块,网络通信模块 2 个 queue,选举算法模块 2 个 queue。彼此之间异步调用解耦合

还有一个非常值得学习的编码技巧就是:我们很多时候可以封装得很好,比如 ZooKeeper 选举算法两个线程启动的代码就很优雅,类似生命周期函数一样,对外提供可实例化的方法以及封装 start() 方法。代码如下:

public class FastLeaderElection {
    
    // 实例化
    public FastLeaderElection() {
       this.messenger = new Messenger(manager);
    }
    
    // 启动
    private void start() {
         this.messenger.start();
    }
    
    protected class Messenger { 
        WorkerSender ws;
        WorkerReceiver wr;
        Thread wsThread = null;
        Thread wrThread = null;

        // 实例化两个线程
        Messenger(QuorumCnxManager manager) {
            this.ws = new WorkerSender(manager);
            this.wsThread = new Thread(this.ws, "WorkerSender[myid=" + self.getId() + "]");
            this.wsThread.setDaemon(true);

            this.wr = new WorkerReceiver(manager);
            this.wrThread = new Thread(this.wr, "WorkerReceiver[myid=" + self.getId() + "]");
            this.wrThread.setDaemon(true);
        }
        
        // 启动两个线程
        void start() {
            this.wsThread.start();
            this.wrThread.start();
        }
    }
}

这段代码十分优雅,在很多开源框架里都会这么设计,封装成内部类,对外提供 API,隐藏底层实现,客户端只需要调 start() 方法就好了,底层干了什么并不关心,错误的做法是:客户端手动去实例化这两个线程,然后手动调用这两个线程的 start() 方法。

posted @ 2023-03-30 20:53  Dazzling!  阅读(63)  评论(0编辑  收藏  举报