LevelDB之Log
前言
在上一篇的文章中,将 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
1 | class Writer { |
公有域:
- AddRecord 方法,用于外部写入 Slice
私有域:
- EmitPhysicalRecord 用于写入磁盘
- dest_ env 中提供的一个写文件的封装,可以理解位一个已经打开的可以写入的文件
- blockoffset 当前 writer 写入的 block 位置
- typecrc ,这个是一个数组,里面存储的是当前的 type 对应的 crc,因为 type 是一个常量,不需要每次都计算。
AddRecord
AddRecord 本身的实现主要就是对当前写入 Record 做切割成 Fragement,具体代码如下:
1 | Status Writer::AddRecord(const Slice& slice) { |
上面代码中的 kBlockSize 初始值在util/log_format.h
中的 kBlockSize,大小是 32768 字节也就是 32kb。blockoffset则是 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 方法具体实现如下:
1 | Status Writer::EmitPhysicalRecord(RecordType t, const char* ptr, |
首先是拼接头节点,这里不是从头到尾来做的,而是首先将长度和 type 放入,具体的数据结构可以看上面的图中的 fragement 里面的头节点类容:
1 | 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
中。
1 | class Reader { |
上文没有贴完整的代码,私有域中的方法和对象我没有完全贴。因为 Reader 方法本身只是将 Record 从 Log 中读取出来,当然其他如 MANIFEST 的文件其实也是按照 Record 来存储的。但是整体上来说,都是从文件中将 Record 的日志恢复,然后按照类型插入到 Memtable 或者 VersionSet 中。
Log 日志恢复主要是在 RecoverLogFile 方法中位于db/db_impl.cc
中。这个方法比较长,下文挑一些核心的实现:
1 | while (reader.ReadRecord(&record, &scratch) && status.ok()) { |
上面是从文件中读取 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 的方式写入,阅读起来很顺畅。