zookeeper原理篇-Zookeeper的数据存储与恢复原理
前言
经过前面的一些文章的学习和了解,我们对Zookeeper有了一定的理解,但是无论是节点持久化,还是启动流程中的数据恢复等,我们都没有详细的去了解内部的数据存储和恢复的机制,本篇文章就开始学习Zookeeper的数据存储相关。
内存存储
zookeeper刚开始的时候,我们就已经知道其结构就像一个内存数据库一样,按照树的结构,能把节点的路径、节点数据以及ACL和节点的数据存储,其核心就是依靠DataTree实现的所谓树型存储结构。而每一个DataTree内部包含了多个DataNode,每一个DataNode则是zookeeper中最小的存储单元。
DataTree存储了Zookeeper中所有节点的路径,所有节点的数据以及ACL信息,除此之外,具体的每个节点存储依赖DataNode,而管控所有的node节点使用的经典的ConcurrentHashMap键值对结构:
private final ConcurrentHashMap<String, DataNode> nodes = new ConcurrentHashMap<String, DataNode>();
每一个DataNode内部除了存储对应的数据内容、ACL列表和每个节点对应的状态以外,还会保存树的一些信息,例如节点的父节点引用,以及当前节点的子节点列表信息等,以此实现更方便的管理和实现树型结构。
在nodes这个Map中,存储了Zookeeper所有的数据结构,基本上所有的增删改等操作,都是操作map中对应path下的DataNode(key是path,value为DataNode),另外,在Zookeeper中,我们知道临时节点的表现和持久化节点不同,其生命周期和会话进行绑定,因此为了便于操作和清理,DataTree中会单独将临时节点保存起来:
private final MapcLong, HashSet<String» ephemerals =
new ConcurrentHashMap<Long, HashSet<String»();
事务日志
除了内存存储以外,我们知道事务操作的时候会有日志,而文件存储主要是依靠事务日志文件保存,在我们启动zookeeper的时候,往往会指定dataDir目录,这个目录是zookeeper中默认用来存储事务日志的目录,除此之外我们可以给事务日志单独分配目录存放,只需要指定dataLogDir属性即可
日志文件
在zookeeper运行了一段时间以后,我们查看日志目录下的文件,可以看到大概如下的列表:
1590645019475.png
而比较值得注意的是这些文件的大小,都是一样的67108880KB,这大小换算成MB刚好是64MB大小,除此之外,可以看到log文件的命名都是log.作为前缀,后面的名字都是十六进制的数字,那么这个是什么呢?其实这个是使用了一个ZXID作为后缀,而选择的则是当前日志中的第一条事务的ZXID,而ZXID我们前面也了解过,是由两个部分组合而成,高32位代表当前Leader的选举周期--epoch的大小,而低32位则是该周期内的操作序列号,因此我们可以根据事务日志的名称快速的解析读取出来对应的epoch信息和先后顺序。
我们随便选择一个日志文件打开,发现里面的内容无法阅读,都是序列化后的事务日志:
1590645472055.png
可以看到里面的内容只能隐约的看到一些节点的路径以外,其他的几乎分辨不出来了,而在zookeeper中提供了一个格式化日志的命令--org.apache.zookeeper.Server.LogFormatter,使用方式只需要在目录下输入:
Java LogFormatter 日志文件
我们随便找一个日志文件输入命令,看看格式化后的内容:
第一行日志:
ZooKeeper Transactional Log F ile with dbid 0 txnlog format version 2
可以看到这句日志是日志记录的开始,告诉我们日志的当前版本号是2,以及当前的dbid是0,接着我们看下一行日志:
01:07:41 session 0x144699552020000 cxid 0x0 zxid 0x300000002 createSession 30000
而第二行从左到右分别记录了事务的发生时间、当前事务的会话id、客户端序列号cxid、事务id--zxid以及当前触发事务的动作是创建操作,接着我们来看第三行日志的内容:
01:08:40 session 0x144699552020000 cxid 0x2 zxid 0x300000003 create
/test_log,#7631,v{s{31 ,s{/w orld,'anyone}}},F,2
这一行日志我们看到,不仅有和第二行记录一样的以外,还记录了节点的路径,节点的数据内容,这里需要注意的是这里记录的方式的#+值的ASSCII的码值,节点的ACL信息以及是否为临时节点,这里使用了F/T方式记录,F代表是临时节点,T为持久化节点,以及版本号,基本上一个事务大体上记录的内容就这么多,其他的日志大体上和这些类似,因此不再详细介绍
FileTxnLog
FileTxnLog负责维护事物日志相关的操作,包括事物日志的写入和读取以及数据恢复等。首先我们来看事物写入的方法:
public synchronized boolean append(TxnHeader hdr, Record txn);
从方法的定义可以看出来,如果要写入日志,需要传入两个参数,分别是事物头和事物消息体,而整个方法的大概过程如下:
1.当整个Zookeeper启动完成后第一次进行日志的写入或者是上一次日志刚好写满以后,都会处于一个与日志文件断开的状态。因此,在进行日志写入之前,Zookeeper会先判断FileTxnLog组件是否已经关联一个事物日志文件,如果没有关联的日志文件,那么就会使用该事物关联的ZXID作为后缀创建一个新的事物日志文件,同时会去创建事物日志头信息(其中包括magic,事物日志的版本号version和dbid),并且立即写入到这个事物日志文件中去,然后将文件流存入一个集合中--StreamsToFlush。
2.在客户端触发每一次的事物操作的时候,会进行一次空间大小检测操作,当发现事物日志的剩余空间不足4096字节(4KB)大小的时候,就会进行一次扩容操作,而每一次扩容(包括第一次分配大小)都是65536KB(64MB)大小,而这些扩容的内容,还没使用的情况下,会预先使用0进行占满,这里涉及到一个IO性能优化的地方,如果Zookeeper不预先分配空间大小,可能会导致事物日志在写入的过程中,频繁的触发Seek,开辟新的空间,导致写入IO性能缓慢。当然默认的预分配大小64MB,如果需要调节大小,可以设置系统参数:
zookeeper.preAllocSize来改变大小
3.在写入事物之前,会进行一次事物序列化,分别是对TxnHeader和Record的序列化,其中包括创建会话事物、节点创建事物、删除节点事物和更新节点事物等,序列化完成以后,为了保证事物写入的完整性和准确性,会根据序列化生成的字节数组计算一个Checksum,在Zookeeper中默认使用的是Adler32算法来计算Checksum值。
4.将序列化后的事物头、事物体消息以及checkSum的值一起写入到文件流中, 此时使用的是BufferedOutputStream,因此会等待缓存区填充满以后才会真正的写入日志文件中,当事物日志写入到BufferedOutputStream以后,因为文件流都存入了stramToFlush,因此我们会从中提取文件流,并且调用FileChannel.force(boolean metaData)方法进行强制刷盘操作,至此Zookeeper的一次事物日志操作写入完成。
注意:在Zookeeper运行过程中,由于会出现leader机器出现异常等情况,最后变成非leader机器,重新选举出来的leader发现非leader机器上记录的事物ID大于自身的,那么由于遵循前面文章说过的,Zookeeper要求所有的follower机器在Leader存在的过程中,必须和Leader保持一致,因此这个时候Leader就会发送一个TRUNC命令给这个follower机器,强制对这部分日志进行截断,follower机器在收到请求以后,会将这部分大于Leader事物ID的日志信息删除。
Snapshot
在Zookeeper中,除了事物日志以外,还有一个核心的数据存储组件--Snapshot(数据快照),与事物日志不同的是,数据快照用于记录某一时刻的zookeeper上的全量数据内容,并且存入磁盘文件中。和事物日志相同的一点是,数据快照也支持指定dataDir属性进行配置存储的目录,我们打开对应的存储目录,查看一下快照文件的格式,如下:
-rw-rw-r-- 1 admin admin 1258072 03-01 17:49 snapshot.2c021384ce
可以看到和事物日志很像的一点是,快照的数据文件命名格式也是使用ZXID的十六进制作为文件后缀,同样的,在数据恢复的阶段,会根据ZXID来确定和进行数据恢复。当然与事物日志不同的是,快照文件并没有预分配空间的机制,因此也可以认为快照文件中的数据都是当时全量数据的有效数据。
当我们打开一个快照文件以后,发现和事物日志差不多,里面的内容也是被序列化后的,当然,Zookeeper也提供了一个格式化工具 org .apache.zookeeper.server.SnapshotFormatter,使用的方式也和前面的事物日志格式化工具差不多,在快照所在的目录下,使用如下命令:
Java SnapshotFormatte 快照
这个时候我们再去读取内容,会发现,已经能成功看到每个节点的状态信息,虽然看不到具体的数据内容,但是已经对我们运维很有帮助了,大概信息如下:
CZxid » 0x00000000000000
ctiffle » Thu Jan 01 08:00:00 C S T 1970
mZxid - OxOOOOOOGOOQOOOO
mtime = Thu Jttxi 01 08:0D:0 © C S T 1972
pZxid » 0*00000300000003
cversion = 2
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x00000000000000
dataLength = 0
而在Zookeeper中,负责快照相关操作的类是FileSnap,包括处理快照的写入和读取等操作。我们知道,Zookeeper的每一次事物操作,都会写入到事物日志中,当然同时也会写入到内存数据库中,而在触发了多次事物写入日志的操作以后,就会触发一次快照的数据写入操作,而这个次数snapCount参数则是可以在zookeeper参数中进行配置,接下来我们来看看快照的大概写入过程:
1.每一次事物日志写入完毕以后,Zookeeper都会检测一次是否需要写入到快照中的操作,理论上达到snapCount次数以后的事物日志就要触发快照的demp操作,但是考虑整体性能,Zookeeper并不是每一次都会执行demp,而是选择使用了过半随机的原则,即:
logCount > (snapCount /2 + randRoll)
这里的logCount指的是当前记录的日志数量,snapCount指的是配置的多少次事物日志触发一次快照,randRoll则是1 - snapCount/2之间的一个随机数,如果我们配置的事物日志的数量为10000,那么则会在一半 + 随机值的次事物日志以后才开始写入快照。
2.当事物日志数量刚好达到半数随机值以后,Zookeeper会进行一次事物日志文件切换(即事物日志已经需要写入snapCount个事物日志),需要重新创建一个新的事物日志文件出来,这个时候为了保证性能稳定,会创建一个单独的线程用来处理demp快照的操作
3.而生成快照的过程则是将所有节点和会话信息保存到本地磁盘文件中,而文件的命名规则则是根据当前已经提交的最大ZXID来生成数据快照文件名。接下来会进行序列化操作,首先序列化文件头信息,这里包含了magic,事物日志的版本号version和dbid,然后再对会话信息和DataTree分别序列化,同样序列化完成后会生成一个CheckSum,一并写入到快照文件中,至此快照文件写入完成
数据初始化与数据同步
前面我们有学习过,Zookeeper的启动流程,其中有两个步骤,一个是初始化启动的时候,会去磁盘中加载数据,另外一个则是集群启动后,会有follower机器与leader机器进行数据同步的过程,接下来我们来看看这两个过程是如何进行数据之间的恢复与同步的。
初始化数据
1.在Zookeeper中,进行数据恢复或者数据同步使用的是FileTxnSnapLog类,这个类属于衔接业务与下层数据存储的类,其中包含类事物日志的操作,以及快照操作,因此FileTxnSnapLog的初始化就是事物日志操作类--FileTxnSnapLog和快照管理类--FileSnap的初始化过程。
2.在FileTxnSnapLog类初始化完成后,会将其交给ZKDatabase,完成初始化操作,包括创建初始化的一些节点,例如/,/zookeeper和/zookeeper/quota节点,除此之外,还会创建 一个保存所有会话超时时间的记录器--sessionsWithTimeouts,初始化完成后,会去创建一个PlayBackListener监听器,这个监听器用来接受事务应用过程中的回调,会在数据恢复的过程中,进行数据修正操作。
3.完成内存数据库的初始化以后,就要读取快照文件,进行全量数据恢复了,这个时候会默认读取最多一百个最新的快照文件,然后从ZXID最大的快照文件开始,进行逐个解析,进行反序列化操作,然后生成DataTree和sessionWithTimeout,并且根据checkSum校验完整性,如果校验失败,会放弃这个快照文件,选择第二个ZXID最大的快照文件,继续解析,依次类推,如果读取到的最多一百个快照文件都失败了,那么就直接启动失败,如果有校验成功的,则使用该文件进行全量恢复。
4.当快照文件恢复全量数据完成后,此时已经创建了DataTree实例和sessionsWithTimeOuts集合了,这个时候我们也知道快照文件对应的最新的ZXID,而这个时候我们就需要找到比snap中的ZXID大的事物日志,进行增量恢复和数据修正,每一条事务日志被恢复后,就会应用到快照恢复出来的DataTree和sessionsWithTimeOuts中,并且会回调PlayBackListener 监听器,将这一
事务操作记录转换成 Proposal , 并保存到ZkDatabase.committedLog 中,以便 Follower 进行快速同步操作。
5.当事务日志恢复完毕后,数据的初始化过程基本结束,这个时候再去获取一个ZXID,用来作为上次服务器正常阶段提交的最大事务ID,这个时候根据ZXID解析出来上一次leader的周期-epochOfZxid,同时在磁盘的currentEpoch和acceptedEpoch文件读取上次记录的epoch进行校验,至此数据初始化流程完成
数据同步
当zookeeper初始化完成后,集群选举后,Learner服务器会向Leader完成注册以后,就会触发数据同步环节。在前面的文章中,我们学习过,注册Learner的最后阶段,会发送给Leader服务器一个ACKEPOCH数据包,Leader会根据发来的数据包解析出来Learner机器当前currentEpoch和lastZxid,接着Leader服务器会从Zookeeper内存数据库中提取出事务对应的提议缓存队列:proposals,同时完成对以下三个ZXID的初始化,分别是peerLastZxid(Learner服务器最后处理的ZXID),minCommittedLog(提议缓存队列CommittedLog中的最小的ZXID),maxCommittedLog(提议缓存队列CommittedLog中的最大的ZXID)。而在Zookeeper中,数据同步有四类,分别是DIFF差异化同步、回滚后差异化同步、仅回滚同步以及全量SNAP同步。
全量同步
全量SNAP同步发生在以下两个场景,一个是peerLastZxid的值小于minCommittedLog,另外一个是Leader服务器上不存在提议缓存队列的情况下,此时都无法根据提议缓存队列进行同步,只能选择全量同步。
仅回滚同步/回滚后差异化同步
这两种同步的方式都是针对zookeeper运行过程中Leader故障后重新选举恢复的场景,唯一的区别在于,仅回滚同步针对的是Leader机器在故障前刚好把事物执行存储完成,但是却没来得及发送给其他follow机器的场景,这个时候Leader机器再次恢复以后,身份不再是Leader,但是却存在大于Leader机器的事物日志,这个时候就需要进行事务回滚操作。而回滚后差异同步则是针对的Leader故障后,原来的Leader机器保存了事务日志,当机器重新注册到集群中,恢复服务以后,后续选出来的Leader机器和其他的follow机器此时已经进行了多次事务同步,这个时候就需要先把原来的多余的那条事务日志删除后,再次进行差异化同步操作。
DIFF差异化同步
一般进行差异化同步的场景是zookeeper使用过程中最常见的,这种同步的场景往往是发生在peerLastZxid介于minCommittedLog 和 maxCommittedLog之间。
在执行同步的过程中,首先Leader服务器会发送一个DIFF指令给所有的需要同步的Learner服务器,用于通知差异化数据,而差异化数据则是通过PROPOSAL和COMMIT数据包完成,Leader在发送完差异化数据以后,就会把Learner加入到forwardingFollower或者observingLearners队列中,并且这个时候Leader会发送一个NEWLEADER指令,用于通知对应的Learner,已经将所有的缓存队列中的Proposal都同步过去了。同样的Learner服务器在收到了DIFF指令后,开启DIFF同步阶段,然后将收到的数据包,依次的应用到内存数据库中,最后等到收到Leader发送的NEWLEADER指令后,代表Leader已经全部发送完毕,此时Learner会反馈一个ACK消息,Leader接受到ACK消息后,代表此时Learner服务器已经接受完所有的同步数据,此时会继续等待其他的Learner的ACK响应,直到集群中过半的Learner服务器都响应了为止。
至此,Zookeeper已经认为完成了数据同步操作,直接开放集群,提供对外的服务。