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的方式写入,阅读起来很顺畅。

相关推荐
IT_陈寒9 小时前
Vite 5.0重磅升级:8个性能优化秘诀让你的构建速度飙升200%!🚀
前端·人工智能·后端
hui函数9 小时前
scrapy框架-day02
后端·爬虫·python·scrapy
Moshow郑锴9 小时前
SpringBootCodeGenerator使用JSqlParser解析DDL CREATE SQL 语句
spring boot·后端·sql
小沈同学呀15 小时前
创建一个Spring Boot Starter风格的Basic认证SDK
java·spring boot·后端
方圆想当图灵17 小时前
如何让百万 QPS 下的服务更高效?
分布式·后端
凤山老林18 小时前
SpringBoot 轻量级一站式日志可视化与JVM监控
jvm·spring boot·后端
凡梦千华18 小时前
Django时区感知
后端·python·django
Chan1618 小时前
JVM从入门到实战:从字节码组成、类生命周期到双亲委派及打破双亲委派机制
java·jvm·spring boot·后端·intellij-idea
烈风19 小时前
004 Rust控制台打印输出
开发语言·后端·rust
用户214118326360220 小时前
用 AI 一键搞定!中医药科普短视频制作升级版
后端