zk peer 生命周期
下面内容是本人挑了一些个人感兴趣的点来研究的,实际的内容远不止这些。😆
本人也是才开始啃 zk 源码,有错误的地方欢迎指正~
另外需要说一下,下文中源码均来自官方 3.4.14 分支。
前情提示
peer 是什么?
开始之前,这里再谈谈 QuorumPeer。在 zk server 集群模式启动时,每个 zk 实例默认都是 QuorumPeer,在恢复完数据,并进行 leader 选举之后,每个 peer 进入新的角色 —— leader 或者 follower。
而 zk 服务启动的过程,其实就是集群 peer 初始化和启动的过程,详细的说,该过程包括:
- zk 服务的启动。(这个过程包括配置读取、数据恢复)
- 初始化选举。(这个过程包括构建选举算法,构建选举通信连接管理器)
- 进入状态机。(这个过程就是 peer 整个生命周期不断循环的过程,这里面包括集群选举、扮演角色)
再谈一下我这里所说的 peer 状态机,其实集群逻辑自洽的服务端应用大多都会用到状态机,zk 也不例外,zk 的状态就漂浮在选举态和 leader、follower 之间:
主从同步?数据恢复!
主从同步这个概念,很容易让人想起 master-slave 架构的中间件中的数据同步概念。而本文中的主从同步,并不发生在运行态下主从数据的增量同步;本文中的主从同步,主要描述的是在 leader 选举结束之后,follower 需要和主节点同步数据,来保证 follower 数据和 leader 一致,保证集群数据处于一致性装态,这也是 zab 协议的要求。一言蔽之,就是 follower 数据的恢复!
总结
zk 集群中 QuorumPeer 从启动到死亡包含一下流程:
- main 启动
- 读取配置文件
- 启动 zk 集群服务(quorumPeer 对象)
- 准备选举(此后,zk 服务进入状态机)
- 启动 quorumPeer 对象,进入选举状态机
- 投票选举
- 进入角色
- 阻塞在当前状态…
1.main 函数启动
zk 的启动入口 main 函数,进入后,真正服务运行委托了 initializeAndRun 方法:
// org.apache.zookeeper.server.quorum.QuorumPeerMain
public static void main(String[] args) {
QuorumPeerMain main = new QuorumPeerMain();
try {
// 真正运行入口
main.initializeAndRun(args);
} catch (IllegalArgumentException e) {
...
}
...
}
2.读取配置文件
进入 initializeAndRun 后,第一件事就是解析配置,实际上是以文件方式读取的。
这里有个细节,如果读取到了配置文件,就会按照集群方式启动 zk;如果没有读取到启动配置文件,默认会按 standalone 方式读取配置文件。
另外,在启动 zk 服务之前,会开启一个磁盘清理线程,专门清理 snapshot 历史日志。
protected void initializeAndRun(String[] args)
throws ConfigException, IOException
{
QuorumPeerConfig config = new QuorumPeerConfig();
// 读取文件配置
if (args.length == 1) {
config.parse(args[0]);
}
// 开启磁盘清理任务(清理多余的 历史snapshot 日志)
// Start and schedule the the purge task
DatadirCleanupManager purgeMgr = new DatadirCleanupManager(config
.getDataDir(), config.getDataLogDir(), config
.getSnapRetainCount(), config.getPurgeInterval());
purgeMgr.start();
if (args.length == 1 && config.servers.size() > 0) {
// 拿刚刚解析的配置,启动 zk
runFromConfig(config);
} else {
LOG.warn("Either no config or no quorum defined in config, running "
+ " in standalone mode");
// there is only server in the quorum -- run as standalone
ZooKeeperServerMain.main(args);
}
}
3.启动 zk 集群服务
启动 zk 集群服务的流程,核心是创建和启动 quorumPeer 对象。quorumPeer 对象专用来管理集群状态。
public void runFromConfig(QuorumPeerConfig config) throws IOException {
...
try {
ServerCnxnFactory cnxnFactory = ServerCnxnFactory.createFactory();
cnxnFactory.configure(config.getClientPortAddress(),
config.getMaxClientCnxns());
quorumPeer = getQuorumPeer();
// 设置其他 peer 的信息(在配置文件中读取到的)
quorumPeer.setQuorumPeers(config.getServers());
quorumPeer.setTxnFactory(new FileTxnSnapLog(
new File(config.getDataLogDir()),
new File(config.getDataDir())));
quorumPeer.setElectionType(config.getElectionAlg());
quorumPeer.setMyid(config.getServerId());
quorumPeer.setTickTime(config.getTickTime());
quorumPeer.setInitLimit(config.getInitLimit());
quorumPeer.setSyncLimit(config.getSyncLimit());
...
quorumPeer.initialize();
// 启动 zk 服务
quorumPeer.start();
// 主线程等待 zk 服务线程启动完毕
quorumPeer.join();
} catch (InterruptedException e) {
// warn, but generally this is ok
LOG.warn("Quorum Peer interrupted", e);
}
}
zk server 的核心启动流程,就在于 quorumPeer 对象启动流程(start 方法里)。这里做了三件非常重要的事:
- 恢复节点数据(恢复 ZkDatabase)(这一步很重要,恢复数据之后,可以选举出更好的 leader)
- 启动服务端连接处理器
- 准备 leader 选举
- 开启集群服务(quorumPeer.start(),进入状态机,其中包括选举流程)
public synchronized void start() {
loadDataBase();
cnxnFactory.start();
startLeaderElection();
super.start();
}
其中,在发起 leader 选举时,会创建集群 peer 连接管理器(QuorumCnxManager),这个地方会发起和集群中其他节点的连接,后面也会说到。
4.准备 leader 选举
这一步是为选举做准备,是创建选举算法,一般默认选择 FastLeaderElection:
protected Election createElectionAlgorithm(int electionAlgorithm){
Election le=null;
switch (electionAlgorithm) {
case 0:
le = new LeaderElection(this);
break;
case 1:
le = new AuthFastLeaderElection(this);
break;
case 2:
le = new AuthFastLeaderElection(this, true);
break;
// 一般走这个 case
case 3:
// 创建集群连接管理对象
qcm = createCnxnManager();
// 监听 38881 端口
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;
}
FastLeaderElection 这个算法在前面 leader 选举章节也说明了,该算法主要负责 leader 选举,并进行选举相关的操作:
- 选票收集
- 选票核对
- 选票广播
这个阶段会创建集群连接管理对象,并创建 listener,其用来监听集群选举端口(38881,用于集群各节点互联)。
5.启动 quorumPeer,进入选举状态机
准备工作(加载数据、准备 peer 互联管理器、加载选举算法)做完,就会进入 quorumPeer 状态机,这里面会进行选举、互联、接受客户端服务等,各种各样的操作:
public void run() {
while (running) {
switch (getPeerState()) {
case LOOKING:
// 选举...
makeLEStrategy().lookForLeader();
break;
case OBSERVING:
// 监听 leader
observer.observeLeader();
break;
case FOLLOWING:
// 监听 leader
follower.followLeader();
break;
case LEADING:
// 开始 lead
leader.lead();
break;
}
}
}
选举
一开始会处于 LOOKING 状态,这个时候是没有 leader 的,需要去发起 leader 选举。leader 选举的过程,就是不断监听 IO 票箱的过程。
选举时通信互联
别忘了,在选举准备阶段,仅仅是监听集群互联的 38881 选举端口,而没有进行集群互联操作,即,只是自行监听了 38881 选举端口,而没有去连接别人的 38881 选举端口。(毕竟选举通信的前提是要互相建连的)
在选举时,当前节点准备广播自己的投票时,则会对所有集群节点进行一次连接(发起集群的互联操作):
private void sendNotifications() {
// 将自己投票结果发送给所有的投票者
for (QuorumServer server : self.getVotingView().values()) {
long sid = server.id;
ToSend notmsg = new ToSend(...);
sendqueue.offer(notmsg);
}
}
这个地方会往 sendQueue 里面塞入自己的选票,然后发给所有人。在给每个节点发送时,会和其进行懒连接:
public void toSend(Long sid, ByteBuffer b) {
// 发给自己的(zk 会给自己也发一下)
if (this.mySid == sid) {
b.position(0);
addToRecvQueue(new Message(b.duplicate(), sid));
// 发给别人的
} else {
ArrayBlockingQueue<ByteBuffer> bq = new ArrayBlockingQueue<ByteBuffer>(SEND_CAPACITY);
ArrayBlockingQueue<ByteBuffer> bqExisting = queueSendMap.putIfAbsent(sid, bq);
if (bqExisting != null) {
addToSendQueue(bqExisting, b);
} else {
addToSendQueue(bq, b);
}
// 兜底懒链接
connectOne(sid);
}
}
这样,zk 就完成了和各个节点的连接。
这个时候就是被动和主动相结合的过程了。被动是在选举的准备阶段,就已经开始监听 38881(使用 listener 监听,上述一.4节中结束部分提到的),等其他人来连自己;主动就是选举时也会尝试和其他节点建立连接。
此外,为了避免互联时造成重复连接,zk 还约定了:只能和比自己 zxid 小的节点连接。否则关闭该链接。
6.投票选举
looking 状态下的投票,就是不断 广播自己选票
--> 汇总检查票
的过程:
这个过程之前已经详细说过,这里不再赘述。最终选举结束后,各个节点变更自己当前的状态,然后开始进入自己的角色。
7.进入角色
选举结束后,各个节点开始扮演自己的角色,做一些初始化操作之后,就可以提供 zk 服务了。
主节点(Leader.lead()
方法)
1.创建 learner 连接接收器,专门用于接受和处理来自 learner 的连接,使用的仍然是 28881 端口:
void lead() throws IOException, InterruptedException {
// log 当前选举耗时为...
try {
//...
// 监听 28881 上新来的 follower 的连接
cnxAcceptor = new LearnerCnxAcceptor();
cnxAcceptor.start();
//...
}
}
LearnerCnxAcceptor 监听到 follower 等建连请求后,为其创建 learnerHandler(follower、observer 统称 learner),即 learner 节点处理器,然后启动 learnerHandler:
class LearnerCnxAcceptor extends ZooKeeperThread{
// ...
@Override
public void run() {
while (!stop) {
try{
Socket s = ss.accept();
...
// 为 learner 创建 连接管理器
LearnerHandler fh = new LearnerHandler(s, is, Leader.this);
// 启动连接处理器
fh.start();
} catch (SocketException e) {
...
}
}
// ...
}
}
learnerHandler 中,会监听 follower 的数据包,由于 follower 在初始化完毕后,会向 leader 发送包含自己的 zxid 的签到数据包(我自己起的名字,并非来自源码或者官方文档);这个地方 learnerHandler 就会收到这个数据包;learnerHandler 收到数据包后,会判断 learner 的状态,告知其该 learner 怎么从本 leader 获取数据来恢复。可能是 DIFF、SNAP、TRUNC、TUNC+DIFF。
这里很容易混淆,再详细描述下:
leader 在自己这一端,会为每个 learner(follower、observer)创建一个 learnerHandler,这个 learnerHandler 专门处理来自 learner 的请求。也就是说,这个地方主节点为每个 learner 都分配了一个处理器(单独线程),都是 vip 专坐~(没读源码之前,让我来设计,我可能就是一个 handler 来处理器所有 learner)
在选举结束,集群初始化阶段,learnerHandler 会收到各个 learner 的签到数据包,这个数据包就包含了该 learner 的 zxid
当 learnerHandler 收到这个数据包后,会抽取其中的 learner zxid,然后比对 learder 的 minCommittedLog 和 maxCommittedLog,然后决策出该 learner 应该采取何种数据恢复策略。
learnerHandler 的源码如下:
public void run() {
try {
// 1.创建 follower 的读写流
...
// 2.完善当前 handler 信息(handler 和 follower 是一对一的关系)
...
// 3.根据 follower 的信息,决定恢复策略
...
// 4.传输 NEWLEADER 包 (夹在3和5之间,需要注意),这一步算是广播给所有 follower,自己是 new leader
QuorumPacket newLeaderQP = new QuorumPacket(Leader.NEWLEADER,
ZxidUtils.makeZxid(newEpoch, 0), null, null);
...
// 5.告诉 follower 应该采取何种数据恢复策略(包括 DIFF、SNAP、TRUNC、TRUNC+DIFF)
oa.writeRecord(new QuorumPacket(packetToSend, zxidToSend, null, null), "packet");
bufferedOutput.flush();
...
// 6.不停的接收来自 follower 的包
while (true) {
qp = new QuorumPacket();
ia.readRecord(qp, "packet");
...
}
}
}
需要额外注意的是,在数据恢复操作之间,有一个发送 NEWLEADER 数据包的操作。follower 收到该数据包后
2.开启 zk 服务:
startZkServer();
3.阻塞在和 follower 之间的心跳,如果每次有半数 follower 响应心跳,则继续下次心跳;如果心跳时发现丢失半数心跳,则退出 leader 角色,重新开始选举。
while (true) {
// 半个 tick 心跳一次
Thread.sleep(self.tickTime / 2);
if (!tickSkip) {
self.tick.incrementAndGet();
}
// 每次收到心跳后,将该节点添加到 syncedSet 中
HashSet<Long> syncedSet = new HashSet<Long>();
syncedSet.add(self.getId());
for (LearnerHandler f : getLearners()) {
// 这是异步的方式,如果 ping 失败后,下次心跳才会发现 synced() == false
if (f.synced() && f.getLearnerType() == LearnerType.PARTICIPANT) {
syncedSet.add(f.getSid());
}
f.ping();
}
...
// 和半数以上的节点 ping 失败,则结束循环,退出 leader 角色
if (!tickSkip && !self.getQuorumVerifier().containsQuorum(syncedSet)) {
shutdown("Not sufficient followers synced, only synced with sids: [ "
+ getSidSetString(syncedSet) + " ]");
return;
}
tickSkip = !tickSkip;
}
从节点(follower.followLeader()
方法)
1.当从节点确定自己是从节点之后,要做的第一件事,向主节点建立连接。此时连接的实际上是 28881 端口(回顾下,之前选举是则是 toSend 方法中发起的 38881,leader peer 使用 qcm.listener 监听的),再建立一个链接。
2.建立连接后,follower 立刻向 leader 汇报自己的 zxid,方便 leader 决断 follower 该采取何种数据恢复策略。
3.向主节点查询数据数据恢复策略,并开始进行数据恢复(这个方法里面会启动 zkServer,zkServer 启动后,就可以对外提供服务了,不过需要等待数据恢复结束,并且主节点发来 UPTODATE 指示)
4.停留在地监听来自主节点的数据(大多是 2PC 的 事务 proposal),扮演好自己的 follower 角色
5.会在 4 中循环,完毕
void followLeader() throws InterruptedException {
try {
QuorumServer leaderServer = findLeader();
try {
// 1.向 leader 节点 28881 节点建立连接
connectToLeader(leaderServer.addr, leaderServer.hostname);
// 2.向 leader 报道,同时汇报自己的 zxid
long newEpochZxid = registerWithLeader(Leader.FOLLOWERINFO);
// 3.向主节点查询数据数据恢复策略,并开始进行数据恢复
syncWithLeader(newEpochZxid);
// 4.不停监听主节点的消息
while (this.isRunning()) {
readPacket(qp);
processPacket(qp);
}
...
}
}
}
8.阻塞在当前状态…
一旦选举结束,确定完当前 peer 在集群中的角色后,就会阻塞在当前状态。等到集群因为意外而发生状态变更了,就会继续回到选举态,继续选举。