四、Zookeeper集群及启动流程
1.1 Zookeeper集群
ZooKeeper集群是由多个ZooKeeper服务器节点组成的分布式系统,用于提供协调服务。一个ZooKeeper集群通常由三个或更多个服务器节点组成。
集群角色如下:
- Leader(领导者):在ZooKeeper集群中,只有一个节点可以担任Leader角色。Leader负责处理所有的写请求(包括创建、更新和删除操作),并协调集群中的其他节点。Leader通过与Follower节点保持心跳连接,并将更新操作广播给它们。
如果Leader节点发生故障,集群会重新选举新的Leader
。 - Follower(跟随者):Follower节点负责接收客户端的读请求,并将写请求转发给Leader节点。Follower节点通过与Leader节点保持心跳连接来确保自身状态与Leader保持同步。Follower节点无权执行写操作,只能复制Leader节点的状态。
1.2 启动流程
假设现在有zk节点1、节点2、节点3,集群启动流程如下(节点3和节点2一致):
大致步骤如下:
- 通过
快照文件 + 日志文件
,将数据加载到内存中 - 开启监听端口,默认2181,用于接收客户端的请求
- 开始
故障恢复阶段
,选举leader,节点会先把票投给自己,然后交换选票并比较,当有节点获得超过半数的投票则成为leader,其余节点为follower。接下来进行主从之间的数据同步,leader会将数据同步给follower节点 - 开始
原子广播阶段
,启动zk服务,leader处理读写请求,并会将请求同步给follower,这里采用了两阶段提交
机制,当有超过半数的follower确认该请求后,leader才会将该请求commit,将数据更新到内存
中
源码分析
入口是QuorumPeerMain类的main方法,当前节点会先加载配置文件,然后开始启动,源码如下
org.apache.zookeeper.server.quorum.QuorumPeer
@Override
public synchronized void start() {
// 1、加载磁盘文件数据到内存中
loadDataBase();
// 2、开启监听端口,默认2181
cnxnFactory.start();
// 3、准备选举leader
startLeaderElection();
// 4、开始启动过程
super.start();
}
可以看到,当前zk节点QuorumPeer会先加载磁盘文件数据到内存中,开启监听端口,随后准备选举leader,并开始启动过程。start启动过程里包括了leader选举、变成leader/follower之后的处理过程,方法主要源码如下
try {
/*
* 主循环
*/
while (running) {
// 判断当前节点状态,初始是LOOKING
switch (getPeerState()) {
case LOOKING: // LOOKING状态,选举leader
LOG.info("LOOKING");
setBCVote(null);
setCurrentVote(makeLEStrategy().lookForLeader());
break;
case OBSERVING: // OBSERVING状态,只同步leader数据,不参与投票
LOG.info("OBSERVING");
setObserver(makeObserver(logFactory));
observer.observeLeader();
break;
case FOLLOWING: // FOLLOWING状态,参与事务投票,同步leader数据
LOG.info("FOLLOWING");
setFollower(makeFollower(logFactory));
follower.followLeader();
break;
case LEADING: // LEADING状态,处理事务,将数据同步给follower
LOG.info("LEADING");
setLeader(makeLeader(logFactory));
leader.lead();
setLeader(null);
break;
}
}
} finally {
LOG.warn("QuorumPeer main thread exited");
}
1.3 leader选举
节点间如何通信?
首先要在这些zookeeper节点之中选出一个leader,节点之间需要互相通信,zookeeper具体做法是将节点id大的连接到节点id小的
,如下图
sid=3的zk03节点连接到zk01、zk02,sid=2的zk02节点连接到zk01,这样就在两两之间建立了连接
。代码如下
// 如果对方的sid小于自己的sid
if (sid < self.getId()) {
SendWorker sw = senderWorkerMap.get(sid);
if (sw != null) {
sw.finish();
}
// 关闭这个socket
closeSocket(sock);
// 自己连接到对方
connectOne(sid);
} else {
// 否则启动SendWorker、RecvWorker
SendWorker sw = new SendWorker(sock, sid);
RecvWorker rw = new RecvWorker(sock, sid, sw);
sw.setRecv(rw);
SendWorker vsw = senderWorkerMap.get(sid);
senderWorkerMap.put(sid, sw);
sw.start();
rw.start();
return true;
}
如何选举出leader?
zookeeper使用了一种叫FastLeaderElection快速选举算法
,具体步骤如下:
- 每个节点拥有一张选票,选票里包括sid(节点id)、zxid(事务id)、peerEpoch(epoch,类似年号)信息,这张选票一开始投给自己,并把这张选票发送给其他节点
- 每个节点接收到其他节点的选票,会与自己当前选票进行比较,
先比较epoch,epoch相同进一步比较zxid,zxid相同再比较sid
,如果对方的选票大于自己当前选票,则将自己的选票投给对方节点,并再次发送给其他节点,否则只记录不改票 - 每个节点都会记录接收到的投票信息,当某个节点拥有了
超过半数
的投票,则认定该节点为leader,其余节点为follower。
结合源码来看,节点一开始先投票给自己,如下
org.apache.zookeeper.server.quorum.QuorumPeer
synchronized public void startLeaderElection() {
// 先投票给自己
currentVote = new Vote(myid, getLastLoggedZxid(), getCurrentEpoch());
}
然后将选票发送给其他节点,每个节点接收到其他节点的选票后,与自己当前选票进行比较。如果选票变更,需要再次将选票发送给其他节点,以让其他节点感知。部分代码如下
org.apache.zookeeper.server.quorum.FastLeaderElection
public Vote lookForLeader() throws InterruptedException {
HashMap recvset = new HashMap();
LOG.info("New election. My id = " + self.getId() +
", proposed zxid=0x" + Long.toHexString(proposedZxid));
// 将选票发送给其他节点
sendNotifications();
// 循环直到找到leader
while ((self.getPeerState() == QuorumPeer.ServerState.LOOKING) &&
(!stop)) {
// 接收到其他节点的选票
FastLeaderElection.Notification n = recvqueue.poll(notTimeout,
TimeUnit.MILLISECONDS);
switch (n.state) {
case LOOKING:
// 将接收到的选票与自己当前选票进行比较
if (totalOrderPredicate(n.leader, n.zxid, n.peerEpoch,
proposedLeader, proposedZxid, proposedEpoch)) {
// 如果接收到的选票 > 自己当前选票,更新自己的选票,并再次将选票发送给其他节点
updateProposal(n.leader, n.zxid, n.peerEpoch);
sendNotifications();
}
// 记录接收到的选票
recvset.put(n.sid, new Vote(n.leader, n.zxid, n.electionEpoch, n.peerEpoch));
// 如果已经有节点获得了半数的投票
if (termPredicate(recvset,
new Vote(proposedLeader, proposedZxid,
logicalclock, proposedEpoch))) {
// 等待finalizeWait时间,如果又接收到了其他选票,再进行比较
while ((n = recvqueue.poll(finalizeWait,
TimeUnit.MILLISECONDS)) != null) {
if (totalOrderPredicate(n.leader, n.zxid, n.peerEpoch,
proposedLeader, proposedZxid, proposedEpoch)) {
recvqueue.put(n);
break;
}
}
// 如果未接收到其他选票,则最后确认leader
if (n == null) {
self.setPeerState((proposedLeader == self.getId()) ?
QuorumPeer.ServerState.LEADING : learningState());
Vote endVote = new Vote(proposedLeader,
proposedZxid,
logicalclock,
proposedEpoch);
leaveInstance(endVote);
return endVote;
}
}
break;
case OBSERVING:
case FOLLOWING:
case LEADING:
// 略
default:
break;
}
}
return null;
}
可以看到,当某个节点拥有了超过半数
的投票,则认定该节点为leader,其余节点为follower。选票比较
的源码如下:
// 比较选票
protected boolean totalOrderPredicate(long newId, long newZxid, long newEpoch, long curId, long curZxid, long curEpoch) {
if(self.getQuorumVerifier().getWeight(newId) == 0){
return false;
}
// 先比较epoch,再比较zxid,最后比较sid
return ((newEpoch > curEpoch) ||
((newEpoch == curEpoch) &&
((newZxid > curZxid) || ((newZxid == curZxid) && (newId > curId)))));
}
1.4 数据同步
leader选举出来之后,每个follower会主动连接到leader,并开始与leader进行数据同步。同步策略有3种,分别为DIFF、TRUNC、SNAP
。leader会存储最近的一部分事务,用于快速同步数据,minCommittedLog表示这些事务里的最小zxid,maxCommittedLog表示这些事务里的最大zxid。
- DIFF:当
minCommittedLog <= (follower节点的zxid) <= maxCommittedLog
时,会将zxid到maxCommittedLog的这一部分事务,发送给follower - TRUNC:当
(follower节点的zxid)> maxCommittedLog
时,说明此时follower节点存在部分事务超过了leader节点,会发送TRUNC命令让follower将这一部分数据删除 - SNAP(默认):当
(follower节点的zxid)< minCommittedLog
时,说明此时follower节点与leader节点的数据存在较大差距,leader会发送当前数据快照给follower节点进行同步
部分源码如下:
int packetToSend = Leader.SNAP;
long zxidToSend = 0;
long leaderLastZxid = 0;
ReentrantReadWriteLock.ReadLock rl = lock.readLock();
try {
rl.lock();
// 最近事务的最大zxid、最小zxid
final long maxCommittedLog = leader.zk.getZKDatabase().getmaxCommittedLog();
final long minCommittedLog = leader.zk.getZKDatabase().getminCommittedLog();
// 最近事务集合
LinkedList proposals = leader.zk.getZKDatabase().getCommittedLog();
if (proposals.size() != 0) {
// 当minCommittedLog <= peerLastZxid <= maxCommittedLog时,发送DIFF命令,逐一发送事务并提交
if ((maxCommittedLog >= peerLastZxid) && (minCommittedLog <= peerLastZxid)) {
LOG.debug("Sending proposals to follower");
long prevProposalZxid = minCommittedLog;
boolean firstPacket=true;
packetToSend = Leader.DIFF;
zxidToSend = maxCommittedLog;
for (Leader.Proposal propose: proposals) {
// 跳过follower节点已经存在的事务
if (propose.packet.getZxid() <= peerLastZxid) {
prevProposalZxid = propose.packet.getZxid();
continue;
} else {
// 逐一发送事务,并提交
queuePacket(propose.packet);
QuorumPacket qcommit = new QuorumPacket(Leader.COMMIT, propose.packet.getZxid(),
null, null);
queuePacket(qcommit);
}
}
} else if (peerLastZxid > maxCommittedLog) {
// 当peerLastZxid > maxCommittedLog,发送TRUNC命令
packetToSend = Leader.TRUNC;
zxidToSend = maxCommittedLog;
updates = zxidToSend;
} else {
LOG.warn("Unhandled proposal scenario");
}
} else {
LOG.debug("proposals is empty");
}
LOG.info("Sending " + Leader.getPacketType(packetToSend));
leaderLastZxid = leader.startForwarding(this, updates);
} finally {
rl.unlock();
}
// 给follower节点发送SNAP命令
if (packetToSend == Leader.SNAP) {
zxidToSend = leader.zk.getZKDatabase().getDataTreeLastProcessedZxid();
}
oa.writeRecord(new QuorumPacket(packetToSend, zxidToSend, null, null), "packet");
bufferedOutput.flush();
// 给follower发送数据快照
if (packetToSend == Leader.SNAP) {
leader.zk.getZKDatabase().serializeSnapshot(oa);
oa.writeString("BenWasHere", "signature");
}
bufferedOutput.flush();
当follower与leader完成数据同步之后,follower会返回ACK给leader,当超过半数的follower返回ACK时,leader才会正式启动服务。
至此大致的启动流程就结束了,后续会介绍zookeeper在启动服务之后,是如何接收并处理请求的。