前言
zookeeper服务端通讯模型的实现主要有两种方式,一种是原生的java nio ,另一种则是通过netty3构建,默认情况下使用原生nio,因此,我们这里只会分析nio的实现方式,后者也比较简单,有兴趣的读者可以自行分析。
总结一下服务端具体流程:
整个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)
读取四字节的消息长度:
结合上图,连接业务抽象类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服务端通讯模型。