前情提要
上一篇博客我们基本上把Zookeeper在集群模式下数据同步之前,服务端的启动流程梳理了一遍。主要讲了Zookeeper是如何在配置文件中识别并开启集群模式的。开启以后,又是如何从快照中加载数据的。开启主线程以后Leader
和各个Learner
是如何进行交互,如何产生最新的Epoch
届号以及如何进行ACK
机制的。那么我们本篇就是要带大家看一下,完全启动之前的另一项非常重要的操作----数据同步,是如何进行的。本篇也会被收录到【Zookeeper 源码解读系列目录】中。
到目前为止,我们一直都在讲服务器启动的过程,同步数据也是启动的过程之一。但是这些并不是说只有在启动的时候才会发生。比如一个集群的leader
挂掉了,需要重新选举,选举结束以后这个过程也会走一遍。而数据同步的过程则更加的频繁,下面我们设想一个场景来探究数据同步。我们假设一个集群正在运行,突然加入了一个follower
,那么这个集群是怎么向这个新的follower
上面同步数据的。
Leader向Learner发送内存的内容
看过单机模式系列的同学应该能想到,leader可以直接把自己的快照发给新的follower。但是这就会有一个问题,如果快照不是最新的怎么办?请大家回想一下处理器链的逻辑,只要我们走到了更新内存的步骤,是不是就是说说明已经做好了持久化?就算是没有打快照,持久化和更新内存也是会一直做的,所以leader
除了把快照同步给follower
以外,还会把内存的数据同步给follower
,这样就也能做到数据的完全同步了。那么就要去补上我们在处理器链的最后一环FinalRequestProcessor
的一个点,当时这里不影响单机模式,所以我们没有讲。那么我们在这里补上,首先还是先去FinalRequestProcessor
这个类里面的processRequest(Request request);
方法,然后找到这一小块内容:
public void processRequest(Request request) {
/**暂时无关代码略**/
synchronized (zks.outstandingChanges) {//获取修改记录
/**暂时无关代码略**/
if (request.hdr != null) {
TxnHeader hdr = request.hdr;
Record txn = request.txn;
rc = zks.processTxn(hdr, txn);//更新内存
}
//如果发现是集群模式
if (Request.isQuorum(request.type)) {
zks.getZKDatabase().addCommittedProposal(request);
}
}
/**暂时无关代码略**/
}
我们找到if (Request.isQuorum(request.type))
这里的意思从名字上很明显,如果发现是集群(Quorum
)的请求类型就进入这个逻辑块,然后把这个request
传入addCommittedProposal(***)
这个方法里。这个方法的作用就是存储需要持久化并且已经持久化过的事务的请求到列表committedLog
里,有点绕。简单来说就是集群模式下把持久化好的事务放进去,那么我们进去看下:
public void addCommittedProposal(Request request) {
WriteLock wl = logLock.writeLock();
try {
wl.lock();
if (committedLog.size() > commitLogCount) {//commitLogCount初始值是500
committedLog.removeFirst(); //移除第一个
minCommittedLog = committedLog.getFirst().packet.getZxid();//更新最小的事务id
}
if (committedLog.size() == 0) {
minCommittedLog = request.zxid;//存下最小的事务id,这里注意后面要用
maxCommittedLog = request.zxid;//存下最大的事务id,这里注意后面要用
}
byte[] data = SerializeUtils.serializeRequest(request);
QuorumPacket pp = new QuorumPacket(Leader.PROPOSAL, request.zxid, data, null);
Proposal p = new Proposal();
p.packet = pp;
p.request = request;
committedLog.add(p);//把这个事务添加到,committedLog里面
maxCommittedLog = p.packet.getZxid();
} finally {
wl.unlock();
}
}
内容不是很多,首先用写锁wl.lock();
锁住保证线程安全。然后我们看变量commitLogCount
,它的初始值就是500。那么这个if (committedLog.size() > commitLogCount)
块的逻辑就很简单了,如果说committedLog
里面存事务的数量大于500,那么就把第一个事务id
移除committedLog.removeFirst();
出去,然后更新最小的事务id
到当前第一个事务id
,这里就是为了保证committedLog
不会太大,永远少于500个。接着如果发现committedLog
里面是空的,那么就把最大的id
和最小的id
用请求的zxid
更新一下。再往下走,取出数据数组data
,然后包装出来一个QuorumPacket
,是不是有些熟悉了?我们看传的参数Leader.PROPOSAL
这里就是leader
提交的提议。然后committedLog.add(p);
把这个事务包添加到committedLog
里面。
我们说了这么多其实就是想说清楚,committedLog
里面存的到底是什么:从我们分析解析源码来看,这个committedLog
就是存的已经持久化好的事务。因为我们已经到了FinalRequestProcessor
这个处理器里面,只要到了这里就说明持久化已经做好了。而这个添加addCommittedProposal(request)
是在更新内存的方法rc = zks.processTxn(hdr, txn);
之后调用的。就说明我们的事务持久化完毕、更新内存完毕以后才会被存入到committedLog
里面去,所以committedLog
这个链表存的永远都是最新的事务合集。那么新加入的follower
想要获取最新的数据,只需要去读取committedLog
里面的内容就足够了。这里要再强调一点,committedLog
里面存储的可能包含了快照的部分数据,但是其中很有可能存在比快照更新的数据,所以follower
同步了快照,再同步committedLog
里面的数据就能够做到数据不丢失。
Zxid的概念
说到这里不知道大家有没有注意到,我们一直再说的zxid
这个东西。这个其实就是我们说的事务id
,Zookeeper里面还有一个比较重要的id
就是sid(service id)
,也就是外面配置的myid
。这些id在选举中是很有作用的,而zxid
在我们同步数据以及领导者选举的过程中可以说有着举足轻重的作用。我们知道Zookeeper为了保证自身分布式数的一致性,使用了ZAB
协议,也就是大名鼎鼎的Zookeeper原子消息广播协议(Zookeeper Atomic Broadcast)
。用于标识一次更新操作的Proposal ID,为了保证顺序性,Zxid必须单调递增。在ZAB协议
的事务编号Zxid
的设计中,Zxid
是一个64位的数字。其中低32位是一个简单的单调递增的计数器,针对客户端每一个事务请求,计数器加1。而高32位则代表Leader
周期的Epoch
编号,每当当选产生一个新的Leader
服务器,就会从这个Leader
服务器上去出本地日志中最大事务的Zxid
,并从其中读取Epoch
值,然后加1,以此作为新的Epoch
,并将低32位从0开始计数。这样就保证了Zxid
的全局递增性。那么以下说到Zxid
或者事务id
,大家要知道说的是一个东西。
数据同步
通过上面的分析,我们知道数据同步有两个来源,一个是快照,另一个是committedLog
这个列表。既然已经知道了上面两点,我们再回到LearnerHandler.run()
里面接着走上次没有走完的代码,代码很长,我们还是分开讲解:
public void run(){
/**上略,下面就是开始同步数据的代码了**/
peerLastZxid = ss.getLastZxid(); //拿到最新的follower端的zxid
int packetToSend = Leader.SNAP; //定义操作为快照SNAP
long zxidToSend = 0;
long leaderLastZxid = 0; //声明leader最新的zxid变量
long updates = peerLastZxid; //follower需要更到的zxid
ReentrantReadWriteLock lock = leader.zk.getZKDatabase().getLogLock();
ReadLock rl = lock.readLock();
try {
rl.lock();
//拿到最大id
final long maxCommittedLog = leader.zk.getZKDatabase().getmaxCommittedLog();
//拿到最小id
final long minCommittedLog = leader.zk.getZKDatabase().getminCommittedLog();
/**Log4j**/
//拿到最新的committedLog
LinkedList<Proposal> proposals = leader.zk.getZKDatabase().getCommittedLog();
if (peerLastZxid == leader.zk.getZKDatabase().getDataTreeLastProcessedZxid()) {
/**暂时略**/
} else if (proposals.size() != 0) {//如果说committedLog是有值的
/**暂时略**/
} else {
LOG.debug("proposals is empty");
}
LOG.info("Sending " + Leader.getPacketType(packetToSend));
leaderLastZxid = leader.startForwarding(this, updates);
} finally {
rl.unlock();
}
/**暂时略**/
}
接着分析,首先其实就是为了获取需要同步的事务内容,首先一些相关的变量,然后使用了一个读锁rl.lock();
,下面就要开始正文了。先把最大的Zxid
和最小的Zxid
拿到,这里的maxCommittedLog
和minCommittedLog
都是在ZKDatabase.addCommittedProposal(***)
方法里设置好了,我们开篇刚刚说过的方法。紧接着就拿到了最新的committedLog
事务链表:proposals = leader.zk.getZKDatabase(). getCommittedLog();
。接着就到了第一个检查点if (peerLastZxid == leader.zk.getZKDatabase() .getDataTreeLastProcessedZxid())
这个判断是说什么意思呢?首先peerLastZxid
就是最新的跟随者的事务id
是在上面的peerLastZxid = ss.getLastZxid()
这句话里拿到的,那么这句话的意思就是:如果这个跟随者的事务id
和当前的leader
里面最新的事务id
是一样的,就进入这个逻辑块,下面我们就单看这个if
逻辑块里面的内容:
if (peerLastZxid == leader.zk.getZKDatabase().getDataTreeLastProcessedZxid()) {
LOG.info("leader and follower are in sync, zxid=0x{}", Long.toHexString(peerLastZxid));
packetToSend = Leader.DIFF;
zxidToSend = peerLastZxid;
}
看到里面其实也没什么内容,如果说follower
里最新的zxid
和leader
里最新的zxid
是一样的,说明follower
里面的数据也是最新的,所以就不需要进行什么逻辑处理。那么我们的重点就在于,如果不一样怎么办,接着看else if (proposals.size() != 0)
里面的内容,这句话是说如果说committedLog
是有值的话,要怎么处理:
else if (proposals.size() != 0) {//如果说committedLog是有值的
LOG.debug("proposal size is {}", proposals.size());
if ((maxCommittedLog >= peerLastZxid) && (minCommittedLog <= peerLastZxid)) {
LOG.debug("Sending proposals to follower");
long prevProposalZxid = minCommittedLog;//这里同步最小值
boolean firstPacket=true;
packetToSend = Leader.DIFF;//这个操作就叫做DIFF
zxidToSend = maxCommittedLog;
for (Proposal propose: proposals) {
if (propose.packet.getZxid() <= peerLastZxid) {//把大于的min的zxid找出来
prevProposalZxid = propose.packet.getZxid();//更新最小值
continue;
} else {//如果大于最小值,就添加到一个packet队列中
if (firstPacket) { //这里会判断是不是首次发送
firstPacket = false;
if (prevProposalZxid < peerLastZxid) { //如果follower发的不是leader需要的
packetToSend = Leader.TRUNC; //删除,后面因为first变成了false再也进不来了
zxidToSend = prevProposalZxid;
updates = zxidToSend;
}
}
queuePacket(propose.packet);//添加到一个队列(queuedPackets)中
QuorumPacket qcommit = new QuorumPacket(Leader.COMMIT, propose.packet.getZxid(),
null, null);//这里就是在构造ack以后leader发给follower的commit
queuePacket(qcommit);//同样也是先添加到queuedPackets
}
}
} else if (peerLastZxid > maxCommittedLog) {//follower比leader当前的还要大
/**Log4j**/
packetToSend = Leader.TRUNC;//回滚的操作叫做TRUNC
zxidToSend = maxCommittedLog;//更新最大id给zxidToSend
updates = zxidToSend;
}
}
其实我们简单思考下,无非就是判断peerLastZxid
和leader
端Zxid
的关系。既然有了上述的判断,那么if ((maxCommittedLog >= peerLastZxid) && (minCommittedLog <= peerLastZxid))
那这里就很好理解了,就是判断peerLastZxid
在不在leader
端最大的事务id
和最小的事务id
之间的情况。比如minCommittedLog=100
,maxCommittedLog=400
,传过来的peerLastZxid=200
,那么就需要同步201到400这些事务。
进入这个逻辑块,首先这里同步最小值prevProposalZxid
,然后packetToSend = Leader.DIFF
给这个操作命名叫做"DIFF"
意为同步不一样的事务,再然后更新最大值zxidToSend
。再往下有一个for
循环,紧接着的if (propose.packet.getZxid() <= peerLastZxid)
语句,这里就是把大于的minCommittedLog
的事务id
找出来,如果小于从跟随者那里拿到的事务号peerLastZxid
,那么直接更新最小值,然后continue
继续寻找。举个例子,leader最小是200,而follower的是300,那么就把最小值更新为200,下次更新为201,直到和follower的300相同为止。如果大于从跟随者那里拿到的事务peerLastZxid
,就添加到一个queuePacket(propose.packet)
队列中。这里就好比,leader
最小是301,而follower
的是300,那么把301添加到队列queuedPackets
里,因为301是follower
需要更新的事务。这里提醒下只是添加并没有发送,记住这个队列。然后又构造一个QuorumPacket qcommit
实例,这里传入的操作码是Leader.COMMIT
。这个commit
请求就是我们一般情况下学习Zookeeper的时候,集群服务器之间的连接过程:leader
会有一个提议给follower
,然后follower
会返回leader
一个ack
响应(就是我们上篇说的ack
),最后leader
再回给follower
一个commit
,这个commit
就是在这里发送给follower
的。这里要提醒一下,follower
收到commit
以后就会直接提交数据,不会再发ack
之类的内容了。继续往后看queuePacket(qcommit);
同样也是把这个包先添加到queuedPackets
队列,并不会在这里发送。
我们看过peerLastZxid
在消息范围之间的关系以后,就剩下一种情况了。当follower
的事务id
比当前leader
的最大的事务id
还要大的情况。此时follower
必须做回滚操作以保证和leader
的数据同步,因为leader
这台机器才是最权威的。回滚的操作叫做TRUNC
,发送这个命令给follower
,follower
就会把多余的事务删除。然后更新最大的事务id
给zxidToSend
,就是要follower
收到删除命令以后,从leader
端最大事务id
也就是maxCommittedLog
这个数值开始向后删除。
但是还有一种情况,那就是如果follower
传递来的事务id
小于leader
的zxid
最小值怎么办?比如follower
传来100,但是leader
的最小事务id
是200,那么就会去取默认值SNAP
快照,也就是说就是让follower
直接从leader
的快照中更新就行了。因为follower
里面的数据太老旧,就没有什么意义了,刷掉就好了。
发送数据
我们说完处理数据以后,自然就要到了发送了,那么什么时候发呢?往下拉,发现new一个匿名线程,这里面有发送方法,这是个单独的匿名线程,就在LearnerHandler.run()
里面:
public void run(){
/**略**/
new Thread() {
public void run() {
Thread.currentThread().setName( "Sender-" + sock.getRemoteSocketAddress());
try {
sendPackets();//发送
} catch (InterruptedException e) {
LOG.warn("Unexpected interruption",e);
}
}
}.start();
/**略**/
}
我们进入sendPackets()
看:
private void sendPackets() throws InterruptedException {
long traceMask = ZooTrace.SERVER_PACKET_TRACE_MASK;
while (true) {
try {
QuorumPacket p;
p = queuedPackets.poll();//取出队列
/**略**/
oa.writeRecord(p, "packet");//发送出去
} catch (IOException e) {
/**略**/
}
}
}
进入后发现进入后会发现这里是一个while(true)
的循环,那么只要此线程不停,凡是被添加到队列queuedPackets
里的事务都会被发送。我们看先p = queuedPackets.poll();
取出队列,然后调用方法oa.writeRecord(p, "packet")
发送出去。
这里要区分一下,SNAP
,DIFF
,TRUNC
是直接通过Socket
发送出去的,而COMMIT
是加到队列里,统一发出去的。因为在这个专门为了发送的匿名线程之前还有一段:
public void run(){
/**略**/
if (packetToSend == Leader.SNAP) {
zxidToSend = leader.zk.getZKDatabase().getDataTreeLastProcessedZxid();
}
//这里会发送SNAP,DIFF,TRUNC
oa.writeRecord(new QuorumPacket(packetToSend, zxidToSend, null, null), "packet");
bufferedOutput.flush();
if (packetToSend == Leader.SNAP) {
/**这里是另一个条件分支,没有太大关系**/
}
new Thread() { sendPackets(); }.start();
/**略**/
}
专门判断如果if (packetToSend == Leader.SNAP)
成立,那么就直接oa.writeRecord(new QuorumPacket( packetToSend, zxidToSend, null, null), "packet");
发出去。
总结
到了这里leader
传输数据给follower
的内容基本上告一段落,我们接下来要看的就是follower
怎么处理SNAP、DIFF、TRUNC
等等这些操作的。我们的同步数据还没有完,后续leader
和follower
还会继续有交互,但是我们放到下一篇在讲。那么最后更新一下昨天的交互图作为一个结尾。