leveldb

源码版本 master分支(aa5479bbf47e9df86e0afbb89e6246085f22cdd4)
release版本好旧

关于LSM tree

磁盘随机操作慢,LSM tree采用追加的方式避免随机写,付出的代价就是牺牲读性能、写放大。


leveldb中有哪些文件

  • sstable 一个持久化的,有序的sortedMap,存储在磁盘上。
  • wal 日志文件,保证内存数据的持久性。
  • LOCK 文件锁,确保一个leveldb数据库同时只被一个进程操作。
  • LOG 通用日志文件
  • MANIFAST 版本信息文件,一个版本对应着一组sstable文件,以及每个文件在哪个level等信息。
  • CURRENT MANIFAST过大时,会新增一个MANIFAST(只会在每次打开时检测),CURRENT保存了当前使用的最新的MANIFAST文件。
  • dbtmp 更换CURRENT时,不是直接覆盖CURRENT,而是生成临时文件并重命名,防止更新时出错(应该是利用了系统的原子性保证)。

关于Comparator

Comparator被用来对key进行排序,leveldb使用InternalKeyComparator用来对内部key进行排序(内部key由用户key、一个序列号和一个操作类型组成。)。
InternalKeyComparator需要由用户注册一个user_comparator_来比较用户key。
比较逻辑如下:首先按照用户key增序,如果用户key相同则按照序列号降序排列(操作类型在这里没有意义,因为序列号本身是惟一的)。

int InternalKeyComparator::Compare(const Slice& akey, const Slice& bkey) const {
  // Order by:
  //    increasing user key (according to user-supplied comparator)
  //    decreasing sequence number
  //    decreasing type (though sequence# should be enough to disambiguate)
  int r = user_comparator_->Compare(ExtractUserKey(akey), ExtractUserKey(bkey));
  if (r == 0) {
    const uint64_t anum = DecodeFixed64(akey.data() + akey.size() - 8);
    const uint64_t bnum = DecodeFixed64(bkey.data() + bkey.size() - 8);
    if (anum > bnum) {
      r = -1;
    } else if (anum < bnum) {
      r = +1;
    }
  }
  return r;
}

用户可以使用默认的Comparator:BytewiseComparatorImpl,其比较逻辑如下:

inline int Slice::compare(const Slice& b) const {
  const size_t min_len = (size_ < b.size_) ? size_ : b.size_;
  int r = memcmp(data_, b.data_, min_len);
  if (r == 0) {
    if (size_ < b.size_)
      r = -1;
    else if (size_ > b.size_)
      r = +1;
  }
  return r;
}

首先比较a和b共有的部分,如果a和b共有的部分相同,则按照长度比较,举例:

  • abc < abcde
  • abad < abb

这种定义有利于key的前缀压缩

参考:


关于Compaction

知乎上这篇文章即可,这里后面会补充一些细节问题
Compaction可以分为:

  • MinorCompaction
  • MajorCompaction

    • ManualCompaction // 人工触发
    • SizeCompaction // 根据每个level的总文件大小来触发(level0特殊)
    • SeekCompaction // 根据文件的seek miss次数来触发

这篇文章主要分析SizeCompaction和SeekCompaction,MinorCompaction比较简单,看代码即可。
versions_->PickCompaction()
这个函数决定了本次compaction从哪个level开始?参与compaction的sstable文件有哪些?

  • 判断是size_compaction还是seek_compaction,size_compaction具有更高的优先级
  • 选择第一个input sstable:

    • 对于size_compaction而言,将最大值大于compact_pointer_[level]的第一个sstable作为input。compact_pointer_记录了本level上次compaction的最大值,这种策略为了确保每个level的数据均匀的参与compaction。
    • 对于seek_compaction而言,选择对应的sstable文件作为input。
    • 对于level0,因为不同的sstable可能有重叠,因此需要将所有与第一个input有重叠的sstable都加入input。
  • 选择level+1的input

    • 以level层的key范围为边界,在level+1层选择重叠文件并计算其边界
  • AddBoundaryInputs

    • 分别对level层和level+1层的边界进行扩展,有一种情况是user_key相同的两个key刚好作为sstable文件ss1和ss2的边界,即
      ss1->largest_key < ss2->smallest_key
      ss1->largest_user_key == ss2->smallest_user_key
      这种情况下ss2也要加入input,如果不加入的话ss2会在level层保留一个旧版本的数据,导致查询到旧数据。
  • 在不扩大边界的情况下,在level层寻找可能加入compaction的sstable
    举例:假如选择的第一个input为ss1,并且在level+1层找到了重叠的ss3和ss4,那么在这一步会回到level层查找,是否有不改变level+1层参与的sstable的前提下可以加入的sstable,这里找到了ss2和ss0。

level n      [  ss1  ] [  ss2  ] [ss0]
level n+1 [ ss3  ] [      ss4            ]

  • 总结:以上是PickCompaction()的全部内容

接着就是执行具体的compaction操作了:
首先针对某些特殊情况优化处理,比如level+1层的input是空的,则可以直接将level层的sstable移动到level+1层。
否则

  • DoCompactionWork

    • 使用迭代器模式封装,多路归并input文件,生成新文件,生成新版本并持久化edit到MANIFAST文件
    • 这里会获取smallest_snapshot,版本号大于等于smallest_snapshot的数据不能在归并过程中删除,因此如果长时间持有快照,会导致旧数据难以被删除。
    • 判断哪些kv可以被删除的逻辑如下:对于某个user_key而言,非最新的且版本号小于smallest_snapshot的数据可以被删除,只需要读到最新的数据即可;如果这个kv的版本号小于smallest_snapshot,并且是删除操作,并且更高level中也不存在这个key,则也可以被删除。

        // 第一次出现的user_key, sequence = kMaxSequenceNumber,因此不会进入这里
        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.
          drop = true;
        }
  • CleanupCompaction
  • RemoveObsoleteFiles

    • 删除过时的文件

从 DB::Open开始

DBImpl* impl = new DBImpl(options, dbname);
...
Status s = impl->Recover(&edit, &save_manifest);

DBImpl的构造函数中关注TableCache和VersionSet的构建,这两个类型在后续遇到时进行具体分析,目前仅需要知道这两个成员在构造函数中初始化。

接下来看DBImpl::Recover流程:

  • env_->CreateDir(dbname_) // 如果是新建db则需要创建目录,如果是打开则这里会报错,我们忽略这个错误
  • env_->LockFile(...) // 利用文件锁确保只有一个进程在操作db。
  • env_->FileExists(CurrentFileName) // 在当前目录下找CURRENT文件,如果文件不存在则认为是新建db,调用NewDB(),NewDB的逻辑非常简单:生成一个MANIFAST文件,将初始的VersionEdit写入。
  • versions_->Recover(save_manifest) //恢复流程,versions_就是构造函数中构建的VersionSet对象。这里我们跳转进VersionSet::Recover函数:

    • 读取CURRENT文件得到最新的MANIFAST文件名称,MANIFAST文件中存储着一组VersionEdit值,每个VersionEdit记录着前后两个版本的变迁的内容,例如log_number_、next_file_number_、last_sequence_、新增的文件、删除的文件等信息。
    • builder.Apply(&edit); // 将VersionEdit的信息apply到一个Builder中,这里的Builder是个辅助类,用来合并多个VersionEdit的信息,构建Version。
    • Version* v = new Version(this); builder.SaveTo(v); // 用Builder构建Version。
    • Finalize(v); // 为v计算执行compaction的最佳level,这部分内容在Compaction中进行介绍。
    • AppendVersion(v); // 将v作为最新版本加入到VersionSet中
    • ReuseManifest(...) // 判断是否复用当前的MANIFAST文件,如果当前MANIFAST文件过大,则不复用;如果打开当前MANIFAST文件失败,则不复用;否则复用并且设置descriptor_log_和manifest_file_number_。(*save_manifast = true的含义是保存旧的MANIFAST,即不复用)。
    • 总结:VersionSet::Recover从当前MANIFAST文件中读取版本变迁数据并用其生成一个新的Version,将该Version作为当前最新的Version加入VersionSet中,最后判断是否复用MANIFAST文件,如果复用的话打开该文件,设置descriptor_log_和manifest_file_number_。
  • 回到DBImpl::Recover流程。
  • const uint64_t min_log = versions_->LogNumber(); // 将当前目录下所有log_number >= min_log 的wal文件按照log_number从小到大依次调用RecoverLogFile。这里需要说明:每个版本维护的log_number是属于当前版本的所有log_number的下一个log_number,也就是当前版本的下一个log_number
  • 这些wal文件对应的sstable过于新,在生成后还没有来得及归档到最新版本程序就退出了,因此我们需要将这些wal文件中的数据重新读出来,在当前版本下写入memtable,并有可能触发刷盘操作(memtable -> level0 sstable)。当然,在读这些wal文件的数据时会不断更新当前版本的last_sequence,last_sequence唯一标识着每次数据操作,不能重复。
  • 总结:DBImpl::Recover流程结束。此时数据已经恢复,但是恢复过程中可能产生的新的文件(例如来不及归档的数据在DBImpl::Recover流程中在当前版本下写入memtable,当触发刷盘操作的话会产生新的VersionEdit,这条语句中的edit就记录着这些版本变更信息:Status s = impl->Recover(&edit, &save_manifest);

我们继续DB::Open的分析:

  if (s.ok() && impl->mem_ == NULL) {
    // Create new log and a corresponding memtable.
    uint64_t new_log_number = impl->versions_->NewFileNumber();
    WritableFile* lfile;
    s = options.env->NewWritableFile(LogFileName(dbname, new_log_number),
                                     &lfile);
    if (s.ok()) {
      edit.SetLogNumber(new_log_number);
      impl->logfile_ = lfile;
      impl->logfile_number_ = new_log_number;
      impl->log_ = new log::Writer(lfile);
      impl->mem_ = new MemTable(impl->internal_comparator_);
      impl->mem_->Ref();
    }
  }
  if (s.ok() && save_manifest) {
    edit.SetPrevLogNumber(0);  // No older logs needed after recovery.
    edit.SetLogNumber(impl->logfile_number_);
    s = impl->versions_->LogAndApply(&edit, &impl->mutex_);
  }

如果在Recover之后mem_是空的,则构建新的memtable和对应的wal文件的log_number,注意我们看到edit.SetLogNumber(impl->logfile_number_),这个logfile_number_是新申请的、对于将要生成的版本而言的下一个log_number

进入versions_->LogAndApply(&edit, &impl->mutex_)函数:
这个函数主要做了几件事:

  • 为edit设置必要的信息,比如next_file_number_和last_sequence_, 并在当前版本的基础上应用edit,生成一个新的版本v。
  • 如果不复用旧的MANIFAST文件,则生成新的MANIFAST文件并调用WriteSnapshot将当前版本的信息写入;否则跳过这一步。
  • 将edit信息持久化写入MANIFAST文件。
  • 如果生成了新的MANIFAST文件,修改CURRENT指向它。
  • 将版本v添加到VersionSet作为最新的版本。

DB::Open的最后:

if (s.ok()) {
  impl->DeleteObsoleteFiles();
  impl->MaybeScheduleCompaction();
}

DeleteObsoleteFiles函数根据对数据库文件进行遍历,并根据当前版本信息过滤出没用的文件进行删除,典型的例如log_number < versions_->LogNumber()的日志文件,这些文件对应的sstable已经被归档到当前版本,因此我们可以安全的删除wal文件。
MaybeScheduleCompaction函数根据当前版本的Compaction信息开始调度执行Compaction。

可以看到,DB::Open的流程基本上就是Recover流程。我个人认为Recover流程是leveldb中最重要,也是最难的部分。因为要保证程序任何时候退出都能通过Recover流程恢复到某一致的状态,因此对于各文件的写入、修改、删除的顺序有很高的要求,很烧脑。


Get

leveldb的每个写入操作都会带一个递增的last_sequence,因此每个last_sequence标识了数据库的一个"快照",在查询的时候用户可以指定一个Snapshot,每个Snapshot由last_sequence构成,所有sequence大于当前这个Snapshot的last_sequence的操作在这次查询中都是不可见的。

从user_key构造internal_key,之后的查询实际上使用的都是lkey:
LookupKey lkey(key, snapshot)
lkey的格式是这样的: [klength(varint32) | userKey(char[klength]) | sequence(7 bit) | value_type(1 bit)],最后一位用来标志这个kv是否被删除,leveldb的删除也是一次写入操作,在compaction的时候延时删除。

levldb的读操作按照以下顺序进行:

  • memtable
  • immutable
  • current->Get(...) // 当前版本
  • MaybeScheduleCompaction() // 有可能触发seek compaction

memtable和immutable的类型都是MemTable,leveldb使用MemTable类型的memtable缓存最近的写入,当memtable到达一定大小时将memtable赋值给immutable并开始将其持久化为sstable文件,同时新建一个memtable,这样可以避免持久化时无法响应用户。
MemTable内封装了一个SkipList,使用跳表作为存储有序kv对的容器,leveldb实现的跳表有如下特点:

  • 无锁的并发读
  • 不支持del操作
  • 写操作需要外部同步

参考文章:

node:
相比于平衡树和hash表,skiplist的优点有:插入快(相比于平衡树没有平衡过程),实现容易,有序(相比于hash表),容易实现并发版本。

接着看current->Get(...)
按照level从底到高的顺序依次在每一层寻找key,首先通过sstable元信息进行过滤,然后在可能存在key的文件中进行查找,最终是通过TableCache进行查找:
s = vset_->table_cache_->Get(options, f->number, f->file_size, ikey, &saver, SaveValue);
顺便记录了第一个seek miss的文件,当一个文件的seek miss到达一定次数触发seek compaction。


Write

关于Write操作可以参考我写的这篇文章,主要点在于多线程同步写入、write batch、MakeRoomForWrite可能触发compaction。


p__n
491 声望10 粉丝

科学告诉你什么是不可能的;工程则告诉你,付出一些代价,可以把它变成可行,这就是科学和工程不同的魅力。


引用和评论

0 条评论