07| LevelDB Compaction压缩

688 阅读14分钟

这一篇文章,我们来分析一下LevelDB的压缩Compaction流程,LevelDB非常重要的流程就是后台的压缩功能,通过前面的读、写操作分析,我们可知读写操作是相对简单的,没有太复杂的逻辑。

1 、为何会有Compaction ?它的作用是什么?

前文已经分析,LevelDB的写操作(增删改)都是追加Append操作,只要写入Memtable即可,如果带sync同步的,也只是把写入日志log的数据刷新到磁盘上而已。这就意味着同一个key的增删改全部记录下来了,数据的冗余度必然是直线上升的,庞大的数据冗余必然带来大量的文件,读操作的成本很高,读性能很差,精简文件的数量就十分必要。从SSTable的角度来看,何谓SSTable?就是Sorted String Table,有序的固化表文件,有序体现在Key是按序存储的,也体现在除了Level-0之外,其他Level中的SSTable文件之间也是Key有序的,即:Key不重叠。这就要求有专门的功能来做key的合并排序工作,这也是增加Compaction的必然要求。所以Compaction的作用:1)清理过期的数据;2)维护数据的有序性。

2 、那么怎么做压缩?你会怎么设计Compaction?

Compaction的功能无非是删除冗余的数据,精简SSTable文件的数量,再就是给Key排序,新生成的SSTable文件中的key是有序排列的。又因为等待Compaction的数据量和文件必然是庞大的,不可能通过一次Compaction就能搞定,这部现实,而且还会有不断地写操作进来,因此Compaction的时机也很有讲究。这些都是需要考虑的点。

3 、触发Compaction的时机:

以下三个条件满足一个即可发起Compaction:

1)imm_ != NULL 表示需要将Memtable dump成SSTable,发起Minor Compaction。

2)manual_compaction_ != NULL 表示手动发起Compaction。

3)versions_->NeedsCompaction函数返回True。

无论是哪一种compaction,入口点都是调用的接口:MaybeScheduleCompaction()

void DBImpl::MaybeScheduleCompaction() {
  mutex_.AssertHeld();
  if (background_compaction_scheduled_) {
    // Already scheduled
  } else if (shutting_down_.load(std::memory_order_acquire)) {
    // DB is being deleted; no more background compactions
  } else if (!bg_error_.ok()) {
    // Already got an error; no more changes
  } else if (imm_ == nullptr && manual_compaction_ == nullptr &&
             !versions_->NeedsCompaction()) {
    // No work to be done
  } else {
    background_compaction_scheduled_ = true;
    env_->Schedule(&DBImpl::BGWork, this); //start a new thread to do compaction:GBWork()
  }
}

Compaction是后台进行的任务:

void DBImpl::BGWork(void* db) {
  reinterpret_cast<DBImpl*>(db)->BackgroundCall();
}
void DBImpl::BackgroundCall() {
  MutexLock l(&mutex_);
  assert(background_compaction_scheduled_);
  if (shutting_down_.load(std::memory_order_acquire)) {
    // No more background work when shutting down.
  } else if (!bg_error_.ok()) {
    // No more background work after a background error.
  } else {
    BackgroundCompaction();
  }
  background_compaction_scheduled_ = false;
  // Previous compaction may have produced too many files in a level,
  // so reschedule another compaction if needed.
  MaybeScheduleCompaction();
  background_work_finished_signal_.SignalAll();
}

4 、压缩策略

LSM-Tree中有两种压缩策略Size-Tiered Compaction Strategy和Leveled Compaction Strategy。

LevelDB采用的是Leveled Compaction Strategy:SSTable被划分到不同的Level中,Level-0层的SSTable文件中的Key是存在相互重叠的,且限制默认文件个数是4个,但当Level-0中的文件数目达到软限制——8个,kL0_SlowdownWritesTrigger = 8; 此时为了性能必须降低写速率,所以写操作会休眠1s再进行;而当文件数目达到kL0_StopWritesTrigger = 12时,则会停止写操作。除Level-0层,Level-i(i>0)中的SSTable文件之间所包含的Key是不重叠的,全局有序,任意两级Level之间的SSTable文件容量呈指数级倍数。在Compaction过程中,首先对参与压缩的SSTable文件按key进行归并排序,然后将排序后结果写入到新的SSTable文件中,删除参与Comaction的旧SSTable文件。

image-37.png

5 、压缩Compaction方式:

Compaction分为 Minor Compaction和 Major Compaction,其中 Minor Compaction是将Memtable转换到Level-0中,只会新增文件,而Major Compaction会跨 Level 做合并,既新增文件也删除文件。每当这时,便会生成一个新的 VersionEdit 产生新的 Version,插入 VersionSet 链表的头部,对 Version 的修改主要发生于 Compaction之后。

1)当 MemTable 的大小达到阈值时,进行 MemTable 切换,然后需要将 Immutable MemTable 转换为SSTable磁盘文件,这个称之为 Minor Compaction。

2)当 Level-n的SSTable超过限制,Level-n和Level-n+1的SSTable文件会进行 Compaction,这称之为 Major Compaction。Level-0是通过 SSTable 的数量来判断是否需要 Compaction;Level-1~Level-n(n > 0) 是通过 SSTable 的大小来判断是否需要 Compaction。

 

6 、Compaction与文件

随着更新与Compaction的进行,LevelDB会不断生成新文件,有时还会删除老文件,所以需要一个文件来记录文件列表,这个列表就是清单文件的作用,清单会不断变化,DB需要知道最新的清单文件,必须将清单准备好后原子切换,这就是CURRENT文件的作用。

Level DB包含的几种文件:

文件类型                                     说明

dbname/MANIFEST-[0-9]+          清单文件            

dbname/[0-9]+.log                      db日志文件

dbname/[0-9]+.sst                      dbtable文件

dbname/[0-9]+.dbtmp                db临时文件

dbname/CURRENT                      记录当前使用的清单文件名

dbname/LOCK                             DB锁文件

dbname/LOG                               info log日志文件

dbname/LOG.old                      旧的info log日志文件

上面的log文件,sst文件,临时文件,清单文件末尾都带着序列号,序号是单调递增的(随着next_file_number从1开始递增),以保证不会和之前的文件名重复。

7 、下面我们来具体分析下LevelDB中Compaction的实现: BackgroundCompaction ()

7.1) Minor Compaction 即:Immutable Memtable向level-0中的SSTable文件的转换,这是首先进行的压缩。

void DBImpl::BackgroundCompaction() {
  |-mutex_.AssertHeld();
  |-if (imm_ != nullptr) {//if immutable memtable is not nullptr, begin to compact memtable
    |-CompactMemTable();//有转化的memtable,直接将MemTable写入SSTable即返回。将immutable内存中的数据写入level中,一般是level-0,并且生成一个新的版本Version保存到VersionSet中的链表中。
    |-return;
  |-}

具体看下CompactMemTable()是怎么做的:

image-38.png

简化代码:

void DBImpl::CompactMemTable() {
  |-VersionEdit edit; //increament part that's contents of memtable
  |-Version* base = versions_->current();//current compaction files statement
  |-Status s = WriteLevel0Table(imm_, &edit, base);//将immutable中的key-value添加到新的SSTableFile文件中,并在当前版本CurrentVersion中查找与该新文件key存在重叠的文件所在的层级level(level-0有重叠,则存入level-0;否则存入level-1或level-2),记录在增量版本VersionEdit中。
    |-s = BuildTable(dbname_, env_, options_, table_cache_, iter, &meta);//table file is disk file, so write all keys-values in iterator of skiplist table_ into sstable file, and build a new file metadata, then insert new file metadata into LRU table_cache_ to cache it.
    |-level = base->PickLevelForMemTableOutput(min_user_key, max_user_key);//从当前的版本version中查找与该新文件key存在有重叠的层级level:如果level-0有重叠,则level=0;否则,就从level-1和level-2中查找是否有重叠
    |-edit->AddFile(level, meta.number, meta.file_size, meta.smallest, meta.largest);//将新文件和有重叠key的层级level记录在增量版本VersionEdit.new_files_中。
  |-s = versions_->LogAndApply(&edit, &mutex_);//将新增版本edit的文件信息叠加到Current上并生成一个新的版本Version,添加到VersionSet的链表中,并写入日志文件中。
  |-imm_ = nullptr;//immutable可以释放

 

04| LevelDB中版本控制Version介绍时,我们知道在版本Version数据结构中有与文件压缩相关的信息:LevelDB通过合并操作来生成一个新的版本,因此,需要在当前Version中指定如何合并。每一个Version需要合并的时候,都会经过一定的计算得到每个Level下一次合并的位置,在合并操作Apply()时,直接使用压缩指针compact_pointers_中记录的Level作为索引,因为是版本的Apply,这意味着总是后面的一个版本增量VersionEdit去覆盖前面一个版本增量VersionEdit的合并位置,压缩指针compact_pointers_记录在版本增量VersionEdit中,每个Level下一次合并的位置最终是记录在VersionSet中的compact_pointer_,因此可直接赋值覆盖VersionSet中的compact_pointer_。

所以这里首先就申请了一个版本增量VersionEdit,WriteLevel0Table(imm_, &edit, base)会将immutable中的key-value添加到新的SSTable文件中,并在当前版本Current Version中查找与该新文件key存在重叠的文件所在的层级level,level-0有重叠,则存入level-0,level-1有重叠,则存入level-1,或level-2有重叠,则存入level-2,这些信息会记录在增量版本VersionEdit中。可见新产生出来的SSTable文件并不一定总是处于level-0, 尽管大多数情况下,处于level-0。新创建的出来的SSTable文件应该位于那一层呢? 由PickLevelForMemTableOutput 函数来计算。

 

在BuildTable(dbname_, env_, options_, table_cache_, iter, &meta)这一步,就涉及到一个SSTable文件的新建、文件内部数据布局的构造:Data Block、Filter Block、Meta Index Block、Index Block、Footer,将数据刷到磁盘上生成持久化的SSTable文件,并将该Table数据插入LRUCache缓存中。

 

在level = base->PickLevelForMemTableOutput(min_user_key, max_user_key)这一步决定了SSTable位于哪一层。从策略上来讲,要尽量将新产生的文件推至高level,因为在level-0对文件数目有严格控制,且level-0文件中的key是重叠的,压缩IO和查找key都比较费时费力;但是,另一方面也不能推至过高的level,因为数据的查找是按一定顺序的,且如果某些范围的key更新比较频繁,越往高层压缩IO消耗也越大。所以PickLevelForMemTableOutput需要做权衡折中,如果新生成的SSTable和level-0的SSTable有交叠,新产生的SSTable就直接加入level 0;否则根据一定的策略,向上推到level-1甚至是level-2,但是最高推到level-2,这里有一个控制参数:kMaxMemCompactLevel。

 

在edit->AddFile(level, meta.number, meta.file_size, meta.smallest, meta.largest)这一步则是把文件FileMetaData f写入版本增量VersionEdit的new_file_中new_files_.push_back(std::make_pair(level, f))。

 

在versions_->LogAndApply(&edit, &mutex_)这一步包括:A+B=C的步骤,写日志log、写清单MANIFEST文件、向VersionSet插入新的版本Version。

  |-Version* v = new Version(this); //新实例化一个版本对象Version
  { // Initialize a builder with the files from *base and other info from *vset
    |-Builder builder(this, current_); //实例化一个版本叠加操作Builder
    |-builder.Apply(edit);// Apply all of the edits in *edit to the current state.将增量版本删除和新增的文件记录到Builder.levels_状态中记录。
    |-builder.SaveTo(v);//将Builder中保存的增量版本状态和当前版本Current中记录的已存在版本,一起保存成一个新版本Version
  }
  |-Finalize(v);//计算得到Size Compaction的最佳level和比率score

 

LevelDB的清单MANIFEST过程更新如下:

1)递增清单序号,生成一个新的清单文件。

2)将此清单文件的名称写入到一个临时文件中。

3)将临时文件rename为CURRENT。

 

7.2 )ManualCompaction

手工压缩,即:调用versions_->CompactRange(m->level, m->begin, m->end);

接口:

Compaction* VersionSet::CompactRange(int level, const InternalKey* begin, const InternalKey* end)

从接口就可以看出这是指定了level、begin、end的压缩。

7.3 )Major Compaction

那什么情况下发起major compaction呢?看下面这个函数:

  // Returns true iff some level needs a compaction.
  bool NeedsCompaction() const {
    Version* v = current_;
    return (v->compaction_score_ >= 1) || (v->file_to_compact_ != nullptr);
  }

这是在版本Version中记录的两种压缩方式:Seek Compaction和Score Compaction

class Version {
  // Next file to compact based on seek stats.
  FileMetaData* file_to_compact_;
  int file_to_compact_level_;
  // Level that should be compacted next and its compaction score.
  // Score < 1 means compaction is not strictly needed.  These fields
  // are initialized by Finalize().
  double compaction_score_;
  int compaction_level_;
};

通过函数PickCompaction()来判定使用哪种方式。

c = versions_->PickCompaction();//[zll]: 如果没有指定手工压缩,那就选择一种压缩方式: 优先选择按比率值size,其次是按key的寻道次数seek
Compaction* VersionSet::PickCompaction() {
    …
  // We prefer compactions triggered by too much data in a level over
  // the compactions triggered by seeks. 两种压缩方式:按比率值size和key的寻道次数seek
  const bool size_compaction = (current_->compaction_score_ >= 1);
  const bool seek_compaction = (current_->file_to_compact_ != nullptr);
  if (size_compaction) {//[zll]: 优先使用按比率值size压缩
    level = current_->compaction_level_;//[zll]: 根据之前的计算要压缩的level已经确定
    assert(level >= 0);
    assert(level + 1 < config::kNumLevels);
    c = new Compaction(options_, level);//[zll]: 生成一个Compaction类
        … …
  } else if (seek_compaction) {//[zll]: 按key的寻道次数seek压缩
    level = current_->file_to_compact_level_;//[zll]: 已指定压缩的层级level
    c = new Compaction(options_, level);//[zll]: 生成一个Compaction类
    … …
  } else {
    return nullptr;
  }

 

什么是Score Compaction?

compaction_score_这个值来源于哪里呢?Finalize(v);//计算得到Size Compaction的最佳level和比率score

我们来看下它的实现:

遍历所有的level(0~n),如果level-0层级文件的数目太多, 或者某一层级level-i(i>0)的文件总大小Size太大,超过阈值,则记录score和level,最后设置v->compaction_score_为score最高的那个level。

代码:

void VersionSet::Finalize(Version* v) {
  // Precomputed best level for next compaction
  |-int best_level = -1;//最大比率的level
  |-double best_score = -1;//最大比率
  |-for (int level = 0; level < config::kNumLevels - 1; level++) {
    |-double score;
    |-if (level == 0) {//因为level-0文件数量容易多,文件写入频繁,文件容易变大,为了避免level-0频繁压缩导致性能下降,故用文件数量
      |-score = v->files_[level].size() / static_cast<double>(config::kL0_CompactionTrigger);//level-0文件数量/4
    |-} else {
      // Compute the ratio of current size to size limit.
      |-const uint64_t level_bytes = TotalFileSize(v->files_[level]);//if not level-0, compute all files bytes of this level
      |-score = static_cast<double>(level_bytes) / MaxBytesForLevel(options_, level);//level-1 10MB, level-2 100MB, ..., 每一层级level的最大文件大小是:10的level次方MB大小。因为每层文件的总大小size是不一样的,所以这里score是不同的,并不总是level-1最大。
    }
    |-if (score > best_score) {//找出比率最高的那个level,这是为Size Compaction做准备。
      best_level = level;
      best_score = score;
    }
  }//for
  |-v->compaction_level_ = best_level; //这是为Size Compaction做准备。
  |-v->compaction_score_ = best_score;

 

对于Size Compaction来说,需要考虑上一次Compaction做到了哪个key,什么地方,然后选择compact_pointer_[level]后面的第一个文件,即:大于该key的第一个文件即为level(n)的参与Compaction的文件。

代码1:

  void Apply(VersionEdit* edit) {
    // Update compaction pointers
    for (size_t i = 0; i < edit->compact_pointers_.size(); i++) {
      const int level = edit->compact_pointers_[i].first;
      vset_->compact_pointer_[level] =
          edit->compact_pointers_[i].second.Encode().ToString();
    }

对于n >0的情况,初选情况下level n的参与compaction文件只会有1个,如果n=0,因为level 0的文件之间,key可能交叉重叠,因此,根据选定的level 0的该文件,得到该文件负责的最小key和最大key,找到所有和这个key 区间有交叠的level 0文件,都加入到参战文件。

代码2:

  if (size_compaction) {//[zll]: 优先使用按比率值size压缩
    level = current_->compaction_level_;//[zll]: 根据之前的计算要压缩的level已经确定
    assert(level >= 0);
    assert(level + 1 < config::kNumLevels);
    c = new Compaction(options_, level);//[zll]: 生成一个Compaction类
    // Pick the first file that comes after compact_pointer_[level] 遍历level层所有文件,与compact_pointer_记录的压缩开始Key比较
    for (size_t i = 0; i < current_->files_[level].size(); i++) {
      FileMetaData* f = current_->files_[level][i];
      if (compact_pointer_[level].empty() || icmp_.Compare(f->largest.Encode(), compact_pointer_[level]) > 0) { //[zll]: 压缩开始key为空,或者该文件比压缩开始key大,表示这个文件这次是可以压缩的。
        c->inputs_[0].push_back(f);//[zll]: 那就保存下来。
        break;
      }
    }
    if (c->inputs_[0].empty()) {
      // Wrap-around to the beginning of the key space
      c->inputs_[0].push_back(current_->files_[level][0]);
    }

 

什么是Seek Compaction?

在文件的定义中,会有allowed_seeks变量,在初始化一个文件时赋初值allowed_seeks(1 << 30)。

struct FileMetaData {
  FileMetaData() : refs(0), allowed_seeks(1 << 30), file_size(0) {}
  int refs;
  int allowed_seeks;  // Seeks allowed until compaction
  uint64_t number;
  uint64_t file_size;    // File size in bytes
  InternalKey smallest;  // Smallest internal key served by table
  InternalKey largest;   // Largest internal key served by table
};

某个文件seek次数太多,触发Compaction:

除了level 0以外,任何一个level的文件内部是有序的,文件之间也是有序的。但是level(n)和level(n+1)中的两个文件的key可能存在交叉。正是因为这种交叉,查找某个key值的时候,如果查找level(n),但是没找到,然后去level(n+1)查找,结果找到了,那么对level (n)的某个文件而言,该文件就意味着有一次未命中。我们可以很容易想到,某个文件如果在level(n)中查找了多次,却总也找不到,不得不去level(n+1)查找,如果总是去高一级的level才能找到。这说明level(n)的文件和level(n+1)的文件中的key的范围严重重叠,这就不合理了,会导致查找效率的严重下降。因此,需要对level(n) 发起一次major compaction,减少 level(n)和level(n+1)的重叠情况,这就是所谓的 Seek Compaction。哪个文件seek的次数到了阈值f->allowed_seeks <= 0,那个文件就是level(n)的参与Compaction的文件file_to_compact_ = f; file_to_compact_level_ = stats.seek_file_level;。

 

如下代码所示:

  s = current->Get(options, lkey, value, &stats);
  have_stat_update = true;
 
  if (have_stat_update && current->UpdateStats(stats)) {
    MaybeScheduleCompaction();
  }
 
bool Version::UpdateStats(const GetStats& stats) {
  FileMetaData* f = stats.seek_file;
  if (f != nullptr) {
    f->allowed_seeks--;
    if (f->allowed_seeks <= 0 && file_to_compact_ == nullptr) {
      file_to_compact_ = f;
      file_to_compact_level_ = stats.seek_file_level;
      return true;
    }
  }
  return false;
}

在从SSTable文件中查找一个key后,会记录被查找的文件,并更新seek统计状态,allowed_seeks变量递减一次f->allowed_seeks--,当该值减为0时,说明该文件被查找的次数太多了,该文件就需要进行Compaction了。

解决了哪些文件参与Compaction的问题,接下来就是真正的压缩这些文件了。

 

7.4 )单个文件参与压缩: Trivial Compaction ,直接将文件移动到下一层。

如果待压缩的只有一个文件,而父层level没有文件,祖父层level+1总大小<20M// Move file to next level 这时候只需要直接更改元数据,然后文件移动到level_ + 1即可,不需要多路归并。

文件参与压缩后,会生成新的文件,因此,在版本增量VersionEdit中记录下这个待删除的文件deleted_files_.insert(std::make_pair(level, file))。同样该文件作为增量会生成新的文件,因此,在版本增量VersionEdit的新增加的文件FileMetaData f;中记录下new_files_.push_back(std::make_pair(level, f));

最后,调用versions_->LogAndApply(c->edit(), &mutex_)生成新的版本Version记录。

代码:

  } else if (!is_manual && c->IsTrivialMove()) {//[zll]: 待压缩的只有一个文件,而父层level没有文件,祖父层level+1总大小<20M// Move file to next level 这时候只需要直接更改元数据,然后文件移动到level_ + 1即可,不需要多路归并。
    assert(c->num_input_files(0) == 1);//[zll]: 待压缩的只有一个文件
    FileMetaData* f = c->input(0, 0);//[zll]: 取出这个待压缩的文件
                        |-return inputs_[which][i];
    c->edit()->RemoveFile(c->level(), f->number);//[zll]: 把这个文件存入增量版本VersionEdit的
               |-deleted_files_.insert(std::make_pair(level, file));待删除:level层的文件f要删除
    c->edit()->AddFile(c->level() + 1, f->number, f->file_size, f->smallest, f->largest);//[zll]: 把这个文件存入增量版本VersionEdit的待添加:文件f要添加到level+1层
    status = versions_->LogAndApply(c->edit(), &mutex_);//[zll]: 将该增量版本VersionEdit与Current版本一起生成一个新版本Version,该版本记录了该文件f及所在的level

 

7.5 )压缩任务:压缩level和level+1的重叠文件,进行多路归并。主要通过DBImpl::DoCompactionWork方法实现,其流程非常复杂,这里只做简单概述,后续再展开讨论。

    CompactionState* compact = new CompactionState(c);//记录压缩的状态
    status = DoCompactionWork(compact);//[zll]: 开始做压缩
  • Immutable MemTable优先级最高,如果 imm_ 非空,则合并,使Immutable MemTable 落盘。
  • 对所有输入文件的 iterator 多路合并生成一个合并 iterator,迭代此 iterator,过滤掉过期的键值,通过 TableBuilder 写入新的文件。

 

过滤键值时,因为有多版本 Snapshot 的存在,所以:

  • 如果遍历得到同一个 key 有多个版本,这是因为不同level之间,可能存在Key值相同的记录,但是记录的seq不同。最新的数据存放在较低的level中,其对应的seq也一定比level+1中的记录的seq要大,因此当出现相同Key值的记录时,只需要记录第一条记录,后面的都可以丢弃。即:过滤掉其中 sequence 号小于 smallest_snapshot 的键值。如果上一个Key的SequenceNumber <= 最小的存活的Snapshot,那么这个Key的SequenceNumber一定 < 最小的存活的Snapshot,那么这个Key就不会被任何线程看到了,可以被丢弃; 上面碰到了第一个User Key时,设置了last_sequence_for_key = kMaxSequenceNumber, 保证第一个Key一定不会被丢弃。

  • 如果一个键被标记为删除,删除记录的操作也会在此时完成,删除数据的记录会被丢弃,而不会被写入到更高level的文件中。如果碰到了一个删除操作,并且SequenceNumber <= 最小的Snapshot,通过IsBaseLevelForKey判断更高Level不会有这个User Key存在,那么这个Key就被丢弃。

  • 将可以留下的记录写入到文件中,并将相关信息保存在变量compact中,然后调用InstallCompactionResults()将所做的改动加入到VersionEdit中,再调用LogAndApply()来得到新的版本。

  • 如果数据库重启,如何恢复到之前的状态呢?这就需要将数据库的历史变化信息记录下来,这些信息都是记录在Manifest文件中的。为了节省空间和时间,leveldb采用的是在系统一开始就完整的记录所有数据库的信息,通过WriteSnapshot(descriptor_log_)把CurrentVersion的所有文件current_->files_[level]都记录到Manifest文件中去log->AddRecord(record)。以后则只记录数据库的变化,即:VersionEdit中的信息通过descriptor_log_->AddRecord()添加一条Record到Manifest文件。恢复时,只需要根据Manifest中的信息就可以一步步的恢复到上次的状态。