前言
在上一篇的文章中,将 LevelDB 的架构做了一个简单的介绍。分析了需要的各个模块,后文将针对各个模块做一个更加详细的介绍。在介绍的过程中,希望能够了解到为什么这么做。
LOG
作用
Log 本身就是一个 WAL 日志,将每次写入的改变数据的操作首先持久化到文件,因为数据是顺序写入的所以写入性能高。又因为是每次首先都记录在 WAL 日志中然后进行具体的操作,所以根据 WAL 日志能够在系统意外崩溃的情况下恢复到崩溃前的状态,不会出现客户端已经返回成功,但是数据丢失的情况。
实现
具体实现位置
Log 涉及到的主要就是读写. 写操作就是 WAL 日志,顺序写入操作到磁盘,主要实现在:
- db/log_writer.cc
- db/log_writer.h
读操作就是在系统重启后从 WAL 日志恢复,也是顺序读取。和 Writer 感觉就是队列一个,一个队头读,一个队尾写。主要实现在:
- db/log_reader.cc
- db/log_reader.h
在写的过程中还涉及到日志的序列化和反序列化。这部分的实现是在:
- db/log_format.h
当然不同的操作系统对底层调用如文件读写是不一样的,LevelDB 使用了一个env 来统一上层操作,然后不同环境在编译器自动实现。具体位置在:
- util/env.cc
在env 中,包含了多个类。其中和文件相关的类有:
- SequentialFile 从一个文件顺序读
- RandomAccessFile 随机读取某个文件
- WritableFile 顺序写的文件,注意的是,在env中说明了,这个类需要提供一个buffer,可以让小的fragments能够合并一起刷入磁盘
上面提到的读写和序列化的三个都属于 log的namespce。
namespace
是一种用于组织和管理命名空间的机制。命名空间是用来避免名称冲突(名称重复)的一种方式,尤其在大型项目中非常有用,以确保不同部分的代码可以使用相同的名称而不产生冲突。而且namespace 是可以嵌套的。个人将它类比为java的package
数据封装
为了减少磁盘IO,LevelDB每次读取文件都会读取4kb的数据,具体实现后文会说。为了让一次性读取的数据读取到当前刚好能处理的数据,所以写入的过程中也针对4kb做了操作。这个4Kb大小的数据在LevelDB中称之为Block,写入的数据或者读取的数据被称为Record,但是并不是每次写入的数据都刚好等于4Kb,所以针对这种情况,又将存储在Block中的数据切割成Fagement。组织如下图所示:
上图为logfile的里面数据的组织结果,一次写入称之为Record,一个Record会被切分成一个或多个fragement中分布在一个或者多个block中,每次读取是一个block,但是每次写入只是写入一个fragement。
写操作
Writer
LevelDB会为每次写入封装一个Writer 对象,这个对象定义在db/log_writer.h
arduino
class Writer {
public:
// Create a writer that will append data to "*dest".
// "*dest" must be initially empty.
// "*dest" must remain live while this Writer is in use.
explicit Writer(WritableFile* dest);
// Create a writer that will append data to "*dest".
// "*dest" must have initial length "dest_length".
// "*dest" must remain live while this Writer is in use.
Writer(WritableFile* dest, uint64_t dest_length);
Writer(const Writer&) = delete;
Writer& operator=(const Writer&) = delete;
~Writer();
Status AddRecord(const Slice& slice);
private:
Status EmitPhysicalRecord(RecordType type, const char* ptr, size_t length);
WritableFile* dest_;
int block_offset_; // Current offset in block
// crc32c values for all supported record types. These are
// pre-computed to reduce the overhead of computing the crc of the
// record type stored in the header.
uint32_t type_crc_[kMaxRecordType + 1];
};
公有域:
- AddRecord 方法,用于外部写入Slice
私有域:
- EmitPhysicalRecord 用于写入磁盘
- dest_ env中提供的一个写文件的封装,可以理解位一个已经打开的可以写入的文件
- block_offset_ 当前writer写入的block位置
- type_crc_ ,这个是一个数组,里面存储的是当前的type对应的crc,因为type 是一个常量,不需要每次都计算。
AddRecord
AddRecord本身的实现主要就是对当前写入Record做切割成Fragement,具体代码如下:
ini
Status Writer::AddRecord(const Slice& slice) {
const char* ptr = slice.data();
size_t left = slice.size();
// Fragment the record if necessary and emit it. Note that if slice
// is empty, we still want to iterate once to emit a single
// zero-length record
Status s;
bool begin = true;
do {
//当前的block中的剩下的值
const int leftover = kBlockSize - block_offset_;
assert(leftover >= 0);
// 如果小于7 ,则全部填满为0
if (leftover < kHeaderSize) {
// Switch to a new block
if (leftover > 0) {
// Fill the trailer (literal below relies on kHeaderSize being 7)
static_assert(kHeaderSize == 7, "");
dest_->Append(Slice("\x00\x00\x00\x00\x00\x00", leftover));
}
block_offset_ = 0;
}
// Invariant: we never leave < kHeaderSize bytes in a block.
assert(kBlockSize - block_offset_ - kHeaderSize >= 0);
const size_t avail = kBlockSize - block_offset_ - kHeaderSize;
// 一个block中的每一个fragment的大小,left为本次写入过程中待写入的数量,avail为可以写入的长度
const size_t fragment_length = (left < avail) ? left : avail;
RecordType type;
const bool end = (left == fragment_length); // 如果当前待写入的数据量小于block可以使用的,则说明本次可以作为一个完整的写入
if (begin && end) {
type = kFullType;
} else if (begin) { // 如果第一次待写入的数据库大于可以使用的,则需要进行切段,并且标记
type = kFirstType;
} else if (end) { // 再循环中,如果前面的写入都完成了,那么最后可能是写入一个完整的数据,并且将它标记为最后的fragment
type = kLastType;
} else { // 在循环中,写入第一个后,后面仍然不足够,则需要进行切分为多个,既然不是开始也不是最后,则是处于中间的数据量
type = kMiddleType;
}
s = EmitPhysicalRecord(type, ptr, fragment_length);
ptr += fragment_length;
left -= fragment_length;
begin = false;
} while (s.ok() && left > 0);
return s;
}
上面代码中的kBlockSize 初始值在util/log_format.h
中的kBlockSize,大小是32768字节也就是32kb。block_offset_则是writer 对象中写入成功后会更新的值。
实现的流程核心分为以下判断:
-
当前block 中剩下的值是否不能写入一个header即7个字节,如果小于则直接填充0,所以给实际数据写入的值为avail,即等于 整个blog剩下的值减去header的7个字节
-
如果当前的block的avail大小大于需要写入的数据,则当前fragement的长度就等于需要写入的长度,也就是一个fullfragement,否则只能写入剩下可以写的大小
-
判断当前的fragement的类型是通过2个参数确定的。
- begin 在第一次进入方法时候为true
- end 如果当前剩下的可写长度比fragement的长度长,则end为true,否则为false
- 如果end 和begin都为true,则是一个fragement,如果两者中只有一个为true,则要么是最后一个,要么是第一个,两个都为false,则说明是kMiddleType。
确认好type后,也就确认了当前fragement,也就是可以进行数据的持久化了,即调用了EmitPhysicalRecord方法:
EmitPhysicalRecord
EmitPhysicalRecord 方法具体实现如下:
scss
Status Writer::EmitPhysicalRecord(RecordType t, const char* ptr,
size_t length) {
assert(length <= 0xffff); // Must fit in two bytes
assert(block_offset_ + kHeaderSize + length <= kBlockSize);
// Format the header
char buf[kHeaderSize];
buf[4] = static_cast<char>(length & 0xff); // 将长度的低8位放到第四个位置
buf[5] = static_cast<char>(length >> 8); // char是一个字节,所以这里也是一个字节。可以看到是小端数组,低八位放在前面,然后最多两个字节表示带下,最多一次性写入16k的数据
buf[6] = static_cast<char>(t); // 当前fragment的类型放在6 这个位置
// Compute the crc of the record type and the payload.
uint32_t crc = crc32c::Extend(type_crc_[t], ptr, length);
crc = crc32c::Mask(crc); // Adjust for storage
EncodeFixed32(buf, crc);
// Write the header and the payload
Status s = dest_->Append(Slice(buf, kHeaderSize));
if (s.ok()) {
s = dest_->Append(Slice(ptr, length));
if (s.ok()) {
s = dest_->Flush();
}
}
block_offset_ += kHeaderSize + length;
return s;
}
首先是拼接头节点,这里不是从头到尾来做的,而是首先将长度和type放入,具体的数据结构可以看上面的图中的fragement 里面的头节点类容:
scss
buff[6] = char[6]{crc_low,crc_mid0,crc_mid2,crc_high,length_low,length_high,type}
和Varint类似,甚至前面4个crc就是使用的EncodeFixed32,固定长度的char表示32位整型。都是小端存储。
封装好header后,首先将header 的数据append到dest_ 中,成功后append 数据,append 成功后会调用flush,将本次的record刷入到磁盘上。
整个写流程就完成了,此时Log中已经包含了本次写入的Record。
读操作
前文提到,每次写入都会将Record写入到磁盘上作为WAL日志,WAL日志的读取只有一个地方会做,就是数据库重启后的恢复动作。但是数据库的恢复动作除了读取Record还涉及到很多其他的如版本等的操作。读操作的篇幅里都不会涉及,在后面了version 的时候会详细说下,所以本文仅仅涉及到读Record的操作。
Reader
和Writer 类型,LevelDB 会为每次读取都提供一个Reader的对象,实现位置在db/log_reader.h
中。
arduino
class Reader {
public:
// Interface for reporting errors.
class Reporter {
public:
virtual ~Reporter();
// 某些字节可能已经损坏,损坏的
virtual void Corruption(size_t bytes, const Status& status) = 0;
};
// reader 传入的参数是一个SequentialFile,也就是一个顺序读取的对象
Reader(SequentialFile* file, Reporter* reporter, bool checksum,
uint64_t initial_offset);
Reader(const Reader&) = delete;
Reader& operator=(const Reader&) = delete;
~Reader();
// 将当前Record的数据读取到record里面,读取成功则返回true,如果已经读取到本次输入的尾部,则返回false,并且将数据临时存储在scratch 中
// Read the next record into *record. Returns true if read
// successfully, false if we hit end of the input. May use
// "*scratch" as temporary storage. The contents filled in *record
// will only be valid until the next mutating operation on this
// reader or the next mutation to *scratch.
bool ReadRecord(Slice* record, std::string* scratch);
// Returns the physical offset of the last record returned by ReadRecord.
//
// Undefined before the first call to ReadRecord.
uint64_t LastRecordOffset();
private:
// 有删减
SequentialFile* const file_;
Reporter* const reporter_;
bool const checksum_;
char* const backing_store_;
Slice buffer_;
bool eof_; // Last Read() indicated EOF by returning < kBlockSize
// Offset of the last record returned by ReadRecord.
uint64_t last_record_offset_;
// Offset of the first location past the end of buffer_.
uint64_t end_of_buffer_offset_;
// Offset at which to start looking for the first record to return
uint64_t const initial_offset_;
};
上文没有贴完整的代码,私有域中的方法和对象我没有完全贴。因为Reader方法本身只是将Record从Log中读取出来,当然其他如MANIFEST的文件其实也是按照Record来存储的。但是整体上来说,都是从文件中将Record的日志恢复,然后按照类型插入到Memtable或者VersionSet中。
Log日志恢复主要是在RecoverLogFile 方法中位于db/db_impl.cc
中。这个方法比较长,下文挑一些核心的实现:
ini
while (reader.ReadRecord(&record, &scratch) && status.ok()) {
if (record.size() < 12) {
reporter.Corruption(record.size(),
Status::Corruption("log record too small"));
continue;
}
WriteBatchInternal::SetContents(&batch, record);
if (mem == nullptr) {
mem = new MemTable(internal_comparator_);
mem->Ref();
}
status = WriteBatchInternal::InsertInto(&batch, mem);
MaybeIgnoreError(&status);
if (!status.ok()) {
break;
}
const SequenceNumber last_seq = WriteBatchInternal::Sequence(&batch) +
WriteBatchInternal::Count(&batch) - 1;
if (last_seq > *max_sequence) {
*max_sequence = last_seq;
}
if (mem->ApproximateMemoryUsage() > options_.write_buffer_size) {
compactions++;
*save_manifest = true;
status = WriteLevel0Table(mem, edit, nullptr);
mem->Unref();
mem = nullptr;
if (!status.ok()) {
// Reflect errors immediately so that conditions like full
// file-systems cause the DB::Open() to fail.
break;
}
}
}
上面是从文件中读取record的实现,是一个循环读取的过程,上面的方法介绍里说过,reader.ReadRecord(&record, &scratch) 中的两个传入的参数分别为,如果是fullFragement,则将值放在record中,如果是first,mid 则放在scratch中,一直遇到end 后放到record中。
问题a:是否存在比如当前有两个日志文件(000001.log,000002.log),然后000001.log 中的末尾刚好是000002.log第一个fragement的header呢?
在持续读取过程中,会将Record的数据写入到memtable中,如果发现Memtable的值超过了4MB,则刷入level0层。
循环执行完后,当前的log日志已经全部弄到内存中了。如果当前的Options
中指定了使用原来的log文件,则无需将内存中的数据刷入磁盘,因为log文件在后续的写入中继续使用,则将当前恢复memTable复制给mem对象作为后续写入的memtable,log的回收可以走写入的流程过程中的日志文件回收策略,否则的话,仍然需要将当前的数据刷入level0。因为可能在新写入的操作中,该日志文件被删除,到时候没有刷盘则丢失数据了。
Log回收和截断
Log日志如果不做截断,数据量会持续堆积,越来越大。截断的时机是个比较重要的事件。看上面的Recover可以看出一些端倪。如果当前的memTable中的数据被刷入到了磁盘,成了level0,那么就说明可以回收当前对应的log文件了。
在db/db_impl.cc
中有一个CompactMemTable,该方法就是将imm的数据写入到level0,然后在数据写入到level0 的时候就将当前的log删除。那么是否可能存在误删除当前写入的数据呢?答案是不会的,因为合并的时候正在写的log文件已经变成了新的文件。还记得上面的问题a吗?这里得到了答案,就是不可能存在header的数据在一个文件,然后data的数据在另外一个文件的情况,只有可能出现文件刚好写完header系统就挂掉的情况。这种情况在leveldb中是作为异常处理的。
日志的切换是在db/db_impl.cc
中的MakeRoomForWrite,当当前的资源不住,主要i是mem的资源不足的时候,就新建一个log文件作为本次写入的文件,然后将原来的文件close,然后将当前mem修改为_mem。而且这个文件是递增命名的,所以根据名字就可以进行先后顺序排序,所以也不存在导致删除错误的情况,至于文件的组织后文在探讨Version的时候在讨论。
总结
本文将前面的整体架构中的Log模块做了介绍,还涉及到了部分Recover的情况。在LevelDB中,数据基本上都是按照类似的方式不断的append的,所以基本上都是Record的方式加解密。这部分会在后面的SSTable,Version等中在遇到。
个人觉得设计比较有美感的就是Log的截取和fragement的方式写入,阅读起来很顺畅。