leveldb versionSet 和 versionEdit

前言

Compaction和Version相关的部分交织在一起,不搞明白Version很难理解Compaction。其实这句话反过来说也是对的,如果不理解Compaction也很难理解VersionSet。

versionEdit

Version相关的数据结构有3个,Version VersionEdit and VersionSet。其中VersionEdit顾名思义,是编辑或修改Version,它记录的是两个Version之间的差异。简单的说

Version0 + VersionEdit = Version1

LevelDB中的文件主要的文件有 数据文件即sstable 文件, log文件,manifest文件,current文件(指向当前的manifest),LOG文件和lock文件,而VersionEdit中,最重要的3个成员变量是:

  std::vector< std::pair<int, InternalKey> > compact_pointers_;
  DeletedFileSet deleted_files_;
  std::vector< std::pair<int, FileMetaData> > new_files_;

第一个compact_pointers按下不表,第二个和第三个顾名思义,就是相对于上一个Version,新的Version新增了那些文件以及删除了那些文件。 注意,从一个版本到到另一个版本的过渡,是由Compaction引起的。
Compaction分成两种:Minor Compaction和Major Compaction。
Minor Compaction是说,用户输入的key-value足够多了,需要讲memtable转成immutable memtable,然后讲immutable memtable dump成 sstable,而这种情况下,sstable文件多了一个,而能属于level0,也能属于level1或者level2。这种情况下,我们称version发生了变化,需要升级版本。这种情况比较简单,基本上是新增一个文件。
Major Compaction 要复杂一些,它牵扯到两个Level的文件。它会计算出重叠部分的文件,然后归并排序,merge成新的sstable文件,一旦新的文件merge完毕,老的文件也就没啥用了。因此对于这种Compaction除了new_files_还有deleted_files_(当然还有compaction_pointers_)。
为了更深入地理解version以及后面的Compaction,我们需要介绍下FileMetaData这个数据结构,这个数据结构非常的重要,正是因为存在这个数据结构,才能很方面的选择which文件需要Compaction。

struct FileMetaData {
  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

  FileMetaData() : refs(0), allowed_seeks(1 << 30), file_size(0) { }
};

注意sstable文件的名字是 number.sst 如11423.sst这种格式,只要一个number就可以表示该SSTable文件的名字,除此外还存放着该SSTable文件的长度。因为SSTable文件里面的键值是有序的,因此,最大的key和最小的key就足矣描述key的范围。

Version and VersionSet and MVCC

对于同一笔记录,如果读和写同一时间发生,reader可能读到不一致的数据或者是修改了一半的数据(读稍慢于写,但还未写完)。
对于这种情况,有三种常见的解决方法(也就是并发控制方案):
1.悲观锁(PCC) 最简单的处理方式,就是加锁保护,写的时候不许读,读的时候不许写。效率低。
2.乐观锁(OCC) 假设多用户并发的事物在处理时不会彼此互相影响,各事务能够在不产生锁的的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚(这样做不会有锁竞争,但如果数据竞争的概率较高,效率也会受影响 。)
3.多版本并发控制(MVCC) MVCC是一个数据库常用的概念。Multiversion concurrency control。每一个执行操作的用户,看到的都是数据库特定时刻的的快照(snapshot), writer的任何未完成的修改都不会被其他的用户所看到;当对数据进行更新的时候并是不直接覆盖,而是先进行标记, 然后在其他地方添加新的数据,从而形成一个新版本(空间占用较大), 此时再来读取的reader看到的就是最新的版本了。所以这种处理策略是维护了多个版本的数据的,但只有一个是最新的。

sstable级别的MVCC就是利用Version实现的。

  • 只有一个current version,持有最新的sstable集合。
  • VersionEdit 代表一次更新,新增了哪些sstable file,以及删除了哪些sstable file

LevelDB将所有的Version置于一个双向链表之中,即位于一个集合之中。这样所有的Version组成一个名为VersionSet的结构。

LevelDB会触发Compaction,会对一些文件进行清理操作,让数据更加有序,清理后的数据放到新的版本里面,而老的数据作为原始的素材,最终是要清理掉的,但是如果有读事务位于旧的文件,那么暂时就不能删除。因此利用引用计数,只要一个Verison还活着,就不允许删除该Verison管理的所有文件。当一个Version生命周期结束,它管理的所有文件的引用计数减1.

Version::~Version() {
  assert(refs_ == 0);

  /* 从VersionSet中注销 */
  prev_->next_ = next_;
  next_->prev_ = prev_;

  /*本Version下所有的文件,引用计数减1*/
  for (int level = 0; level < config::kNumLevels; level++) {
    for (size_t i = 0; i < files_[level].size(); i++) {
      FileMetaData* f = files_[level][i];
      assert(f->refs > 0);
      f->refs--;
      if (f->refs <= 0) {
        delete f;
      }
    }
  }
}

从上图我们看到了,从Version 升级到另一个Version中间靠的是VersionEdit。VersionEdit告诉我们哪些文件可以删除了,哪些文件是新增的。这个过程是LogAndApply。为了方便实现
Version(N) + VersionEdit(N) = Version(N+1)
引入了Build数据结构,这个数据结构是一个helper类,帮忙实现Version的跃升。我们下面重点分析LogAndApply。

LogAndApply

调用LogAndApply的时机有4个,其中第一个是打开DB的时候,其余3个都与Compaction有关系。
1.Open DB 的时候,有些记录在上一次操作中,可能有一些记录只在log中,并未写入sstable,因此需要replay, 有点类似journal文件系统断电之后的replay操作。
2.Immutable MemTable dump成SStable之后,调用LogAndApply
3.如果是非manual,同时仅仅是sstable文件简单地在不同level之间移动,并不牵扯两个不同level的sstable之间归并排序,就直接调用LogAndApply
4.Major Compaction,不同level的文件存在交叉,需要归并排序,生成新的不交叉重叠的sstable文件,同时可能将老的文件废弃。

对于Compaction而言,它的作用是: 或者是通过MemTable dump,生成新的sstable文件(一般是level0 或者是level1或者是level2),在一定的时机下,整理已有的sstable,通过归并排序,将某些文件推向更高的level,让数据集合变得更加有序。
生成完毕新文件后,需要产生新的version,这就是LogAndApply的做的事情。

构造Builder

首先是以当前的Version为基础,创建出来一个Build类,这个类是一个help类:
Builder builder(this, current_);
对于Builder这个类,基本的成员有:

typedef std::set<FileMetaData*, BySmallestKey> FileSet;
  struct LevelState {
    std::set<uint64_t> deleted_files;
    FileSet* added_files;
  };

  VersionSet* vset_;
  Version* base_;
  LevelState levels_[config::kNumLevels];

初始化的时候,base_这个指针指向current_ 而,vset_指针指向vset,即当前VersionSet。至于各个level的文件,初始化成空的集合。

apply

builder.Apply(edit);
这一步是将版本与版本的变化部分VersionEdit 记录在Builder
对于文件也记录的allow_seeks这个字段,原因是触发Compaction,可能是因为某一个层级的file个数太多,总长度超过了指定的上限,也可能是因为某sstable seek的次数过多,此处并不展开讲解。

SaveTo

builder.SaveTo(v);
这一部分是根据Builder中的base_指向的当前版本current_, 以及在Apply部分记录的删除文件集合,新增文件集合和CompactionPointer集合,计算出一个新的Verison,保存在v中。
注意一开始新版本v里面各个level的文件集合都是空的,同时除了level0以外,其它的level中的文件是有序的,必须是有序的。因此上面的代码就比较容易理解了。
它是一个三层的循环:最外层是level。无论是当前的Version current_,还是正在生成中的Version v,文件都是分层,包括help类Builder中的delete集合和新增集合,都是分层表示的。因此最外层循环是level就很自然。各个层级处理的逻辑都是一样。
就是base_这个Version(其实就是当前Version current_) 中的文件和 Builder中的added_files文件进行比较,按照顺序进入新Version v的对应level中。
其中很有意思的是MayAddFile。

void MaybeAddFile(Version* v, int level, FileMetaData* f) {
    if (levels_[level].deleted_files.count(f->number) > 0) {
      /*如果文件在删除列表之内,就没必要加入到新的Version v的对应层级的文件集合*/
    } else {
      std::vector<FileMetaData*>* files = &v->files_[level];
      if (level > 0 && !files->empty()) {
        /*除level0外的任何level (1~6),Version v内的对应层级的文件列表必须是有序的,不能交叉
         *所以此处有判断 level >0,这是因为并不care level0 是否交叉,事实上,它几乎总是交叉的* /
        assert(vset_->icmp_.Compare((*files)[files->size()-1]->largest,
                                    f->smallest) < 0);
      }
      f->refs++;
      files->push_back(f);
    }
  }
};

VersionSet::Finalize

这个部分是用来帮忙选择下一次Compaction应该从which level 开始。计算部分比较简单,基本就是看该level的文件数目或者所有文件的size 之和是否超过上限

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) {
      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;
}

对于level0 而言,如果文件数目超过了config::kL0_CompactionTrigger, 就标记需要Compaction, 而该参数的值为:
// Level-0 compaction is started when we hit this many files.
static const int kL0_CompactionTrigger = 4;
对于其他 层级而言,是按照每一level的总大小来判定的。按照预想,每一个层级的文件的总大小是有上限的:

level 1                10M
level 2               100M 
level 3              1000M
level 4             10000M
level 5            100000M

该层级的得分是该层级的所有文件的size总和 除以上限,然后各个层级比较自己的score,选出最急迫需要Compaction的level。 当然了Level 6就不用算了,它已经是最高的层级了。

Compaction的时候,调用Pick Compaction函数来选择compaction的层级,那时候会先按照Finalize算出来的level进行Compaction。当然了,如果从文件大小和文件个数的角度看,没有任何level需要Compaction,就按照seek次数来决定

posted @ 2022-09-26 11:08  misaka-mikoto  阅读(80)  评论(0编辑  收藏  举报