ZooKeeper源码(三)WAL预写日志

2,374 阅读5分钟

前言

本文基于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 streamsToFlush:fos的集合,代表等待刷盘的日志文件

写事务日志逻辑都在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的频率)