在如今这样的分布式系统和云计算的时代,构建可靠、高性能的分布式服务是各个领域不可缺失的一部分。在这个领域里,zookeeper作为java中非常知名的中间件,一直以来都扮演着一个关键的角色。
接下来,我决定写一系列关于zookeeper源码分析的文章,对zookeeper的源码进行深度解读。
为什么要进行ZooKeeper源码分析?
1、深刻理解分布式系统基础理论:ZooKeeper是分布式系统领域的一个经典实现,通过深度源码解析,我们可以更好地理解分布式系统中的关键概念和原理,为设计和构建分布式系统打下坚实基础。
2、学习设计模式和优秀编程实践:ZooKeeper的源码体现了许多设计模式和良好的编程实践。通过学习这些,我们可以汲取宝贵的经验,提高我们自己的编程水平。
3、解密ZooKeeper的神秘面纱:ZooKeeper的内部实现一直以来都是分布式领域的热门话题。通过源码分析,我们将揭示它的神秘面纱,理解它是如何实现高可用性、一致性和可靠性的。
从客户端发送报文开始
java
public static void main(String[] args) throws IOException, InterruptedException, KeeperException {
//1. 创建一个Zookeeper客户端
ZooKeeper zookeeper = new ZooKeeper("localhost:2181", 2000, null);
//2. 创建一个持久节点
zookeeper.create("/hello", "编程易行".getBytes(StandardCharsets.UTF_8), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
Stat stat = new Stat();
//3. 获取写入节点的数据
byte[] data = zookeeper.getData("/hello", false, stat);
System.out.println(new String(data, StandardCharsets.UTF_8));
}
上面是一个非常简单的hello world,就是创建一个Zookeeper客户端,调用create创建节点。然后,在调用getData打印我们之前写入的数据。
我们源码分析就从这里开始。(zookeeper的配置管理,也是个很有意思的话题,后面有机会可以分析下。)
1. 从Zookeeper.create 开始
一切的开始,都是Zookeeper.create
代码非常简洁,其实就是:
1、构造Request报文
2、调用ClientCnxn#submitRequest 发送报文,并等待结果
什么是submit一个报文?
如果是个java,我们想要往一个socket写数据,我们会怎么写?
我们会写 Socket.getOutputStream().write(byte数组)
,如果我们用的Netty网络库,我们可能会写Channel.writeAndFlush(对象)
这里zookeeper发送报文的方法,名字叫submit
,本身其实也说明了它的原理 并不是简单的write
一下就完了。
我们接着往下看
1、先来看类名,叫做 ClientCnxn
这是zookeeper对于客户端连接的一个抽象。Zookeeper客户端与服务端的交互,都是通过ClientCnxn
这一入口。
2、还记得么,在create
方法里,我们构造了一个CreateRequest
对象,并且把这个对象以参数形式,传入 submitRequest
方法中。在submitRequest
里,我们又把CreateRequest
以参数形式传入queuePacket
方法里
发送报文 = 插入队列?
代码也非常简单
1、把我们的Request
和 Response
包装成一个个的Packet
2、把Packet添加到一个队列里
非常简单,后面的一系列文章中,我们能看到zookeeper的源码设计里,大量的应用了队列。
写入队列中的报文是什么时候发出去的?
客户端调用create肯定不只是想写个队列就完了,终极目标肯定还是要发送数据到服务端。因此,一定会有另一个线程,去读这个队列的数据。这个线程,就是SendThread
1、ClientCnxnSocket的一些初始化
2、如果Tcp连接是断开的
2.1)找到要连接的zookeeper节点地址 我们使用zookeeper时,传入的connectString可能不止有一个节点的地址,比如 "10.0.0.1:2181,10.0.0.2:2181,10.0.0.3:2181" 这一步其实就是挑选我们要连接的zookeeper的地址。具体算法其实就是把地址打乱,不停地next。也就是说,如果某台zookeeper节点挂了,下一个循环,会尝试去连另一个zookeeper节点
2.2)发送Connect报文
3、判断是否超时,其实就是一段时间没收到zookeeper的报文就超时了,认为zookeeper已经挂了,这里会抛异常
4、调用网络库发送报文 这里就是调用网络库,获取outgoingQueue
队首的报文,通过socket发送出去
整个流程有点复杂,可以对照着流程图理解
小结create发送报文
所以,整个流程大概是这样。我们调用create时,会往outgoingQueue队列写入一个Packet。然后,会有一个SendThread线程,它会不停地取出队首报文,然后调用网络库(Netty或者NIO)往socket写入数据,实现往zookeeper服务端发送报文。
收到报文流程是怎么样?
从上面的分析中,我们知道。无论是写入队列,还是SendThread的死循环,和我们调用 create
的线程都不是同一个。换句话说,都是异步的。然而,实际使用时,我们调用create后,立刻去查zookeeper节点数据,一定都是能查到的。Zookeeper是如何把异步"变成"同步呢?
我们再回过头来看 submitRequest
方法,秘诀就在这个packet.wait
里。
也就是说,除了往队列里写入一个报文,zookeeper客户端还会调用这个Packet.wait()
方法,等待packet的通知(就是jdk的Object.wait())方法。
网络库写入socket时,会插入pendingQueue
之前分析SendThread
流程时,我们忽略了网络库调用的细节。这里我们把这块补上
这里我们以Netty网络库为例(NIO的类似,但是NIO里有很多NIO编程的细节,相对来说比较复杂,感兴趣的读者可以自己分析下)
可以看到,ClientCnxnSocketNetty
除了调用channel.writeAndFlush()
把报文写入底层socket,还把Packet写入了一个pendingQueue
如图所示,在调用channel.writeAndFlush()
之前,会往pendingQueue
队尾插入一个Packet
网络库收到报文的处理
这是Netty收到报文后的处理。这里有非常经典的粘包拆包的处理,后面有机会分析下。此处我们关心的是收到完整的报文后,Netty会调用SendThread.readResponse()
方法
1、首先,收到报文后,会调用PendingQueue.remove()
获取队首元素。这里有两个注意点:
1)队列的特性是先入先出,所以我们先通过Netty写出的报文,在收到报文时也会被最先弹出。
2)Server端返回报文的数据一定和它收到报文的数据一致。举个例子,客户端往服务端发送了 create
、getChildren
、delete
报文,服务端一定也会按照 create
、getChildren
、delete
返回,不会出现乱序。
接下来我们看看finishPacket
的逻辑
也就是说,收到了服务端的回包后,会调用Packet的notifyAll方法,通知阻塞等待回包的线程。
zookeeper客户端发送报文完整流程
完整的流程如上所示
1、客户端调用 create
等方法时,会往一个 outgoingQueue
队列中,插入一个Packet。随后,通过 Object.wait()
方法,阻塞等待
2、Zookeeper客户端有一个Send线程,它会不停地取出 outgoingQueue
队首元素,然后调用网络库发送报文给zookeeper服务端
3、发送报文给zookeeper服务端前,Send线程会把Packet插入另一个pendingQueue
里,这个队列中专门存放等待服务端响应的报文
4、客户端收到服务端的回包后,会取出 pendingQueue
队首袁术,调用packet.notifyAll()
通知阻塞等待的线程
总结
这篇博客分析了zookeeper 客户端发送报文的流程。从中,我们能有以下几点收获:
1、zookeeper发送报文,是单线程发送的。前一个报文发送完后,才会发送后一个报文。
2、zookeeper客户端发送报文的设计,采用了 "生产者消费者模式",一个线程往队列写,一个线程读取队列数据,写入socket。采用这种设计,有什么好处呢?这个问题比较复杂,后面会单独写文章分析。