ninja_ken  

什么是数据整合?

Compaction是将数据库的键值对从低Level的SST文件,逐渐合并到高Level SST文件中的过程(这里假定读者已经对LevelDb的设计理念已经有所了解,所以不会对一些基本概念进行详细解释)。数据整合的最终结果或者说趋势,是把所有的数据都整合到最高Level的SST上。为什么需要数据整合呢?首先是因为每一级SST的数据容量存在约束,不能无限制地保存数据。当某一级别SST上的数据增大到一定程度时,就需要向更高层、容量更大的Level上合并。其次,不同Level之间SST的键值对数据内容可能存在重合(同一个key既存在于Level A也存在于Level B上),导致不必要的硬盘空间浪费。一般而言,相对老旧的数据处于较高的Level,而新数据处于较低的Level。旧数据中可能有些key已经被新写入所覆盖,或者删除,而这时的旧数据仍然占据着硬盘。当所有的数据都整合到最高Level后,就基本不存在重复、无效的数据了。对于LevelDB的LSM树(Log-Structured Merge-tree)结构,数据整合有两种迥然不同的场景,第一个场景是从MemTable到Level 0~2(config::kMaxMemCompactLevel)的SST文件,第二个场景是从Level L(0 < L < 7config::kNumLevels)到Level L + 1。对于第二种场景,存在两种触发方式,第一种是Level L的总数据size触发,另一种是seek次数触发(当一个SST文件读取次数足够多,则将它放入更高Level的SST)。基本情况说清楚后,下面我们来分别讨论各个场景下相应的整合过程。

从MemTable到SST Level 0~2

每次数据库写入时,DB都会检查当前MemTable的大小是否达到设定值(可参考LevelDB源码解析(4)——数据库KV对写入。如达到,则通过DBImpl::BackgroundCompaction函数调用进入到DBImpl::CompactMemTable,它里面有两个主要的处理函数

  1. WriteLevel0Table(imm_, &edit, base)
  • 调用BuildTable函数,将immutable Memtable中的内容改写成一个SST数据库文件。LevelDB的SST文件格式在源码的doc/table_format.md文档里有非常清楚的说明,如果感兴趣可以参考下
  • 调用Version::PickLevelForMemTableOutput来确定将上一步生成的SST文件写入哪一个Level。根据源码中的注释

// Maximum level to which a new compacted memtable is pushed if it
// does not create overlap. We try to push to level 2 to avoid the
// relatively expensive level 0=>1 compactions and to avoid some
// expensive manifest file operations. We do not push all the way to
// the largest level since that can generate a lot of wasted disk
// space if the same key space is being repeatedly overwritten.
static const int kMaxMemCompactLevel = 2;(kMaxMemCompactLevel)

从Level 0 --> Level 1的Compaction比其他Level相对开销更大(从Level L到Level L+1),所以在某些情况下把MemTable直接写入Level 1或者更高Level,可以减少后续Compaction开销。不过关于第二点理由,to avoid some expensive manifest file operations,我的理解是假如把MemTable写入Level 0,那么数据从MemTable -> Level 0 -> Level 1 -> Level 2总共会触发三次Manifest文件变动(Manifest是LevelDB的一种特殊文件,在源码中又叫作descriptor file,它跟踪和记录当前数据库的文件变动情况,比如新增了哪些文件,删除了哪些文件、上一次写入的Sequence等等),而直接写入Level 2只会涉及一次Manifest文件变动,所以更高效。

  1. versions_->LogAndApply(&edit, &mutex_);
    WriteLevel0Table(imm_, &edit, base)函数输出存放在edit变量中,它是VersionEdit类型的实例。这个类的作用是负责跟踪和记录当前数据库的文件变动情况(与Manifest的作用相同?正是如此。读者或许可以猜到,Manifest文件中的内容正是由VersionEdit序列化后得到)。LogAndApply这个函数记录下来edit的内容,并写入Manifest文件。LevelDB中版本功能由下面几个类型共同实现,VersionSet``VersionVersionEdit。其中,VersionSet是多个Version的组合(首尾相连链表的形式),每次文件变动的内容放在VersionEdit里,所以大致上他们的关系如下

Version + 若干个VersionEdit = New Version

这个关系通过辅助类VersionSet::Builder来实现。

从Level L到Level L+1

LevelDB有如下默认设置,对于SST Level L(0 < L <= 7config::kNumLevels),Level 1的上限是10M,且L + 1级的数据容量是L级的10倍。由此我们可推算出Level 2是100M,Level 3是1000M等等。对于Level 0的处理比较特殊,容量限定的是Level 0文件总数量,而非总数据量大小。每次数据库文件发生变动后,会通过VersionSet的如下方法计算下一次数据整合所在的Level

void VersionSet::Finalize(Version* v) {
  // Precomputed best level for next compaction
  int best_level = -1;
  double best_score = -1;

  for (int level = 0; level < config::kNumLevels - 1; level++) {
    double score;
    if (level == 0) {
      // We treat level-0 specially by bounding the number of files
      // instead of number of bytes for two reasons:
      //
      // (1) With larger write-buffer sizes, it is nice not to do too
      // many level-0 compactions.
      //
      // (2) The files in level-0 are merged on every read and
      // therefore we wish to avoid too many files when the individual
      // file size is small (perhaps because of a small write-buffer
      // setting, or very high compression ratios, or lots of
      // overwrites/deletions).
      score = v->files_[level].size() /
              static_cast<double>(config::kL0_CompactionTrigger);
    } else {
      // Compute the ratio of current size to size limit.
      const uint64_t level_bytes = TotalFileSize(v->files_[level]);
      score =
          static_cast<double>(level_bytes) / MaxBytesForLevel(options_, level);
    }

    if (score > best_score) {
      best_level = level;
      best_score = score;
    }
  }

  v->compaction_level_ = best_level;
  v->compaction_score_ = best_score;

数据整合主要通过DBImpl::MaybeScheduleCompaction在后台异步触发,因为其开销较大,可能会阻塞用户线程。顺着调用链,我们可以达到函数DBImpl::BackgroundCompaction(),这里是Compaction的核心逻辑。
第一个主要函数是c = versions_->PickCompaction();,它先判断是否需要触发compaction以及根据size阈值触发还是seek读取次数触发,之后读取所有需要Compact的Level L和Level L+1上存在交叉的SST文件,分别放入Compaction::inputs_[2]数组中。这里特别指出,对于Level 0->Level 1的Compaction,需要从所有的Level 0文件中查找key范围的重合,因为Level 0的SST文件与其他Level不同,它们并不是按照顺序保存的(假设Level 0中存在A,B,C,D四个文件,这四个文件中key的范围一般存在交叉,其他Level则不会),所以Level 0->Level 1的数据整合开销可能比其他Level更大。
第二个主要函数是DBImpl::DoCompactionWork,它先构造了一个遍历所有待整合SST文件的Iterator,Iterator* input = versions_->MakeInputIterator(compact->compaction);,之后逐个遍历每一个Key(drop不需要的旧数据),将数据保存到Level L+1的新SST文件中。这两个函数即为LevelDB数据整合的主逻辑。

posted on 2022-12-04 18:56  ninja_ken  阅读(219)  评论(0)    收藏  举报