前言
前面我们说过,leveldb用户通过调用write或者put函数向数据库中写入数据实际上是将数据写入到levedb的Memtable中。我们也曾经提到过,leveldb中有两个MemTable,分别是imm_和mem_,其中imm_是不可写的,因此实际上我们将数据写入到mem_中。leveldb提供持久化,也就是需要将内存中的数据保存到磁盘上,也就是前面说的以sstable的形式将数据持久化。在leveldb中,内存中的每个memtable对应磁盘上的每个sstable,一般情况下我们不希望文件太大,因此必须控制memtable中的数据量,当达到一定的阀值时就要将其写盘。leveldb提供异步写盘的方式,这就是imm_的作用,每次mem_中的数据够多时,就将mem_复制给imm_(两者都是指针,所以复制操作很快),并让mem_指向一个重新申请的memTable。交换之前保证imm为空,然后mem_就可以继续接受用户的数据,同时leveldb开启一个背景线程将imm_写入磁盘。
本文介绍的MakeRoomForWrite是在write函数中调用,以确保mem_有空间可写。如果没有空间可写,就要执行上面的一些列操作。下面我们以这个函数为入口分析一下这个过程。
MakeRoomForWrite 函数的实现
Status DBImpl::MakeRoomForWrite(bool force) {
mutex_.AssertHeld();
assert(!writers_.empty());
bool allow_delay = !force;
Status s;
这几行就是确定该函数是在临界区中执行的,以及设置是否允许延迟写入的标记。
后面就是一个大的while循环,该循环会一直执行,直到mem_中有空间可供写。
while (true) {
if (!bg_error_.ok()) { //背景线程执行有错误,直接返回
// Yield previous error
s = bg_error_;
break;
}
这个条件主要是判断背景线程是否出错,如果出错就直接返回
else if (
allow_delay &&
versions_->NumLevelFiles(0) >= config::kL0_SlowdownWritesTrigger) {//level 0 中的文件过多,则需要后台线程compact,延迟写
// 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();
}
这里是判断是否符合延迟写的条件。什么是延迟写呢?就是现在不能马上将数据写入mem_中,需要等一会,因此线程会进入睡眠一段时间。那满足延迟写的条件是什么呢?这里需要了解一下leveldb是怎么在磁盘上存储sstable文件的。
leveldb之所以名字里面有level这个词,主要是因为leveldb将磁盘上的sstable文件分层(level)存储。每个内存中的Memtable所生成的sstable文件在level 0 中,高一层的level文件由低一层的level文件compact而成,或者说合并而成。最开始的时候只有level0中有文件,随着memtable不断生成sstable文件,level0中的文件最终会达到一个数量,当这个数量大于某个阀值时,就选择level0中的若干个sstable文件进行合并,并把合并后的文件放入到level1中,当level1中的文件过多时,level1中的部分文件也将会合成新的sstable文件,放入level2中,以此类推。除了level0之外,其他level中的文件的key是不允许重复的,因此我们这里所说的level i中的部分文件合成放入level (i+1) i>1,是指将level i中的那部分文件和level i+1中和它的key重合的文件进行合并,而不是至单独level i中的文件合并。后面我们会仔细介绍不同level中的文件是怎么合并的。
回到上面的函数,如果发现level 0 中的文件过多,则说明需要背景线程进行合并,因此需要等待背景线程合并完成之后再写入,所以延迟了写。
else if (!force &&
(mem_->ApproximateMemoryUsage() <= options_.write_buffer_size)) { // mem_中有空间可供写,则返回
// There is room in current memtable
break;
}
这个就很简单了,判断mem_中是否有足够的空间,如果mem_中还有空间就直接break 返回了。我们会注意到,while循环只有在这里才会正确地返回,也就是说,这个while循环会一直继续下去直到发生错误或者mem_有足够空间可写
else if (imm_ != NULL) { //mem_没有空间
// 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");
bg_cv_.Wait();
}
当执行到这里,说明mem_没有足够的空间可供写,此时试图将mem_赋值给imm_,然后重新分配一个mem_供用户写,但在此之前必须先检查imm_是否为空,如果它不为空的话,说明上次赋值给imm_的mem还没有被背景线程写入磁盘,那只能等待了,不然就会覆盖掉之前的mem_。
else if (versions_->NumLevelFiles(0) >= config::kL0_StopWritesTrigger) { //暂停写
// There are too many level-0 files.
Log(options_.info_log, "Too many L0 files; waiting...\n");
bg_cv_.Wait();
}
好了,现在说明imm_是空的,也就是上一个需要写盘的mem_已经写入完成了,现在可以把当前的mem_复制给imm_了,以供背景线程继续将它写盘了。但是事实上还不可以。因为之前将imm_写盘完成后,level 0 中的sstable又增加了一个文件,我们必须判断此时level 0 中的文件数量是否太多,太多的话那就还得等一会
else { //imm_为空,mem_没有空间可写
// Attempt to switch to a new memtable and trigger compaction of old
assert(versions_->PrevLogNumber() == 0);
uint64_t new_log_number = versions_->NewFileNumber();
WritableFile* lfile = NULL;
s = env_->NewWritableFile(LogFileName(dbname_, new_log_number), &lfile);
if (!s.ok()) {
// Avoid chewing through file number space in a tight loop.
versions_->ReuseFileNumber(new_log_number);
break;
}
delete log_; //每个log对应一个mem_,因为在这之后这个mem_将不再改变,所以不再需要这个log了
delete logfile_;
logfile_ = lfile;
logfile_number_ = new_log_number;
log_ = new log::Writer(lfile);
imm_ = mem_; //后台将启动对imm_ 进行写磁盘的过程
has_imm_.Release_Store(imm_);
mem_ = new MemTable(internal_comparator_);
mem_->Ref();
force = false; // Do not force another compaction if have room
MaybeScheduleCompaction(); // 有可能启动后台compaction线程的地方(因为可能后台compactiong线程已经启动了),后台只对imm_处理,不会对mem_处理
}
好了,现在条件的都满足了,我们可以把mem_复制给imm_,并为mem_重新分配一个新的Memtable了。这里还涉及一些日志文件的操作,我们且不管它。
一切设置ok后,现在就可以开启背景线程对imm_写盘了,而且当前线程也会回到前面的检查mem_空间是否有足够空间的地方,并在那里返回write函数,因为此时mem_也有足够空间可写了(刚刚分配的新Memtable)。不管怎么样,从MakeRoomForWrite函数返回之后,mem_中都会有足够空间可写了
最后的MaybeScheduleCompaction就是开启一个背景线程。背景线程主要做两件事情
1. 将imm_写入磁盘生成一个新的sstable
2. 对各个level中的文件进行合并,避免某个level中的文件过多,以及删除掉一些过期或者已经被用户调用delete删除的key-value。
DBImpl::MaybeScheduleCompaction函数的实现
这个函数试图启动一个新的线程,因此它不是肯定会启动一个背景线程。
void DBImpl::MaybeScheduleCompaction() {
mutex_.AssertHeld();
if (bg_compaction_scheduled_) {
// Already scheduled
} else if (shutting_down_.Acquire_Load()) {
// DB is being deleted; no more background compactions
} else if (!bg_error_.ok()) {
// Already got an error; no more changes
} else if (imm_ == NULL &&
manual_compaction_ == NULL &&
!versions_->NeedsCompaction()) {
// No work to be done
} else {
bg_compaction_scheduled_ = true;
env_->Schedule(&DBImpl::BGWork, this); // start new thread to compact memtable , the start point is "BGWork"
}
}
从这里我们可以看到,每个时刻,leveldb只允许一个背景线程存在
这里需要加锁主要也是这个原因,防止某个瞬间两个线程同时开启背景线程。
当确定当前数据库中没有背景线程,也不存在错误,同时确实有工作需要背景线程来完成,就通过env_->Schedule(&DBImpl::BGWork, this)启动背景线程,前面的bg_compaction_scheduled_设置主要是告诉其他线程当前数据库中已经有一个背景线程在运行了。
背景线程入口是BGWork
BGWork -> BackgroundCall
背景线程所需要完成的工作在BackgroundCall中。
void DBImpl::BackgroundCall() {
MutexLock l(&mutex_);
assert(bg_compaction_scheduled_);
if (shutting_down_.Acquire_Load()) {
// No more background work when shutting down.
} else if (!bg_error_.ok()) {
// No more background work after a background error.
} else {
BackgroundCompaction();//背景线程的核心工作
}
bg_compaction_scheduled_ = false;
// Previous compaction may have produced too many files in a level,
// so reschedule another compaction if needed.
MaybeScheduleCompaction();
bg_cv_.SignalAll(); // 后台的compaction操作完成
}
前两个判断语句主要是判断当前数据库是不是被关闭以及背景线程是否出错了。shutting_down_只会在DB的析构函数中被设置。
如果都没有问题那就进入else里面的BackgroundCompaction处理背景线程的核心工作。
正如我们前面所说,BackgroundCompaction里面主要处理两个工作:
compaction(合并)部分的内容我们后面再说。
背景线程完成compaction工作之后,它会尝试继续开启一个背景线程。因为在背景线程执行的时候,其他的用户线程可能已经向mem_写入了很多数据,而imm_在BackgroundCompaction已经被写入磁盘变为空,所以可能此时imm又已经被写满的mem_赋值了,所以应该尝试继续开启新的线程对imm_进行写盘。
不管开启新的背景线程是否成功,当前这个已经完成任务的老旧背景线程都将结束。其实从MaybeScheduleCompaction函数中我们可以看到,只要没有shut_down和背景线程没有出错,一直都会有一个背景线程在后面运行
bg_cv_.SignalAll()将会唤醒睡眠的用户线程,因为当一个背景线程执行完成之后,用户线程的执行条件可能已经满足了,比如level0中经过合并后,没有那么多文件了。
总结
leveldb中将文件分成多个level进行存储,每次写入磁盘的memtable都将生成一个新的sstable,放在level 0 中。为了避免一个level中有过多的文件,以及避免过期的key-value占空间,同时实际删除掉那些被用户删除的key-value,leveldb会不时通过背景线程完成文件的合并工作,合并后的文件都会被放在比它高一层的level中。每个时刻系统中只允许一个背景线程,背景线程负责两个工作:1. 将imm_写盘。2. 对level之间的文件进行合并。imm_始终记录的是上一个写满的mem_,每当一个mem_写满时,它都会赋值给imm_,同时重新分配一个Memtable给mem_,这样就可以避免将memtable写盘时影响用户写数据。后面我们将详细介绍leveldb中各个level之间的合并过程。