前言
我们在之前的博客里面已经讲完了Zookeeper的集群模式的启动和启动后接收数据的流程。那么我们本篇将开启Zookeeper的最后一个大的内容:Zookeeper的选举机制。自然本篇也会被收录到【Zookeeper 源码解读系列目录】中。
领导者选举算法理论
选举领导者的标准
在现实中我们进行选举一般会有两个因素影响选票的票数:关系和实力。关系好的自然会争取到更多的票数。如果候选人关系这一层面差不多,那就要拼实力了,谁的实力强,谁一般会当选。但是到了服务器集群选Leader的时候,就没有关系可以依靠了,都是机器谁还跟谁关系好咋地。所以就只能按照谁的实力强谁上任。那么就有一个问题了,怎么定义一个服务器谁的节点能力比较强呢?在Zookeeper里面有一个可以量化的东西—数据。Zookeeper中从众多机器中选举出一个领导者的唯一标准就是:Leader一定是数据内容最新的那一台机器
,数据越新能力越强。数据新旧标准是什么呢,Zookeeper里面有下面几个规则:
- Epoch最大的就是Leader,多数情况Epoch是一样的,所以Epoch在大多数情况下不能作为依据。
- 其次zxid也就是事务id越大就意味着数据越新,即zxid大的就是第一标准。
- 如果zxid是一样的,那么myid(sid)最大的是就是最可能被选中的。
集群启动的时候,领导者选举的过程
我们早就已经说过,Zookeeper认为集群里面有多少机器,是根据我们在配置文件里配置了多少台决定的。假设有两台机器A和B需要进行投票,那么首先他们之间是黑盒子,也就是不知道对方的数据是不是更新一些,否则也不需要投票了。但是都不知道的情况下要怎么办呢?Zookeeper这里的逻辑,就会让两台机器都先给自己投一票,并且把这个票放进自己的投票箱里。这个票包含两个信息:我投的那台服务器的sid
和这个sid对应的那台服务器的zxid是多大
,我们就记作<sid,zxid>。然后把选票发到对方的投票箱里,那么就可以比较了。如果发现自己收到zxid比自己投票的大,就会更改自己的投票为zxid大的那一张选票,然后再发出去。如果收到比自己投的小的,直接丢弃就好了。如果说zxid是一样的怎么办?我们看投票<sid,zxid>,因为投票里包含sid,所以各个机器收到投票以后就知道是投票要选的是哪个机器,那么根据原则直接判断sid大的当选就可以了。这里过半机制又是再哪里呢?举个例子,A机器投给自己一票,收到B机器一票,当然B机器也有这个过程。A判断B的选票大于自己,那么就更改自己的选票并发送出去,而B机器收到的A的票是小的,所以抛弃。此时,A中就保留了两张选票,内容是一样的都是<B,zxid-B>。而B机器呢,此时有一张自己的选票,抛弃了A首次发来的选票,再加上A更改后发出的选票,也是两张<B,zxid-B>。如果有C机器,就重复这个过程,直到大家的投票箱子里的票数超过了配置文件中的一半为止,这样就选出了Leader。
那么集群启动的时候,各个机器会经历这样一个过程:
- 每个机器先投自己一票
- 把票发给对方,各个机器接到不同的机器的票数以后,存到投票箱里,在投票箱里对比数据,数据优势的一方会更改掉数据弱势的一方的票,然后再重新发出去优势票。
- 优势的机器收到重发发出的票以后,会统计自己收到的票,超过半数成为leader。
集群中有机器挂掉以后,Zookeeper会进行的操作
- 如果挂掉的是leader,剩下机器会重新选举一个新的leader。
- 如果挂掉follower,剩下的机器未必会重新选举。因为只要挂掉的follower的数量不超过半数,集群就仍然认为是可用的,因此不需要选举。这里说下,如果挂的follower超过半数了,集群就挂了,也无所谓选举不选举了。
集群启动时,领导者选举源码的流程
介绍完大体的理论以后,我们的探究过程其实也就是按照这个顺序来的。这里依然一步一步的走,所以我们就从启动开始入手。
----入口----> QuorumPeerMain.main();
----转到----> QuorumPeerMain.initializeAndRun(args);
----转到----> QuorumPeerMain.runFromConfig(config);
----转到----> quorumPeer.start();
首先我们还是先启动,进入QuorumPeerMain.main()
,进入加载类main.initializeAndRun(args)
,进入集群模式runFromConfig(config)
,略过初始化参数,找到并进入quorumPeer.start()
:
public synchronized void start() {
loadDataBase();
cnxnFactory.start();
startLeaderElection(); //领导者选举方法
super.start();
}
然后就看到了我们今天要讲解的领导者选举方法startLeaderElection()
,进入:
synchronized public void startLeaderElection() {
try {
//首先生成投票
currentVote = new Vote(myid, getLastLoggedZxid(), getCurrentEpoch());
} catch(IOException e) {
/**Exceptions**/
}
for (QuorumServer p : getView().values()) {//寻找本机地址
if (p.id == myid) {
myQuorumAddr = p.addr;
break;
}
}
if (myQuorumAddr == null) {//本机没有找到,报异常
throw new RuntimeException("My id " + myid + " not in the peer list");
}
if (electionType == 0) {//判断选举类型
try {
udpSocket = new DatagramSocket(myQuorumAddr.getPort());
responder = new ResponderThread();
responder.start();
} catch (SocketException e) {
throw new RuntimeException(e);
}
}
this.electionAlg = createElectionAlgorithm(electionType); //创建选举算法
}
Vote类
首先我们就看到了一句话currentVote = new Vote(myid, getLastLoggedZxid(), getCurrentEpoch())
这里就是先给自己投票。经过上面的原理分析,我们知道每台服务器都有一个自己的投票箱,因为不可能再创建一个投票服务器,而且投票箱里存放的应该是当前自己的投票和别人的选票。那么我们总结三个重要的概念,投票箱、投票、选票。回来这句话这个myid
是自然就是自己的myid(sid)
,我们进入Vote
看下关键的类里面的属性:
public Vote(long id, long zxid, long peerEpoch) {
this.version = 0x0;
this.id = id; //myid
this.zxid = zxid; //事务id
this.electionEpoch = -1; //领导者选举的轮数,本机记录
this.peerEpoch = peerEpoch; //选举周期,届号
this.state = ServerState.LOOKING; //选举状态
}
我们解释下几个选举用到的参数
参数名 | 作用 |
---|---|
this.id | 服务器的sid,也就是myid |
this.zxid | 事务id |
this.electionEpoch | 记录领导者选举的轮数,服务器内自己记录的。这个数值越大,投票的权重越大,对传入的低数值epoch有否决权 |
this.peerEpoch | 当前选举周期,届号,传递进来的参数。 |
this.state | 选举状态,这个我们之前简单说过,以后还会用到。 |
我们看过Vote
这个类以后,接着往下,for循环是寻找本机地址的地方,端口就是我们配置的第一个port 2887
这里的端口时进行服务器同步的。再往下本机验证,如果本机地址没有找到报异常。接着就是判断选举类型,其实到了Zookeeper目前的版本,这个electionType
只有唯一值3了。那么这个if
逻辑肯定进不去。最后找到创建选举算法的方法createElectionAlgorithm(electionType)
进入:
protected Election createElectionAlgorithm(int electionAlgorithm){
Election le=null;
switch (electionAlgorithm) {
case 0 - 2: /**过期的选举算法,略**/break;
case 3:
//初始化负责各个服务器之间的底层leader选举过程中的网络通信
qcm = createCnxnManager();
QuorumCnxManager.Listener listener = qcm.listener;
if(listener != null){
listener.start();
le = new FastLeaderElection(this, qcm); //硕果仅存的算法类
} else {
LOG.error("Null listener when initializing cnx manager");
}
break;
default:
assert false;
}
return le;
}
发现Zookeeper
其实之前有好多中选举的机制,但是这些在当前版本已经是过期的了。那么现在所留下来的也就只有唯一的一种FastLeaderElection(this, qcm)
,那么我们就进入这个硕果仅存的类里去:
public FastLeaderElection(QuorumPeer self, QuorumCnxManager manager){
this.stop = false;
this.manager = manager;
starter(self, manager);
}
private void starter(QuorumPeer self, QuorumCnxManager manager) {
this.self = self;
proposedLeader = -1;
proposedZxid = -1;
sendqueue = new LinkedBlockingQueue<ToSend>();
recvqueue = new LinkedBlockingQueue<Notification>();
this.messenger = new Messenger(manager);
}
我们直接走到FastLeaderElection
类里面发现,这里就是new了一个类,做的都是各种初始化,并不是真正做算法的地方。那么这时候我们就意识到了QuorumPeer
类中startLeaderElection()
看起来像是选举方法。但是其实这个方法只是进行领导者选举,确定服务器角色,在针对不同的服务器角色初始化,并不是领导者选举逻辑所在的地方,被虚晃了一枪。
虽然被虚晃一枪,但是这里跟领导者选举肯定有撇不清楚地关系,那我们就仔细看下electionType=3
这个分支里面到底卖的什么药。看之前我们要知道一点选举是通过socket
连接进行的。既然这里没有线索,我们唯一没有看的就是连接的部分,所以我们去看下看case 3: qcm = createCnxnManager();
里面做了什么:
public QuorumCnxManager createCnxnManager() {
return new QuorumCnxManager(this.getId(),
this.getView(),
this.authServer,
this.authLearner,
this.tickTime * this.syncLimit,
this.getQuorumListenOnAllIPs(),
this.quorumCnxnThreadsSize,
this.isQuorumSaslAuthEnabled());
}
QuorumCnxManager做底层传输
进入后发现这里也只是实例化了一个QuorumCnxManager
类,这个类就是负责底层传输的,所以我们需要解析一下这个类里面的重要属性,那么就接着往里走:
public QuorumCnxManager(final long mySid,
Map<Long,QuorumPeer.QuorumServer> view,
QuorumAuthServer authServer,
QuorumAuthLearner authLearner,
int socketTimeout,
boolean listenOnAllIPs,
int quorumCnxnThreadsSize,
boolean quorumSaslAuthEnabled) {
this(mySid, view, authServer, authLearner, socketTimeout, listenOnAllIPs,
quorumCnxnThreadsSize, quorumSaslAuthEnabled, new ConcurrentHashMap<Long, SendWorker>());
}
再进入this(***)
到QuorumCnxManager
类的构造方法里面去:
public QuorumCnxManager(final long mySid,
Map<Long,QuorumPeer.QuorumServer> view,
QuorumAuthServer authServer,
QuorumAuthLearner authLearner,
int socketTimeout,
boolean listenOnAllIPs,
int quorumCnxnThreadsSize,
boolean quorumSaslAuthEnabled,
ConcurrentHashMap<Long, SendWorker> senderWorkerMap) {
this.senderWorkerMap = senderWorkerMap; // 重要属性
this.recvQueue = new ArrayBlockingQueue<Message>(RECV_CAPACITY); // 重要属性
this.queueSendMap = new ConcurrentHashMap<Long, ArrayBlockingQueue<ByteBuffer>>(); // 重要属性
this.lastMessageSent = new ConcurrentHashMap<Long, ByteBuffer>(); // 重要属性
String cnxToValue = System.getProperty("zookeeper.cnxTimeout");
if(cnxToValue != null){
this.cnxTO = Integer.parseInt(cnxToValue);
}
this.mySid = mySid;
this.socketTimeout = socketTimeout;
this.view = view;
this.listenOnAllIPs = listenOnAllIPs;
initializeAuth(mySid, authServer, authLearner, quorumCnxnThreadsSize,
quorumSaslAuthEnabled);
// 重要属性
listener = new Listener();
}
- ConcurrentHashMap<Long, SendWorker> senderWorkerMap
格式:Map:< serverId(myid) : SendWorker >
Long:存的其他服务器的编号serverId,也就是myid。
SendWorker:负责发送数据的类。
说明:也就是说这个map其实就是保存了当前服务器去向其他服务器发送的数据的SendWorker。 - ConcurrentHashMap<Long, ArrayBlockingQueue< ByteBuffer >>queueSendMap
格式:Map:< myid : Queue >
Long:存的其他服务器的编号serverId,也就是myid
ArrayBlockingQueue:是一个数据队列, 是要向其他服务器发送的数据的队列
说明:所以这个map
保存的就是,当前的服务器要向其他服务器发送的数据的队列。SendWorker是一个线程,这个线程就负责去ArrayBlockingQueue这个队列里取出数据发送出去,这里是不是和之前同步数据的地方很像,只不过这里就是发送的选票。 - ArrayBlockingQueue< Message > recvQueue
格式:队列Queue
Message:保存消息的类。
说明:保存本台服务器接收到的消息。 - ConcurrentHashMap<Long, ByteBuffer> lastMessageSent;
格式:Map:< myid : ByteBuffer >
Long:存的其他服务器的编号serverId,也就是myid。
ByteBuffer:存的就是最新发出去的消息。
说明:所以这个map保存的就是,本服务器发送给每台服务器最新的消息,这里也是用了一个map去存的,这里存的只有最新发送出去的消息。
请大家牢牢记住这几个属性,下面的解析离不开他们。
Listener类启动TCP-Socket
再往下又碰见一个很关键的属性listener = new Listener();
,我们点进去发现这个Listener
是一个QuorumCnxManager
的内部类,而且还是一个线程类,这里就不贴代码了,大家记住这是个线程。这里只是new
了一个实例,并没有启动,没什么可说的了。那么到这里代码就走完了,跳回case 3
里接着看:
case 3:
qcm = createCnxnManager();//初始化负责各个服务器之间的底层leader选举过程中的网络通信
QuorumCnxManager.Listener listener = qcm.listener; //取出listener
if(listener != null){
listener.start(); //启动listener线程
le = new FastLeaderElection(this, qcm); //硕果仅存的算法类
} else {
LOG.error("Null listener when initializing cnx manager");
}
break;
往下走,发现刚刚实例化的Listener
被取出来了QuorumCnxManager.Listener listener = qcm.listener;
,然后如果这个取出来的listener
实例对象不是null
,就listener.start();
启动这个线程。根据这一个线索,下面就开始运行Listener.run()
方法了,我们去去看下这个run()
方法中做了什么:
public void run() {
int numRetries = 0;
InetSocketAddress addr;
while((!shutdown) && (numRetries < 3)){ //numRetries<3,这就是为什么最多尝试三次的原因
try {
ss = new ServerSocket();//TCP Socket
ss.setReuseAddress(true);
if (listenOnAllIPs) { //过滤地址
int port = view.get(QuorumCnxManager.this.mySid).electionAddr.getPort();
addr = new InetSocketAddress(port);
} else {
addr = view.get(QuorumCnxManager.this.mySid).electionAddr;
}
LOG.info("My election bind port: " + addr.toString());
setName(view.get(QuorumCnxManager.this.mySid)
.electionAddr.toString());
ss.bind(addr);//绑定地址
while (!shutdown) {
Socket client = ss.accept();//这里会被阻塞,等待其他的服务器来连接
setSockOpts(client);
LOG.info("Received connection request " + client.getRemoteSocketAddress());
if (quorumSaslAuthEnabled) {
receiveConnectionAsync(client);
} else {//一旦通了就会走到这里来
receiveConnection(client);
}
numRetries = 0;
}
} catch (IOException e) {
/**Exceptions**/
}
}
/**连接尝试超过三次没有成功,就到这里不再尝试,代码不涉及主逻辑,略**/
}
进入后发现了while
循环里面Socket
被实例化了ss = new ServerSocket();
,但是注意这里使用的是java
中的Socket
,是TCP
而不是NIO
。经过一些过滤的逻辑走到了ss.bind(addr);
绑定地址,这个地址electionAddr
的端口就是我们配置的第二个端口3887
,下面又一个while
循环,这里的Socket client = ss.accept();
会被阻塞,等待其他的服务器来连接。如果有连接进来,并且通过以后就会走到下面receiveConnection(client)
,注意这里面的while
几乎也是一个while(true)
,那么就可以断定这里其实就是一个监听器不断监听连接。而且每一个socket
都会调用一次这个方法。说到这里,接下来就进去receiveConnection(client)
看下里面写了什么:
public void receiveConnection(final Socket sock) {
DataInputStream din = null;
try {
//取出数据
din = new DataInputStream(
new BufferedInputStream(sock.getInputStream()));
//进行连接的方法
handleConnection(sock, din);
} catch (IOException e) {
LOG.error("Exception handling connection, addr: {}, closing server connection",
sock.getRemoteSocketAddress());
closeSocket(sock);
}
}
进入后看到,首先取出数据din = new DataInputStream(***)
,然后调用handleConnection(sock, din)
进行连接,接着点进去看看是怎么进行连接的:
private void handleConnection(Socket sock, DataInputStream din) throws IOException {
Long sid = null;
try {
sid = din.readLong(); //拿到sid
if (sid < 0) { // sid<0不合法
/**不合法,略**/
}
if (sid == QuorumPeer.OBSERVER_ID) {
/**发现是观察者,略**/
}
} catch (IOException e) {
/**Exceptions**/
}
LOG.debug("Authenticating learner server.id: {}", sid);
authServer.authenticate(sock, din);
//如果我的sid大于对方
if (sid < this.mySid) {
SendWorker sw = senderWorkerMap.get(sid);
if (sw != null) {
sw.finish();
}
LOG.debug("Create new connection to server: " + sid);
closeSocket(sock);//关掉外部传来的socket
connectOne(sid);//去连接对方
} else {//如果对方的sid大于我
SendWorker sw = new SendWorker(sock, sid); //准备发送工具
RecvWorker rw = new RecvWorker(sock, din, sid, sw); //准备接收工具
sw.setRecv(rw);
SendWorker vsw = senderWorkerMap.get(sid);
if(vsw != null)//如果这里不是null,说明这里已经发送过了,就不用再发送了
vsw.finish();
senderWorkerMap.put(sid, sw);//往map里放入数据
queueSendMap.putIfAbsent(sid, new ArrayBlockingQueue<ByteBuffer>(SEND_CAPACITY));
sw.start();
rw.start();
return;
}
}
初识SendWorker 和 RecvWorker
进入后第一步还是要拿到sid
,接着里面有一个很长的针对sid
的判断。首先if (sid < 0)
,sid
小于0肯定不合法。然后如果是观察者if (sid == QuorumPeer.OBSERVER_ID)
,也先略过。重点看if (sid < this.mySid)
这里:这个判断里面sid
是对方连接过来的服务器传递进来的myid
,在之前通过sid = din.readLong();
拿到的。后面this.mySid
这个很明显就是自己的sid
,那么为什么这里要进行一个sid
的大小比较呢?其实这里是针对连接进行的一个优化:
比如1号服务器要连接2号服务器,那么返回来2号也要链接1号服务器。如果不加以限制,那么这两条服务器就会启动两条socket
相互连接,这样明显就是多余了。所以按照myid
的大小进行了一个连接的规则,也就是说领导者选举里面小的sid
连接大的sid
是不允许的。
那么如果"我"
的sid
大于对方,进入这个if
以后就发现,这里会先closeSocket(sock);
关掉外部传来的socket
,然后主动去connectOne(sid);
去连接对方。反之,如果对方的sid
大于"我"
,那么进入else
。先new
一个SendWorker sw
,然后再new
一个RecvWorker rw
。再后面我们就看到了SendWorker vsw = senderWorkerMap.get(sid)
被从一个map
里面按照sid
取出来。接着if(vsw != null)
这里是一个验证,如果这里不是null
,说明这里已经连接发送过了,所以vsw.finish();
关掉。如果取出来的有数据,就把这个sid
的SendWorker
对象放到senderWorkerMap
这个map
里面。从这里也能看出来为什么上面的判空通过了,就得关闭。因为如果是首次链接,map
里一定是null
;如果不是首次连接,map
里一定有对应的sid
的值。为了不重复连接,连接已经存在的sid
,就要关闭。接着再往queueSendMap
这个队列里放一份数据。然后又启动这两个线程sw.start();
和rw.start();
。那么一直看笔者博客的小伙伴肯定明白了,接下来就是要去这两个线程里面了,一个一个来,先去SendWorker.run()
中看看里面写了什么:
public void run() {
threadCnt.incrementAndGet();
try {
ArrayBlockingQueue<ByteBuffer> bq = queueSendMap.get(sid);//拿出数据
//如果队列里面没有数据
if (bq == null || isSendQueueEmpty(bq)) {
ByteBuffer b = lastMessageSent.get(sid);
if (b != null) {
LOG.debug("Attempting to send lastMessage to sid=" + sid);
send(b);
}
}
} catch (IOException e) {
/**Exceptions**/
}
//如果里面有数据
try {
while (running && !shutdown && sock != null) {
ByteBuffer b = null;
try {
ArrayBlockingQueue<ByteBuffer> bq = queueSendMap.get(sid);
if (bq != null) {
b = pollSendQueue(bq, 1000, TimeUnit.MILLISECONDS);
} else {
LOG.error("No queue of incoming messages for " + "server " + sid);
break;
}
if(b != null){
lastMessageSent.put(sid, b);
send(b);
}
} catch (InterruptedException e) {
LOG.warn("Interrupted while waiting for message on queue", e);
}
}
} catch (Exception e) {
/**Log4j**/
}
this.finish();
LOG.warn("Send worker leaving thread");
}
首先看到bq = queueSendMap.get(sid)
数据被拿出来了。然后经过一个if
逻辑:如果里面没有数据if (bq == null || isSendQueueEmpty(bq))
成立,那么就把数据从lastMessageSent.get(sid)
拿出来,再转化为ByteBuffer b
字节缓存,最后字节数据全部发送出去send(b)
。如果里面有数据,那么就在这个while
循环里不断的把队列bq = queueSendMap.get(sid);
从map
里面拿出来,然后从拿出的队列里拿出数据转换为b = pollSendQueue(bq, 1000, TimeUnit.MILLISECONDS)
字节缓存,最后用send(b)
把字节数据发送出去。到这里SendWorker.start()
运行完了,它做了这些事情。我们先不管它为什么要这么做,我们接着看RecvWorker.start()
这个线程又干了什么事情,所以走到RecvWorker.run()
:
public void run() {
threadCnt.incrementAndGet();
try {
while (running && !shutdown && sock != null) {
int length = din.readInt();
/**length验证,略**/
byte[] msgArray = new byte[length];
din.readFully(msgArray, 0, length);//拿出socket数据
ByteBuffer message = ByteBuffer.wrap(msgArray);//包装成ByteBuffer
addToRecvQueue(new Message(message.duplicate(), sid));//添加到RecvQueue中
}
} catch (Exception e) {
/**Log4j**/
} finally {
LOG.warn("Interrupting SendWorker");
sw.finish();
if (sock != null) {
closeSocket(sock);
}
}
}
这里的代码少一些,从名字上看这个应该是接受数据的。果然这个接收的线程首先从socket
里面读出数据din.readFully(msgArray, 0, length);
。然后把内容包装成字节缓存的实例ByteBuffer message = ByteBuffer.wrap(msgArray)
。再调用addToRecvQueue(new Message(message.duplicate(), sid))
包装字节数据成Message
类实例放到recvQueue
里面。addToRecvQueue(***)
这个方法里面并没有什么逻辑,只是一个判空然后add
一个message
到队列里。
public void addToRecvQueue(Message msg) {
synchronized(recvQLock) {
if (recvQueue.remainingCapacity() == 0) {
try {
recvQueue.remove();
} catch (NoSuchElementException ne) {
LOG.debug("Trying to remove from an empty " + "recvQueue. Ignoring exception " + ne);
}
}
try {
recvQueue.add(msg); //数据都放到了这个队列里
} catch (IllegalStateException ie) {
LOG.error("Unable to insert element in the recvQueue " + ie);
}
}
}
不知道大家看到这里有没有什么感觉,其实重新梳理一下,应该有这样一个逻辑:
queueSendMap(存发送命令)--取走-->SendWorker线程(这是Map) --Socket发送到-->其他
recvQueue(存收到命令)<--存入--RecvWorker线程(这也是个Map)<--Socket发送到--其他
总结
所以这部分的重点就在于SendWorker
和RecvWorker
这两个线程的逻辑,而且这两个类都是属于QuorumCnxManager
这个传输层的类。通过分析我们其实就又整出来了一来一回两个数据链条。直观的看一下这一对儿链条,我们其实可以两个问题:1. queueSendMap
这里的数据从何而来,2. recvQueue
收到的数据是谁来使用的?我们往下面的解读也是围绕着这两个问题走的。那么我们会在下一篇博客继续解读这些内容。那么本章讲的总结为一张图: