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 中,包含了多个类。其中和文件相关的类有:

  1. SequentialFile 从一个文件顺序读
  2. RandomAccessFile 随机读取某个文件
  3. 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 对象中写入成功后会更新的值。

实现的流程核心分为以下判断:

  1. 当前block 中剩下的值是否不能写入一个header即7个字节,如果小于则直接填充0,所以给实际数据写入的值为avail,即等于 整个blog剩下的值减去header的7个字节

  2. 如果当前的block的avail大小大于需要写入的数据,则当前fragement的长度就等于需要写入的长度,也就是一个fullfragement,否则只能写入剩下可以写的大小

  3. 判断当前的fragement的类型是通过2个参数确定的。

    1. begin 在第一次进入方法时候为true
    2. end 如果当前剩下的可写长度比fragement的长度长,则end为true,否则为false
    3. 如果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的方式写入,阅读起来很顺畅。

相关推荐
NiNg_1_2341 小时前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端
Chrikk2 小时前
Go-性能调优实战案例
开发语言·后端·golang
幼儿园老大*2 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
canyuemanyue2 小时前
go语言连续监控事件并回调处理
开发语言·后端·golang
杜杜的man2 小时前
【go从零单排】go语言中的指针
开发语言·后端·golang
customer084 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
Yaml45 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
小码编匠6 小时前
一款 C# 编写的神经网络计算图框架
后端·神经网络·c#