前言
本文基于zk3.5.8,分析zk对于WAL(Write Ahead Log)预写日志的实现。
其实各大中间件都有对于WAL的实现,比如mysql的redolog、es的translog等等。
这些中间件系统都较为庞大,而zk相对来说更加小巧,核心逻辑都在SyncRequestProcessor和FinalRequestProcessor中,对学习WAL更为友好。
WAL
对于一个事务,会先经过SyncRequestProcessor,将事务头和事务数据写事务日志,最终在FinalRequestProcessor应用至内存。
这就是ZK对于WAL的实现,先顺序写事务日志,然后再应用到内存,比随机写数据文件(对于zk就是snapshot)快。
在数据恢复过程中,zk先加载快照,接着通过事务日志回放快照时间点之后的事务,最终内存数据恢复。
事务日志

FileTxnLog是事务日志的实现,这个概念就和mysql innodb redolog一样,写数据之前,先写事务日志。
在FileTxnLog中有四个重要的成员变量:
1)FileOutputStream fos:对应一个日志文件
2)BufferedOutputStream logStream:包装fos,减少系统调用
3)OutputArchive oa:包装logStream,封装zk自己的序列化方式
4)LinkedList

写事务日志逻辑都在SyncRequestProcessor中,如果不考虑写请求频繁,减少write文件系统次数,伪代码如下:
append(request);
if (random) {
rollLog();
snapShot();
}
commit(request);
append
FileTxLog#append:将事务头和事务数据写入应用内存buffer,即BufferedOutputStream。
对比mysql,是不是和redo log buffer很像?


需要注意的是,BufferedOutputStream并不能在write调用时,完全避免操作系统的方法调用,底层默认只有一个8k缓冲区。
public class BufferedOutputStream extends FilterOutputStream {
protected byte buf[];
protected int count;
public BufferedOutputStream(OutputStream out) {
this(out, 8192);
}
public BufferedOutputStream(OutputStream out, int size) {
super(out);
if (size <= 0) {
throw new IllegalArgumentException("Buffer size <= 0");
}
buf = new byte[size];
}
}
当write时数组已经满了,则会调用包装的fos的write方法,实际调用操作系统write。
private void flushBuffer() throws IOException {
if (count > 0) {
out.write(buf, 0, count); // fos
count = 0;
}
}
public synchronized void write(int b) throws IOException {
if (count >= buf.length) {
flushBuffer();
}
buf[count++] = (byte)b;
}
rollLog
SyncRequestProcessor#run:在append完成后,有概率执行rollLog,rollLog完毕后执行创建快照。

FileTxnLog#rollLog:滚动事务日志
实际上是把当前的内存buffer刷到fos,write到文件系统,但是此时还不保证刷盘。
logStream被置空后,下次append就会创建新的事务日志文件继续写。

commit
在第一章中介绍了SyncRequestProcessor的实现,如果写请求频繁时,SyncRequestProcessor会优先append到应用内存,避免频繁系统调用,最终将批量写请求统一flush到磁盘。
FileTxLog#commit:提交事务日志
将内存buffer(logStream.flush)写入文件系统(FileOutputStream),
默认情况下forceSync=true,将所有FileOutputStream都强制刷盘(channel.force),此时事务日志真正落盘。

通过设置zookeeper.forceSync=no,让pagecache由操作系统自动刷盘,不做强制刷盘,一定程度上可以提升zk的写性能,但是掉电会丢数据。
对比mysql的redo-log刷盘策略:
Innodb_flush_log_at_trx_commit=1,zookeeper.forceSync=yes,每次事务提交都fsync,
Innodb_flush_log_at_trx_commit=2,zookeeper.forceSync=no,每次事务都只写pagecache。
快照文件
是什么

每个snapshot文件,对应一个时间点的完整的zk数据。
snapshot里包含一个时间点的完整的DataTree和Session数据。
FileSnap#serialize:

DataTree包含了所有节点的数据:

创建快照的场景
快照将在几个场景下创建:
启动阶段
启动阶段需要加载磁盘数据到内存,依赖最新snapshot和事务日志。
这里会针对恢复完成的内存数据进行一次快照。
ZooKeeperServer#loadData:

Follower恢复阶段
Follower恢复阶段,数据同步完成后,收到NEWLEADER会创建快照。
Learner#syncWithLeader:

写事务日志
SyncRequestProcessor在处理每个请求时,都有概率创建快照,同一时间只会产生一个线程创建快照。

数据恢复
恢复DataTree需要同时结合快照和事务日志。
FileTxnSnapLog#restore:数据恢复两步走
1)snapshot恢复
2)事务日志恢复

snapshot恢复
FileSnap#desrialize:从snapshot中恢复数据
findNValidSnapshots从数据目录下找到snapshot开头的快照文件,按照文件名中的事务id,从大到小排列。
循环读snapshot,找到一个校验和通过的snapshot恢复数据,如果找不到,则抛出异常。

事务日志恢复
因为snapshot未必有完整的数据,剩下的数据需要通过回放事务的方式恢复。
FileTxnSnapLog#fastForwardFromEdits:从事务日志恢复剩余的数据。
这里通过上面snapshot恢复的lastProcessedZxid+1,构造了一个TxnIterator。
TxnIterator把查询事务日志(多个文件)封装为一个迭代器,细节不看了。

通过DataTree#processTxn重放事务,完成数据恢复。

总结
本文分析了zk对于WAL的实现。
原子广播阶段,zk对于一个事务,先写事务日志(默认同步刷盘),然后写内存,最终响应客户端。
崩溃恢复阶段,zk先获取最新的snapshot快照,快照包含某一时刻所有节点的数据,这一时刻(zxid)之后的数据,都需要通过事务日志回放事务来恢复。
最后以一个问题结束。
对于WAL,如果写事务日志成功,但是写内存之前崩溃,恢复阶段数据还在吗?
这个其实在mysql、es都有类似的疑问。
针对zk,如果客户端的写请求,处理到一半zk崩了,那肯定收到错误响应。
但是实际上这个写请求也可能在恢复阶段被正常恢复了,这取决于崩溃时间点和事务日志的配置。
假设写事务日志是T1,写内存是T2。
如果在T1之前进程崩溃,恢复之后数据肯定没了,事务日志都没,内存就没,内存没,快照肯定没。
只要在T1之后进程崩溃,恢复之后数据就还在,虽然客户端写的时候报错了,因为事务日志可以在快照之后恢复数据。
(默认同步刷盘,如果zookeeper.forceSync=no,机器掉电就可能没了,取决于os刷pagecache的频率)
原文始发于微信公众号(程序猿阿越):ZooKeeper源码(三)WAL预写日志
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/214581.html