目录
1. 请求处理
1. 会话创建请求
Zookeeper服务端对于会话创建的处理,大体可以分为请求接收,会话创建,预处理,事务处理,事务应用和会话响应6大环节。
请求接收
- I/O层接收来自客户端的请求:NIOServerCnxn实例维护每一个客户端连接,客户端与服务端的所有通信都是由NIOServerCnxn负责的。
- 判断是否是客户端“会话创建”请求:如果NIOServerCnxn尚未被初始化,那么可以确定该客户端请求一定是“会话创建”请求。
- 反序列化ConnectRequest请求
- 判断是否是ReadOnly客户端:如果当前Zookeeper服务器是以ReadOnly模式启动的,那么所有来自非ReadOnly型客户端的请求无法被处理。
- 检查客户端ZXID:如果发现客户端的ZXID大于服务端的ZXID,不接受该客户端的会话创建请求。
- 协商sessionTimeout:默认情况下,Zookeeper服务端对超时时间的限制介于2个tickTime到20个tickTime之间。
- 判断是否需要重新创建会话:服务端会根据客户端请求中是否包含sessionID来判断该客户端是否需要重新创建会话。
会话创建
- 为客户端生成sessionID:每个Zookeeper服务器在启动的时候,都会初始化一个会话管理器(SessionTracker),同时初始化sessionID,针对每个客户端,只要在这个“基准sessionID”的基础上进行逐个递增就可以了。
- 注册会话:创建会话最重要的工作就是向SessionTracker中注册会话,sessionWithTimeout和sessionById。
- 激活会话
- 生成会话密码
预处理
- 将请求交给Zookeeper的PreRequestProcessor处理器进行处理
- 创建请求事务头:服务端后续请求处理器都是基于该请求头来识别当前请求是否是事务请求。
- 创建请求事务体
- 注册与激活会话:此处进行会话注册与激活的目的是处理由非Leader服务器转发过来的会话创建请求。
事务处理
- 将请求交给ProposalRequestProcessor处理器
Sync流程
所谓Sync流程,其核心就是使用SyncRequestProcessor处理器记录事务日志的过程。
完成事务日志记录后,每个Follower服务器都会向Leader服务器发送ACK消息,表明自身完成了事务日志的记录。
Proposal流程
每一个事务请求都需要集群中过半机器投票认可才能被真正应用到Zookeeper的内存数据库中去,这个投票与统计的过程被称为“Proposal流程”。
- 发起投票
- 生成提议Proposal
- 广播协议:Leader服务器会以ZXID作为标识,将该提议放入投票箱outstandingProposals中,同时会将该协议广播给所有Follower服务器。
- 收集投票:Follower服务器在接收到Leader发来的这个提议后,会进入Sync流程来进行事务日志的记录,一旦日志记录完成后,就会发送ACk消息给Leader服务器。
- 将请求放入toBeApplied队列
- 广播COMMIT消息:Zookeeper确定一个提议可以被提交了,分别向Follower和Observer发送commit和inform消息。
Commit流程
- 将请求交付给CommitProcessor处理器
- 处理queuedRequest队列请求:CommitProcessor处理器会有一个单独的线程来处理从上一级处理器流转下来的请求。
- 标记nextPending:如果从queuedRequest队列取出的请求是一个事务请求,那么就需要进行集群中各服务器之间的投票处理,同时需要将nextPending标记为当前请求。
- 等待Proposal投票
- 投票通过:如果一个提议已经获得了过半机器的投票认可,Zookeeper会将该请求放入committedRequests队列中,同时唤醒Commit流程。
- 提交请求:对比nextPending和committedRequests队列中第一个请求是否一致,放入toProcess队列中,交付给下一请求处理器。
事务应用
- 交付给FinalRequestProcessor处理器
- 事务应用:将事务变更应用到内存数据库中。
- 将事务请求放入队列:commitProposal:commitProposal队列用来保存最近被提交的事务请求,以便集群间机器进行数据的快速同步。
会话响应
- 统计处理
- 创建响应ConnectResponse
- 序列化ConnectResponse
- I/O层发送响应给客户端
2. SetData请求
大体分为四个步骤,分别是请求的预处理,事务处理,事务应用和请求响应。
预处理
- I/O层接收来自客户端的请求
- 判断是否是客户端“会话创建”请求
- 将请求交给ZooKeeper的PrepRequestProcessor处理器进行处理
- 创建请求事务头
- 会话检查:客户端会话检查是指检查该会话是否有效,即是否已经超时。
- 反序列化请求,并创建ChangeRecord记录:面对客户端请求,ZooKeeper首先会将其进行反序列化并生成特定的SetDataRequest请求。 ZooKeeper 会生成一个ChangeRecord 记录,并放入outstandingChanges队列中。outstandingChanges队列中存放了当前ZooKeeper服务器正在进行处理的事务请求。
- ACL检查
- 数据版本检查
- 创建请求事务体SetDataTxn
- 保存事务操作到outstandingChanges队列中去
事务处理
对于事务请求,ZooKeeper服务端都会发起事务处理流程。无论对于会话创建请求还是SetData请求,或是其他事务请求,事务处理流程都是一致的,都是由ProposalRequestProcessor处埋器发起,通过Sync、Proposal 和Commit三个子流程相互协作完成的。
事务应用
- 交付给FinalRequestProcessor处理器
- 事务应用:ZooKeeper会将请求事务头和事务体直接交给内存数据库ZKDatabase进行事务应用,同时返回ProcessTxnResult对象,包含了数据节点内容更新后的stat。
- 将事务请求放入队列: commitProposal
请求响应
- 统计处理
- 创建响应体SetDataResponse:SetDataResponse是一个数据更新成功后的响应,主要包含了当前数据节点的最新状态stat。
- 创建响应头:响应头是每个请求响应的基本信息,方便客户端对响应进行快速的解析,包括当前响应对应的事务ZXID和请求处理是否成功的标识err。
- 序列化响应
- I/O层发送响应给客户端
3. 事务请求转发
- 在Follower或是Observer服务器中,第一个请求处理器分别是FollowerRequestProcessor和ObserverRequestProcessor,无论是哪个处理器,都会检查当前请求是否是事务请求。
- 如果是事务请求,那么就会将该客户端请求以REQUEST消息的形式转发给Leader 服务器。Leader服务器在接收到这个消息后,会解析出客户端的原始请求,然后提交到自己的请求处理链中开始进行事务请求的处理。
4. GetData请求
大体可以分为3个步骤,分别是请求的预处理,非事务请求和请求响应。
预处理
- I/O层接收来自客户端的请求
- 判断是否是客户端“ 会话创建”请求
- 将请求交给ZooKeeper的PrepRequestProcessor处理器进行处理
- 会话检查
由于GetData请求是非事务请求,因此省去了许多事务预处理逻辑,包括创建请求事务头、ChangeRecord和事务体等,以及对数据节点版本的检查。
非事务处理
- 反序列化GetDataRequest请求
- 获取数据节点
- ACL检查
- 获取数据内容和stat,注册Watcher
请求响应
- 创建响应体GetDataResponse
- 创建响应头
- 统计处理
- 序列化响应
- I/O层发送响应给客户端
2. 数据与存储
1. 内存数据
在Zookeeper中,数据存储分为两部分:内存数据存储于磁盘数据存储。
- DataTree是Zookeeper内存数据存储的核心,代表了内存中的一份完整的数据。
- DataNode是数据存储的最小单元。
- DataNode中存储的信息共有三类,数据内容data[],acl列表和节点状态stat。其中数据内容和节点状态就是在客户端上getdata获取到的那些数据。同时,DataNode中还记录了节点的父节点和子节点列表,并提供了对子节点列表的操作。
- 所有的DataNode存在一个ConcurrentHashMap中,对ZK中所有的Znode进行操作,其实底层就是对这个Map进行操作。其中Path是Key,DataNode是Value。
- 对于所有的临时节点,专门有一个Map去存它们,便于实时的访问和Session结束后的集中清理。
private final ConcurrentHashMap<String, DataNode> nodes = new ConcurrentHashMap<String, DataNode>();
private final Map<Long, HashSet<String>> ephemerals = new ConcurrentHashMap<Long, HashSet<String>>();
2. 事务日志
事务日志文件名后缀其实是一个事务ID:zxid,并且是写入该日志文件第一条事务记录的zxid
zxid本身由两部分组成,高32位代表当前Leader周期(epoch),低32位则是真正的操作序列号。
日志写入
- 确定是否有事务日志可写
- 确定事务日志是否需要扩容(预分配):文件的不断追加写入操作会触发底层磁盘I/O为文件开辟新的磁盘块,即磁盘Seek。
- 事务序列化:事务序列化包括对事务头和事务体的序列化。最终生成一个字节数组。
- 生成Checksum:根据字节数组生成Checksum。
- 写入日志文件流:将序列化后的事务头,事务体及Checksum值写入到文件流中去。
- 事务日志刷入磁盘:从streamsToFlush中提取出文件流,强制刷入磁盘文件中去。
public synchronized boolean append(TxnHeader hdr, Record txn)
throws IOException {
// 根据事务id来判断目前最大的zxid,为了判断是否是和上一个可写的事务日志有关联
if (hdr.getZxid() <= lastZxidSeen) {
LOG.warn("Current zxid " + hdr.getZxid() + " is <= " + lastZxidSeen + " for “ + hdr.getType());
} else {
lastZxidSeen = hdr.getZxid();
}
// logStream即日志流为空,用来存序列化数据
if (logStream == null) {
// 根据zxid创建新的文件
logFileWrite = new File(logDir, Util.makeLogName(hdr.getZxid()));
// 两个流
fos = new FileOutputStream(logFileWrite);
logStream = new BufferedOutputStream(fos);
oa = BinaryOutputArchive.getArchive(logStream);
FileHeader fhdr = new FileHeader(TXNLOG_MAGIC, VERSION, dbId);
// 序列化
fhdr.serialize(oa, "fileheader");
// 提取文件流,刷新到磁盘
logStream.flush();
// 当前通道的大小
currentSize = fos.getChannel().position();
streamsToFlush.add(fos);
}
// 这一步判断剩余空间不足4k时填充文件至64M,为了效率
// 文件的不断追加写入操作会触发底层磁盘I/O为文件开辟新的磁盘块即磁盘seek
// 避免随着每次事务的写入过程中文件大小增长带来的seek开销,直至创建新的事务日志
currentSize = padFile(fos.getChannel());
// 把事务头和事务体序列化
byte[] buf = Util.marshallTxnEntry(hdr, txn);
if (buf == null || buf.length == 0) {}
// 生成校验值,用了Adler32算法
Checksum crc = makeChecksumAlgorithm();
crc.update(buf, 0, buf.length);
// 写入buffer流中
oa.writeLong(crc.getValue(), "txnEntryCRC");
// 将序列化的事务记录写入OutputArchive
Util.writeTxnBytes(oa, buf);
// 但是这时候还没有写入文件!!!只在buffer流中。真正写入文件是在commit方法中
return true;
}
日志截断
public boolean truncate(long zxid) throws IOException {
FileTxnIterator itr = null;
try {
// 获取迭代器
itr = new FileTxnIterator(this.logDir, zxid);
PositionInputStream input = itr.inputStream;
// 从当前位置开始清空
long pos = input.getPosition();
RandomAccessFile raf = new RandomAccessFile(itr.logFile, "rw");
raf.setLength(pos);
raf.close();
// 存在下一个log文件
while (itr.goToNextLog()) {
// 删除
if (!itr.logFile.delete()) {
LOG.warn("Unable to truncate {}", itr.logFile);
}
}
} finally {
// 关闭迭代器
close(itr);
}
return true;
}
3. snapshot-数据快照
数据快照用来记录Zookeeper服务器上某一时刻的全量内存数据内容,并将其写入到指定的磁盘文件中。
快照数据文件也是使用zxid的十六进制表示来作为文件名后缀,该后缀标识了本次数据快照开始时刻的服务器最新zxid。
将内存数据库写入快照数据文件中其实是一个序列化过程。
Zookeeper会在进行若干次事务日志记录之后,将内存数据库的全量数据Dump到本地文件中,这个过程就是数据快照。
数据快照
- 确定是否需要数据快照:每进行一次事务日志记录之后,Zookeeper都会检测当前是否需要进行数据快照。需要尽量避免Zookeeper集群中的所有机器在同一时刻进行数据快照,采取“过半随机”策略。
- 切换事务日志文件
- 创建数据快照异步线程:不影响Zookeeper主流程。
- 获取全量数据和会话信息:从ZKDatabase中获取到DataTree和会话信息。
- 生成快照数据文件名
- 数据序列化:首先序列化文件头信息,然后再对会话信息和DataTree分别进行序列化,同时生成一个Checksum。
public synchronized void serialize(DataTree dt, Map<Long, Integer> sessions, File snapShot)
throws IOException {
if (!close) {
// 输出流
OutputStream sessOS = new BufferedOutputStream(new FileOutputStream(snapShot));
CheckedOutputStream crcOut = new CheckedOutputStream(sessOS, new Adler32());
//CheckedOutputStream cout = new CheckedOutputStream()
OutputArchive oa = BinaryOutputArchive.getArchive(crcOut);
// 新生文件头
FileHeader header = new FileHeader(SNAP_MAGIC, VERSION, dbId);
// 序列化dt、sessions、header
serialize(dt, sessions, oa, header);
// 获取验证的值
long val = crcOut.getChecksum().getValue();
// 写入值
oa.writeLong(val, "val");
// 写入"/"
oa.writeString("/", "path");
// 强制刷新
sessOS.flush();
crcOut.close();
sessOS.close();
}
}
4. 初始化
数据的初始化工作,其实就是从磁盘中加载数据的过程,主要包括了从快照文件中加载快照数据和根据事务日志记录进行数据订正两个过程。
- 初始化FileTxnSnapLog:FileTxnSnapLog是Zookeeper事务日志和快照数据访问层,用于衔接上层业务与底层数据存储。
- 初始化ZKDatabase:在初识化过程中,首先会创建一个初始化的DataTree。
- 创建PlayBackListener监听器:FileTxnSnapLog包含了PlayBackListener内部类,用来接收事务应用过程中的回调,在Zookeeper数据恢复后期,会有事务修正过程,此过程会回调PlayBackListener来进行对应的数据修正。
- 处理快照文件
- 获取最新的100个快照文件
- 解析快照文件:对快照进行反序列化,生成DataTree对象和sessionWithTimeouts集合,同时校验确定快照文件的正确性,虽然获取100个文件,但在逐一解析过程中,如果正确性校验通过的话,那么通常只会解析最新的那个快照文件。
- 获取最新的ZXID:根据快照名解析最新的zxid,代表了Zookeeper开始进行数据快照的时刻。
- 处理事务日志
- 获取所有zxid_for_snap之后提交的事务
- 事务应用:每当有一个事务被应用到内存数据库去后,Zookeeper同时会调用PlayBackListener监听器,将这一事务操作记录转换成Proposal,并保存到ZKDatabase.committedLod中,以便Follower进行快速同步。
- 获取最新ZXID
- 检验epoch
public long restore(DataTree dt, Map<Long, Integer> sessions,
PlayBackListener listener) throws IOException {
// 根据snap文件反序列化dt和sessions
snapLog.deserialize(dt, sessions);
return fastForwardFromEdits(dt, sessions, listener);
}
public long fastForwardFromEdits(DataTree dt, Map<Long, Integer> sessions, PlayBackListener listener) throws IOException {
FileTxnLog txnLog = new FileTxnLog(dataDir);
// 获取比最后处理的zxid+1大的log文件的迭代器
TxnIterator itr = txnLog.read(dt.lastProcessedZxid + 1);
// 最大的zxid
long highestZxid = dt.lastProcessedZxid;
TxnHeader hdr;
try {
while (true) {
// itr在read函数调用后就已经指向第一个合法的事务
// 获取事务头
hdr = itr.getHeader();
// 事务头为空
if (hdr == null) {
//empty logs
// 表示日志文件为空
return dt.lastProcessedZxid;
}
// 事务头的zxid小于snapshot中的最大zxid并且其不为0,则会报错
if (hdr.getZxid() < highestZxid && highestZxid != 0) {
LOG.error("{}(higestZxid) > {}(next log) for type {}",
new Object[]{highestZxid, hdr.getZxid(),
hdr.getType()});
} else { // 重新赋值highestZxid
highestZxid = hdr.getZxid();
}
try {
// 在datatree上处理事务
processTransaction(hdr, dt, sessions, itr.getTxn());
} catch (KeeperException.NoNodeException e) {
throw new IOException("Failed to process transaction type: " +
hdr.getType() + " error: " + e.getMessage(), e);
}
// 每处理完一个事务都会进行回调
listener.onTxnLoaded(hdr, itr.getTxn());
if (!itr.next())
break;
}
} finally {
if (itr != null) {
itr.close();
}
}
// 返回最高的zxid
return highestZxid;
}
5. 数据同步
当Learner服务器向Leader完成注册后,就进入数据同步环节。
直接差异化同步(DIFF同步)
场景:peerLastZxid介于minCommittedLog和maxCommittedLog之间
全量同步(SNAP同步)
场景1:peerLastZxid小于minCommittedLog
场景2:Leader服务器上没有提议缓存队列
仅回滚同步(TRUNC同步)
当Leader服务器发现某个Learner包含了一条自己没有的事务记录,那么久需要让该Learner进行事务回滚。
最后
大家可以关注我的微信公众号一起学习进步。