1. LevelDB 简介
LevelDB 是 Google 开源的一款单机 KV 持久存储引擎,可以看作是BigTable 的单机版本。通过 LSM 树(Log Structured Merge Tree),LevelDB 牺牲了一定的读性能,但提高了写性能。对外 LevelDB 提供了常用的 Put
、Get
、Delete
等接口,还支持 Snapshot、Recovery 等操作。
本篇文章主要分析 LevelDB 的 compaction 过程以及分层设计的思想,对于 LevelDB 不熟悉的同学,可以先查看其他资料。
2. LSM 树在 LevelDB 中的应用
我们知道直接在磁盘中进行随机写性能是比较低的,那么为了获得较好的随机写性能,LevelDB 使用 LSM 树来组织数据。我们首先看下写流程:
当有一条记录要写入磁盘时,LevelDB 首先会将本次写操作 Append 到日志中(相当于顺序写),然后把这条记录插入内存中的 Memtable 中(Memtable 底层使用 skiplist 来保证记录有序),可见 LevelDB 将随机写转化为了顺序写。
Memtable 达到一定大小时,就会被 dump 到磁盘,这里有两个原因:1. 内存是有限的,不能将数据都放在内存中;2. 断电后内存中的数据就会全部丢失,虽然有日志可以恢复数据,但每次都恢复全部的数据显然不现实。
Memtable 一般情况下会被 dump 到 level 0,level L 中 tables 总大小超过一定大小时,LevelDB 就会在 level L 中选取一些 table 与 level L+1 中有 key 重叠的 table 进行 compaction,生成新的 table。这里我们思考一下为什么需要设计多层 level?如果只有 level 0,随着 Memtable 不断被 dump 到磁盘上,level 0 上的 table 数量就会越来越多,并且这些 tables 的 key 存在相互重叠的情况。那么每次读操作时,如果在 Memtable 中没有找到数据,就需要在所有 tables 中进行查找,显然读性能受到了严重影响。
为了解决这一问题我们可以考虑限制 level 0 上 tables 的数量,同时增加一个 level 1,当 level 0 上 tables 数量超过一定大小时,就选取一些相互重叠的 tables 将其合并到 level 1,这样 level 1 中 tables 的 key 是相互不重叠的。此时我们最多只需要读 N+1 个文件,而 N 是比较小的,这样就达到了较好的读性能。
从上面的分析中似乎只要设计两层 level 就可以了,那为什么 LevelDB 还要多层的设计呢?设想一下随着 compaction 次数的增加,level 1 上的 tables 数量也会变得很多,这样极端情况下,level 0 上的一个 table 的 key range 可能会和 level 1 上所有tables 相重合,这样在进行 compaction 时就需要合并 level 1 上所有的 table,IO 开销太大。所以为了降低 compaction 时的 IO 开销,需要进行分层的设计。
3. compaction 过程源码分析
3.1 compaction 触发时机
读写数据时都会触发 compaction ,我们首先看下写数据时的触发流程:
写数据时主要调用DBImpl::Write()
接口,在Write()
接口中会调用MakeRoomForWrite()
接口,我们看下这个接口的内部逻辑:
Status DBImpl::MakeRoomForWrite(bool force) { //写数据时该值为false
mutex_.AssertHeld();
assert(!writers_.empty());
bool allow_delay = !force; //true
Status s;
while (true) {
if (!bg_error_.ok()) {
// Yield previous error
s = bg_error_;
break;
} else if (
allow_delay &&
versions_->NumLevelFiles(0) >= config::kL0_SlowdownWritesTrigger) {
//L0文件达到8个时会减慢写入速度,延迟1ms
// We are getting close to hitting a hard limit on the number of
// L0 files. Rather than delaying a single write by several
// seconds when we hit the hard limit, start delaying each
// individual write by 1ms to reduce latency variance. Also,
// this delay hands over some CPU to the compaction thread in
// case it is sharing the same core as the writer.
mutex_.Unlock();
env_->SleepForMicroseconds(1000);
allow_delay = false; // Do not delay a single write more than once
mutex_.Lock();
} else if (!force &&
(mem_->ApproximateMemoryUsage() <= options_.write_buffer_size)) {
// memtable的大小还没有达到指定size,可以直接往里写数据
// There is room in current memtable
break;
} else if (imm_ != nullptr) {
// 走到这一步说明memtable中已经没有空间可以写数据了,需要将memtable转化为immutable
// 但是immutable不为空,表明后台正在进行compact,需要等待compaction完成再进行转化
// We have filled up the current memtable, but the previous
// one is still being compacted, so we wait.
Log(options_.info_log, "Current memtable full; waiting...\n");
background_work_finished_signal_.Wait();
} else if (versions_->NumLevelFiles(0) >= config::kL0_StopWritesTrigger) {
// level 0上的文件数过多,需要compaction
// There are too many level-0 files.
Log(options_.info_log, "Too many L0 files; waiting...\n");
background_work_finished_signal_.Wait();
} else {
//走到这一步表明可以将memtable转化为immutable了
// Attempt to switch to a new memtable and trigger compaction of old
assert(versions_->PrevLogNumber() == 0);
uint64_t new_log_number = versions_->NewFileNumber();
Wri