LevelDB之Compaction

前言

前面大概了解了下LSM-Tree的合并。了解到了分层合并的核心理念,是让每一层的数据之间尽量减少重叠区域,这种方式主要是为了减少读放大的影响。也就是在读写吞吐之间做了取舍,尽量兼顾了读的性能。本文将介绍LevelDB中的合并操作。

MemTable的合并

MemTable写入到Level0 的时候,是肯定会有重叠区域的,在前面的MemTable中就可以看到,数据只会被追加,而不会被替换,所以Level0 层的数据重叠概率是最大的,但是也有存在极端的情况,比如当前的写入非常的离散,导致level0 基本上没有重叠的部分,甚至和level1 都没有,最好的办法就是直接往上推送,但是一直推送存在的问题就是每次合并都需要一个compact,甚至触发高level的多次compact,这是得不偿失的。LevelDB做了一取舍,在当前的Version中选择努力多往上层投递。

MemTable到level0的合并称之为 Minor Compaction

SSTable的合并

已经写入SSTable中的合并分为三种情况:

  • Size compaction 大小触发合并条件后进行合并
  • Manual compaction 人为执行合并
  • Seek compaction 查找Miss后合并

这三种称之为Major compaction。

具体的合并操作

前面提到,每一个文件变化就会触发一个version的变化,合并过程本身就是一个文件读写的操作,所以合并操作的实现都是在Version的文件中。

Minor Compaction

前面在MemTable中已经了解到了如何将MemTable变成level0 的SStable。但是前文也提到过,如果说当前的Level0 的重叠区域较少的时候,可以尽量往上合并。

arduino 复制代码
int Version::PickLevelForMemTableOutput(const Slice& smallest_user_key,
                                        const Slice& largest_user_key) {
  int level = 0;
  if (!OverlapInLevel(0, &smallest_user_key, &largest_user_key)) {
    // Push to next level if there is no overlap in next level,
    // and the #bytes overlapping in the level after that are limited.
    InternalKey start(smallest_user_key, kMaxSequenceNumber, kValueTypeForSeek);
    InternalKey limit(largest_user_key, 0, static_cast<ValueType>(0));
    std::vector<FileMetaData*> overlaps;
    while (level < config::kMaxMemCompactLevel) {
      if (OverlapInLevel(level + 1, &smallest_user_key, &largest_user_key)) {
        break;
      }
      if (level + 2 < config::kNumLevels) {
        // Check that file does not overlap too many grandparent bytes.
        GetOverlappingInputs(level + 2, &start, &limit, &overlaps);
        const int64_t sum = TotalFileSize(overlaps);
        if (sum > MaxGrandParentOverlapBytes(vset_->options_)) {
          break;
        }
      }
      level++;
    }
  }
  return level;
}

如果上一个version不为空,也就是持续写入db的情况下,会触发选择一个Level作为当前MemTable的level。具体实现就是上面的代码。

  1. 判断当前文件的最大最小值是否和其他level0 的文件有重叠
  2. 使用当前的最大最小key,写入到最多kMaxMemCompactLevel-1的层级,kMaxMemCompactLevel 默认值为2,因为合并是将当前层级合并到下一层级,也就是说MemTable最多投递到第二层
  3. 判断是否和下一层级有重叠,有就退出循环。如果当前level为0,但是他和第一层也有重叠部分,那么就不推送到level1,而是仍然使用level0。
  4. 如果没有,则判断是否接近最大的level,如果不是就判断是否和他的祖父级别的层级有较多的重叠部分,如果重叠部分超过了10*(max_file_size 默认2MB)。那么就不往上级合并。
  5. 以上场景都没有,即level0 既和level1 没有合并区间,也和level2 的合并区间少于20MB,则继续将当前level++
  6. 继续判断上面的3 到 5

所以一个MemTable写入到Level2 的条件有

  • MemTable 和其他的level0文件没有重叠
  • MemTable 和level1 ,level2 没有重叠
  • MemTable 和level3的重叠的文件数少于10。

为什么需要加上这几个判断,原文对只升级到第2层的解释为:

// 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.

翻译下就是: 为了避免level0 到level1 中相对昂贵的合并操作和避免写入manifest 文件,所以尝试将level0 推送到更上层,为什么不一致推送到更高的层级,是为了避免浪费空间,存在浪费空间的场景就是大量的重复Key不断更新。假设,我们直接推送到最高层级,那么最高层级可能会存在大量的重复的key,这些key其实是可以在低层级进行合并的。这也间接说明其实越高层级的合并相对是越少执行的。

个人觉得这里也是一个可以根据业务优化的点,比如某些业务场景下,就是间隔某个时间或者什么时候有大量的相同的key,那么完全可以放在最上层,进行一次合并就可以去掉所有重复的数据,还相对减少了其他层的合并,场景必须是间隔一段时间(间隔key可能会被写到最高层级)。

或者就是长时间一直是大批量数据顺序数据写,但是Level2 可以存放的数据已经到了100MB了。

Major Compaction

minor compaction的合并时机只是出现在MemTable写入SSTable,尽管会往level2 写,但是本质上并没有涉及到和存量的SSTable的文件合并。Major 则不同,他是真正会对SSTable文件进行合并。所以虽然他分为Size 和 Seek,我还是合在一起说下。

触发时机

什么时候触发Major合并呢?在db_impl.cc中有个方法MaybeScheduleCompaction方法

arduino 复制代码
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);
  }
}

这个方法最终可能通知后台线程进行合并操作,也就是env_->Schedule(&DBImpl::BGWork, this);,但是合并过程中都是获取了锁的,而且设置了background_compaction_scheduled_ 的判断,所以同一个时间只会有一个线程在合并。

MaybeScheduleCompaction方法被调用的地方有两种类型,对应的就是Major compaction的合并方式

  • size_compaction 每次MakeRoomForWrite ,即可能创建了新的SSTable的时候
  • seek_compaction 每次读取数据后,直到触发了seek_compaction的条件

他们的优先级是先size_compaction 后seek_compaction ,也就是只有size_compaction 不符合条件的时候才进行seek_compaction。

先看下Compaction 类的成员变量,便于后续分析:

arduino 复制代码
class Compaction {
 public:
 // 删减成员变量获取数据的方法
​
 private:
  friend class Version;
  friend class VersionSet;
​
  Compaction(const Options* options, int level);
    // 合并的level
  int level_;
    //最大输出文件大小,默认等于2 * 1024 * 1024
  uint64_t max_output_file_size_;
    // 合并的Version,一般为current
  Version* input_version_;
    // 合并过程的version,后续转换为current
  VersionEdit edit_;
  // Each compaction reads inputs from "level_" and "level_+1"
    // 每个合并都是从当前层合并到下一层,所以将需要合并的文件放到一个vertor中
  std::vector<FileMetaData*> inputs_[2];  // The two sets of inputs
    // 和当前key有重叠区域的祖父文件
  // State used to check for number of overlapping grandparent files
  // (parent == level_ + 1, grandparent == level_ + 2)
  std::vector<FileMetaData*> grandparents_;
    // 重叠开始
  size_t grandparent_index_;  // Index in grandparent_starts_
    // 用于判断祖父文件中和当前文件合并过程中的文件大小累加
  bool seen_key_;             // Some output key has been seen
    // 祖父文件和当前文件合并的大小
  int64_t overlapped_bytes_;  // Bytes of overlap between current output
                              // and grandparent files
    //主要使用在IsBaseLevelForKey方法中的state
    // State for implementing IsBaseLevelForKey
    //level_ptrs_ 这个主要是用来判断当前key是否存在更上层的文件,用于删除某个key,如果当前key在更上层文件中不包含,则可以在本层直接将这个删除的key丢弃
    // level_ptrs_ holds indices into input_version_->levels_: our state
  // is that we are positioned at one of the file ranges for each
  // higher level than the ones involved in this compaction (i.e. for
  // all L >= level_ + 2).
  size_t level_ptrs_[config::kNumLevels];
};  
   

所以一个compaction对象中,是通过vector 的数据,放在inputs中,该数组只包含两个vector,inputs[0] 是当前的level,inputs_[1]是需要合并的level

最后BGWork 中会执行到BackgroundCompaction方法

scss 复制代码
void DBImpl::BackgroundCompaction() {
  mutex_.AssertHeld();
// 合并当前的Mematable
  if (imm_ != nullptr) {
    CompactMemTable();
    return;
  }
  Compaction* c;
  bool is_manual = (manual_compaction_ != nullptr);
  InternalKey manual_end;
  if (is_manual) {
  // 删减如果是人为操作的合并
  } else {
      // 1 选择合并的文件
    c = versions_->PickCompaction();
  }
​
  Status status;
  if (c == nullptr) {
    // Nothing to do
      // 2 如果不是人为,而且选择的过程中IsTrivialMove为True,IsTrivialMove为True的条件是
      // 当前的input[0]是1,input[1]是0,也就是说下层没有和当前区间有重叠部分的数据,那么就简单的将当前的文件移动到下一层,所以是简单的移动。
  } else if (!is_manual && c->IsTrivialMove())
    // Move file to next level
    assert(c->num_input_files(0) == 1);
    FileMetaData* f = c->input(0, 0);
    c->edit()->RemoveFile(c->level(), f->number);
    c->edit()->AddFile(c->level() + 1, f->number, f->file_size, f->smallest,
                       f->largest);
    status = versions_->LogAndApply(c->edit(), &mutex_);
    if (!status.ok()) {
      RecordBackgroundError(status);
    }
    // 日志打印
  } else {
    // 如果有重叠区域
    CompactionState* compact = new CompactionState(c);
    // 执行合并操作
    status = DoCompactionWork(compact);
    if (!status.ok()) {
      RecordBackgroundError(status);
    }
    // 清理合并操作
    CleanupCompaction(compact);
    c->ReleaseInputs();
    // 清理没有引用的文件
    RemoveObsoleteFiles();
  }
  delete c;
    // 删减人为合并和状态结果处理
  }
}

上面的代码一句话说就是找到需要合并的文件,执行合并和清理合并产生的中间态,创建新的Version。下面一一介绍下:

找到需要合并的文件

也就是上文中的c = versions_->PickCompaction();具体实现:

scss 复制代码
Compaction* VersionSet::PickCompaction() {
  Compaction* c;
  int level;
  // We prefer compactions triggered by too much data in a level over
  // the compactions triggered by seeks.
  const bool size_compaction = (current_->compaction_score_ >= 1);
  const bool seek_compaction = (current_->file_to_compact_ != nullptr);
   // 这里的if else 也说明了优先级问题,即size的优先级高于seek
  if (size_compaction) {
     // 找到需要合并的level,这个数据下面会说如何得到的
    level = current_->compaction_level_;
    assert(level >= 0);
    assert(level + 1 < config::kNumLevels);
    c = new Compaction(options_, level);
    // Pick the first file that comes after compact_pointer_[level]
      // 找到当前需要合并的第一个文件,文件是按照大小存放在数组中,所以后续都需要合并。
    for (size_t i = 0; i < current_->files_[level].size(); i++) {
      FileMetaData* f = current_->files_[level][i];
        // compact_pointer_ 是用来标识在当前level中需要合并的起始位置,可能为空
        // 如果起始位置为空,或者当前层的起始位置比当前文件要大,则说明需要合并当前文件,写入到input_[0]中,这里会产生一个问题,就是我们当前找到第一个文件就退出了,但是可能当前key在本层还有更老的相同key的文件,所以会有下面的扩展边界的出现
      if (compact_pointer_[level].empty() ||
          icmp_.Compare(f->largest.Encode(), compact_pointer_[level]) > 0) {
        c->inputs_[0].push_back(f);
        break;
      }
    }
      // 如果当前的起始为空,或者起始的key特别的大,就直接从最开始的位置进行合并
    if (c->inputs_[0].empty()) {
      // Wrap-around to the beginning of the key space
      c->inputs_[0].push_back(current_->files_[level][0]);
    }
  } else if (seek_compaction) {
    level = current_->file_to_compact_level_;
    c = new Compaction(options_, level);
    c->inputs_[0].push_back(current_->file_to_compact_);
  } else {
    return nullptr;
  }
​
  c->input_version_ = current_;
  c->input_version_->Ref();
    // 对于level0的文件,因为他们本身就包含了需要的重叠区间,所以需要将所有的文件中最大最小的都找出来,什么情况下这里的level是0呢,就是当前其他的level都不需要进行数据的合并
  // Files in level 0 may overlap each other, so pick up all overlapping ones
  if (level == 0) {
    InternalKey smallest, largest;
    GetRange(c->inputs_[0], &smallest, &largest);
    // Note that the next call will discard the file we placed in
    // c->inputs_[0] earlier and replace it with an overlapping set
    // which will include the picked file.
    current_->GetOverlappingInputs(0, &smallest, &largest, &c->inputs_[0]);
    assert(!c->inputs_[0].empty());
  }
 // 找到inputs[1]
  SetupOtherInputs(c);
​
  return c;
}
​

PickCompaction整个还是比较简单的,就是从current的version中找到合并的inputs数据,SetupOtherInputs 则是找inputs[1],这里先回到Version中的部分实现。即current中的compaction_level和compaction_score 这些是怎么得到的。

在Version的Finalize 中,会选择出当前version的上诉数据:

ini 复制代码
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) {
     // 对于0层,他的合并选择和其他层不一样,0 层是按照文件个数来判断的,默认是4,所以是当前的size/4 就是获取得分
      score = v->files_[level].size() /
              static_cast<double>(config::kL0_CompactionTrigger);
    } else {
        // 其他层则是按照文件大小进行判断,比如level1 是10mb,如果超过10mb,那么他的得分就超过了level0,当然如果level0 迟迟不被合并,他的得分肯定会更高,
      // 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;
    }
  }
    // 将下次合并最优先级别的level和得分写到version中
  v->compaction_level_ = best_level;
  v->compaction_score_ = best_score;
}

所以上一个版本在apply的时候就确定了下次触发合并操作的level。而comact_pointer 的值是在SetupOtherInputs中写入的。先整体看下如何写入inputs[2]

scss 复制代码
void VersionSet::SetupOtherInputs(Compaction* c) {
  const int level = c->level();
  InternalKey smallest, largest;
 // 填充input[0]的边界
  AddBoundaryInputs(icmp_, current_->files_[level], &c->inputs_[0]);
  // 写入最大最小值
  GetRange(c->inputs_[0], &smallest, &largest);
 // 找到比当前最大最小值范围内的文件,并且写入到inputs_[1]中
  current_->GetOverlappingInputs(level + 1, &smallest, &largest,
                                 &c->inputs_[1]);
    // 填充input[2]的边界
  AddBoundaryInputs(icmp_, current_->files_[level + 1], &c->inputs_[1]);
  // 获取整体上的最大最小值,这里处理的结果是可能当前level中包含,但是level+1中不包含的数据也可以被合并,从而直接将部分数据直接合并了,减少下次合并
  // Get entire range covered by compaction
  InternalKey all_start, all_limit;
  GetRange2(c->inputs_[0], c->inputs_[1], &all_start, &all_limit);
​
  // See if we can grow the number of inputs in "level" without
  // changing the number of "level+1" files we pick up.
    // 看我们是否能够新增inputs[0]的数量,而不需要修改inputs[1]的数量。
    // 这个注释的意思就是,比如当前的level的key是a-g,然后inputs[1]中的数量是a-d,那么从e-g其实也可以放入到本次合并,减少下次level的合并,
  if (!c->inputs_[1].empty()) {
    std::vector<FileMetaData*> expanded0;
      // 将当前level中所有的符合[all_start,all_limit]的文件写入到expanded0 中,作为level的合并文件
    current_->GetOverlappingInputs(level, &all_start, &all_limit, &expanded0);
  // 扩展level的边界
    AddBoundaryInputs(icmp_, current_->files_[level], &expanded0);
    const int64_t inputs0_size = TotalFileSize(c->inputs_[0]);
    const int64_t inputs1_size = TotalFileSize(c->inputs_[1]);
    const int64_t expanded0_size = TotalFileSize(expanded0);
      //如果扩展后的文件比原来的大,而且中的合并文件数小于ExpandedCompactionByteSizeLimit(25个文件)。则需要继续扩大
    if (expanded0.size() > c->inputs_[0].size() &&
        inputs1_size + expanded0_size <
            ExpandedCompactionByteSizeLimit(options_)) {
        // 创建新的其实和结束
      InternalKey new_start, new_limit;
        // 获取新的开始和结束
      GetRange(expanded0, &new_start, &new_limit);
      std::vector<FileMetaData*> expanded1;
        // 在level+1 获取到新的开始和结束,写入到expanded1
      current_->GetOverlappingInputs(level + 1, &new_start, &new_limit,
                                     &expanded1);
        // 扩展expanded1
        AddBoundaryInputs(icmp_, current_->files_[level + 1], &expanded1);
        // 如果扩展失败了
      if (expanded1.size() == c->inputs_[1].size()) {
       // 将当前的最大最小值写入,并且赋值给compaction
        smallest = new_start;
        largest = new_limit;
        c->inputs_[0] = expanded0;
        c->inputs_[1] = expanded1
            // 重新获取总体的最大最小值
        GetRange2(c->inputs_[0], c->inputs_[1], &all_start, &all_limit);
      }
    }
  }
    // 查看和祖父节点的数据重叠,写入到compaction中
  // Compute the set of grandparent files that overlap this compaction
  // (parent == level+1; grandparent == level+2)
  if (level + 2 < config::kNumLevels) {
    current_->GetOverlappingInputs(level + 2, &all_start, &all_limit,
                                   &c->grandparents_);
  }
​
  // Update the place where we will do the next compaction for this level.
  // We update this immediately instead of waiting for the VersionEdit
  // to be applied so that if the compaction fails, we will try a different
  // key range next time.
    // 将本次level的最大值写入到compact_pointer_ 中,作为下次合并的起始位置
  compact_pointer_[level] = largest.Encode().ToString();
  c->edit_.SetCompactPointer(level, largest);
}

上面一直提到一个扩展边界,这个边界是什么意思呢,为什么需要扩展呢?稍微解释下,inputs中存放的都是等带compact的文件,后文成为compaction_files,如果当前的compaction_files 中包含了一个文件b1,在对应的level的文件中有个SSTable文件b2,其中b2的文件比b1老。比如当前文件中存在两个不同sequence的key为hello的文件

hello_11111 在b1 中,还有一个文件在hello_00000没有放在compaction_files ,如果我们直接将b1的文件合并到level+1层,那么可能存在的情况就是老的hello_00000 的数据还存在level层,导致读取数据的出现获取到旧文件的情况。出现这种情况是因为在PickCompaction 方法中,是找到第一个和compact_pointer_ 做比较的文件,而不是最旧的那个文件。input[1] 也是同理,说白了就是希望将存在相同key的文件都合并了。不过越往上层存在这种情况的可能性越低,因为每次合并都有不断扩展边界的操作。

所以AddBoundaryInputs的方法实现就好理解了

c 复制代码
// Extracts the largest file b1 from |compaction_files| and then searches for a
// b2 in |level_files| for which user_key(u1) = user_key(l2). If it finds such a
// file b2 (known as a boundary file) it adds it to |compaction_files| and then
// searches again using this new upper bound.
//
// If there are two blocks, b1=(l1, u1) and b2=(l2, u2) and
// user_key(u1) = user_key(l2), and if we compact b1 but not b2 then a
// subsequent get operation will yield an incorrect result because it will
// return the record from b2 in level i rather than from b1 because it searches
// level by level for records matching the supplied user key.
//
// parameters:
//   in     level_files:      List of files to search for boundary files.
//   in/out compaction_files: List of files to extend by adding boundary files.
void AddBoundaryInputs(const InternalKeyComparator& icmp,
                       const std::vector<FileMetaData*>& level_files,
                       std::vector<FileMetaData*>* compaction_files) {
  InternalKey largest_key;
  // 如果当前的compaction_files文件为空,直接返回,我没有想到这个场景,因为pick的方法中,总是会往里面塞值的,除非原本就没有值
  // Quick return if compaction_files is empty.
  if (!FindLargestKey(icmp, *compaction_files, &largest_key)) {
    return;
  }
​
  bool continue_searching = true;
  while (continue_searching) {
      // 从当前level中找到最小的边界值
      //// Finds minimum file b2=(l2, u2) in level file for which l2 > u1 and
// user_key(l2) = user_key(u1)
      //在当前level中查找,满足的条件是,level中某个文件的最小值比当前的最大值要大,并且最小值的user-key和当前的userkey相同的文件, 
    FileMetaData* smallest_boundary_file =
        FindSmallestBoundaryFile(icmp, level_files, largest_key);
​
    // If a boundary file was found advance largest_key, otherwise we're done.
    if (smallest_boundary_file != NULL) {
      compaction_files->push_back(smallest_boundary_file);
      largest_key = smallest_boundary_file->largest;
    } else {
      continue_searching = false;
    }
  }
}

我对这块的理解不是很透彻,感觉是当前小存在两个文件b1 包含了[key1,key2_seqN] 此时的compaction_pointer 的值比key2_seqN要大,但是可能存在key2_seqN-1 处于 b2[key2_seqN-1,key2_seq_3] 和b3[key2_seq_2,key3],这种情况的出现是因为比较的时候是按照key的升序,seq的降序,也就是相同的userkey的seq越大就越靠前。所以在FindSmallestBoundaryFile的实现中是这么找到数据的:

ini 复制代码
FileMetaData* FindSmallestBoundaryFile(
    const InternalKeyComparator& icmp,
    const std::vector<FileMetaData*>& level_files,
    const InternalKey& largest_key) {
  const Comparator* user_cmp = icmp.user_comparator();
  FileMetaData* smallest_boundary_file = nullptr;
  for (size_t i = 0; i < level_files.size(); ++i) {
    FileMetaData* f = level_files[i];
      // 当前的最小的值比查询的值的seq小,即userkey相同,但是seq靠
    if (icmp.Compare(f->smallest, largest_key) > 0 &&
        user_cmp->Compare(f->smallest.user_key(), largest_key.user_key()) ==
            0) {
      if (smallest_boundary_file == nullptr ||
          icmp.Compare(f->smallest, smallest_boundary_file->smallest) < 0) {
        smallest_boundary_file = f;
      }
    }
  }
  return smallest_boundary_file;
}
​

这里的实现主要是来自一次bug

执行正式的合并

执行正式的合并本质上比较简单,但是代码很长,下面的代码是删减后的代码:

rust 复制代码
Status DBImpl::DoCompactionWork(CompactionState* compact) {
  const uint64_t start_micros = env_->NowMicros();
  int64_t imm_micros = 0;  // Micros spent doing imm_ compactions
    //1  将上面选择的文件组合为一个input
  Iterator* input = versions_->MakeInputIterator(compact->compaction);
  // 执行合并的时候释放锁,说明这个时刻是可以和写入操作并发执行的
  // Release mutex while we're actually doing the compaction work
  mutex_.Unlock();
   // 获取头结点 
  input->SeekToFirst();
  Status status;
  ParsedInternalKey ikey;
  std::string current_user_key;
  bool has_current_user_key = false;
  SequenceNumber last_sequence_for_key = kMaxSequenceNumber;
  while (input->Valid() && !shutting_down_.load(std::memory_order_acquire)) {
    // Prioritize immutable compaction work
    // ... 如果当前需要写入level0,则直接CompactMemTable,需要加锁
​
    Slice key = input->key();
    // 当前合并输出大小是否和祖父节点超过20MB的重叠区间,如果超过就需要提前退出本次合并
    // 这么做的原因是下次合并的时候不会选择更多的上层文件,
    if (compact->compaction->ShouldStopBefore(key) &&
        compact->builder != nullptr) {
      status = FinishCompactionOutputFile(compact, input);
     // 状态判断...
    }
    // Handle key/value, add to state, etc.
      // 判断当前key是否可以直接丢弃
    bool drop = false;
      // 解析是Internalkey
    if (!ParseInternalKey(key, &ikey)) 
        // 解析失败去掉当前的缓存值
      current_user_key.clear();
      has_current_user_key = false;
      last_sequence_for_key = kMaxSequenceNumber;
    } else {
        //has_current_user_key 如果没有上次的userkey
      if (!has_current_user_key ||
          user_comparator()->Compare(ikey.user_key, Slice(current_user_key)) !=
              0) {
          // 判断当前的key是否和上次的key相等,如果不相等就设置has_current_user_key 为true
        // First occurrence of this user key
        current_user_key.assign(ikey.user_key.data(), ikey.user_key.size());
        has_current_user_key = true;
        last_sequence_for_key = kMaxSequenceNumber;
      }
     // 如果已经存在相等的值,而且前面的sequence 就已经比当前的snapshot的最小版本还小,则直接设置可以丢弃,即存在了更新的数据,当前数据太旧了
      if (last_sequence_for_key <= compact->smallest_snapshot) {
        // Hidden by an newer entry for same user key
        drop = true;  // (A)
      } else if (ikey.type == kTypeDeletion &&
                 ikey.sequence <= compact->smallest_snapshot &&
                 compact->compaction->IsBaseLevelForKey(ikey.user_key)) {
        // For this user key:
        // (1) there is no data in higher levels
        // (2) data in lower levels will have larger sequence numbers
        // (3) data in layers that are being compacted here and have
        //     smaller sequence numbers will be dropped in the next
        //     few iterations of this loop (by rule (A) above).
        // Therefore this deletion marker is obsolete and can be dropped.
          // 如果是删除的key,则需要确定更上层没有数据才可以直接丢弃,否则需要等到上次合并的时候才会真正丢弃数据
        drop = true;
      }
        // 设置当前的sequence
      last_sequence_for_key = ikey.sequence;
    }
​
    // 如果不丢弃就打开合并文件执行当前key的写入
    if (!drop) {
      // Open output file if necessary
     // .... 打开文件和写入数据
      // Close output file if it is big enough
        // 如果当前的数据超过了2MB,则需要直接进行数据写入
      if (compact->builder->FileSize() >=
          compact->compaction->MaxOutputFileSize()) {
        status = FinishCompactionOutputFile(compact, input);
      }
    }
​
    input->Next();
  }
// 后续操作,包括了确保当前数据全部刷盘和清理内存
// ....
​
  CompactionStats stats;
// 记录本次合并状态,包括数据和大小以及消耗时间
  mutex_.Lock();
  stats_[compact->compaction->level() + 1].Add(stats);
// 将合并的数据写入到version中
  if (status.ok()) {
    status = InstallCompactionResults(compact);
  }
//日志打印
  return status;
}

合并的过程相对比较简单。主要是选取数据的过程。具体步骤在代码里已经写了,啰嗦着总结下:

  • 将Input封装为Iterator
  • Iterator 循环遍历值
  • 如果当前的key和祖父文件中的key重叠超过了阈值20MB或者说10个文件,则提前退出本次合并,避免下层合并过程出现大量的文件合并
  • 获取当前的key,存放在栈空间里,用于和下次循环的key对比,如果key相同,而且sequence 小于当前的最小snapshot值,当前的key是删除而且sequence小于最小的snapshoy且上层不包含其他的文件,则可以进行删除操作。
  • 在写入过程中,超过2MB就执行一次文件写入
  • 如果当前等待输出的缓冲区不为空,则将缓冲区写入磁盘
  • 更新version 和清理活动,包含了将需要删除的文件,也就是input文件放入Version的待删除区,将本次写入的文件放入到level+1层

Seek 和Manual 的compaction

个人觉得上面的两种整体合并操作和size 差不多,主要是一个input的选择。

Seek Compaction

Seek的选择多来自于每次get操作。比如当前level1 层中的某个SSTable文件中的数据是[1,3,4,5,8,9],level2 中的文件是[4,5,6,7,8],那么可能每次获取7 都是先去level1 中找该数据,发现找不到才去下面找,这个数据是存放在version的

ini 复制代码
 struct GetStats {
    FileMetaData* seek_file;
    int seek_file_level;
  };

中,每一个FileMetaData 都包含了一个int allowed_seeks;属性,这个值是初始化在每次Version更新中,如初始化为

ini 复制代码
   f->allowed_seeks = static_cast<int>((f->file_size / 16384U)); //默认是2Mb的filesize,最后的值是256
   if (f->allowed_seeks < 100) f->allowed_seeks = 100;//最小值不能小于100

每次Get操作都会更新文件的allowed_seeks,主要是执行减一的操作,当这个值为0的时候,则会调用一次合并操作:

ini 复制代码
bool Version::UpdateStats(const GetStats& stats) {
  FileMetaData* f = stats.seek_file;
  if (f != nullptr) {
    f->allowed_seeks--;
      // allowed_seeks小于0,则会将需要合并的level和文件写入,并且返回true。然后调用MaybeScheduleCompaction
    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;
}

然后在选择input的时候,就会将传入的file_to_compact_写入到input0 中,进行单个文件的合并。

Manual Compaction

这个和size 区别不大,这里就不赘述了。

总结

总的来说,LevelDB 的分层合并的实现有如下特点:

  1. 单线程合并
  2. 尽量考虑后续合并操作,每次都会执行level+2的重叠判断,如果过大会丢弃没有完成的合并
  3. 考虑到了特殊的场景,即因为key的排序问题带来可能存在数据不一致的情况
  4. 除了第一层以外,每层的单个SSTable的key值基本上不重叠,每个大小为2MB
  5. 考虑到读取过程可能存在某个文件key的范围和上层重叠的情况,会进行一个单文件合并的操作
相关推荐
gsls20080810 天前
小型kv数据库leveldb配合grpc实现网络访问
数据库·grpc·leveldb
LunarCod7 个月前
LevelDB源码阅读笔记(1、整体架构)
linux·c++·后端·架构·存储·leveldb·源码剖析
码灵1 年前
java LevelDB工具类
java·leveldb
明悠小猪1 年前
LSM-Tree的读写放大和空间放大
leveldb
明悠小猪1 年前
LevelDB之Version
后端·leveldb
明悠小猪1 年前
LevelDB之SSTable读写
后端·leveldb
明悠小猪1 年前
LevelDB之SSTable 数据结构
后端·leveldb
明悠小猪1 年前
LevelDB 之MemTable
后端·leveldb
明悠小猪1 年前
LevelDB之Log
后端·leveldb