前言
日志就是记录数据库增删记录的文件。之所以需要记录这些东西,主要是为了防止万一数据库运行期间异常崩溃导致的数据丢失。而之所以会出现数据丢失,原因在于我们在往数据库中写数据时,并不是真的将数据库写入到了磁盘中,而可能只是将数据暂存到内存中而已。如果在数据flush到磁盘之前系统崩溃(数据库bug,操作系统bug,机器故障等等),那缓存在内存中的数据就会丢失,而用户以为这些数据已经成功写入数据库了(这也是插入数据时数据库告知用户已经成功插入了)。下次重启时,用户会因为在数据库中找不到自己插入的数据而迷惑不解。
日志系统的作用就是保证插入到缓存中的数据也不会丢失。
leveldb日志系统组成
leveldb的日志文件主要分为三种:
类型1的日志是日志恢复的核心,因为只有把所有写到数据库中的key-value都用日志记录下来,后续才有可能进行故障恢复。类型2是为了在每次数据库启动时都可以还原历史版本信息。类型3的日志则只包含一行记录,这行记录就是manifest日志的文件名。
下面我们从代码层面跟踪日志系统的操作流程
日志系统的操作流程 —— key-value日志文件
用户通过DBImpl::Write函数向数据库中写入数据,根据之前的分析,这里的写入并不是真的写入磁盘,而是写入内存的memtable中。在DBImpl::Write函数中,我们可以看到在将数据写入memtable之前,leveldb先将数据写入log_中:
status = log_->AddRecord(WriteBatchInternal::Contents(updates)); // 写日志
而且如果我们深入到AddRecord函数中就会发现,它在将每个数据写入时都会flush,这样保证写入日志中的数据都实际地写入到了磁盘中。这里可能会觉得写日志会不会效率太低,其实不然,因为写日志是顺序写,和随机写相比,效率要高得多。
问题在于,这里的log_会记录历史上所有加入到数据库中的key-value吗?如果这样的话,日志文件实在就太大了。下面我们看到,其实每个memtable都会生成一个日志文件,而log_日志文件总是记录的是当前memtable中的key-value,也就是说当这个memtable转变成imm_等待被写入磁盘时,就会另生成一个log_,这个新的log_指向新的mem_。这个我们可以从DBImpl::MakeRoomForWrite函数中找到端倪:
logfile_number_ = new_log_number;
log_ = new log::Writer(lfile);
imm_ = mem_; //后台将启动对imm_ 进行写磁盘的过程
has_imm_.Release_Store(imm_);
mem_ = new MemTable(internal_comparator_);
即使为每个memtable生成一个日志文件,使得单个日志文件不至于太大,但是随着写操作的进行,总的日志文件还是可能太大了。理所当然的想法是当memtable真正写盘时,将它对应的日志文件从磁盘上删掉。leveldb也是这样实现的,这部分代码在DBImpl::CompactMemTable函数中。
void DBImpl::CompactMemTable() {
..........
Status s = WriteLevel0Table(imm_, &edit, base);
.......
.......
if (s.ok()) {
edit.SetPrevLogNumber(0);
edit.SetLogNumber(logfile_number_);
s = versions_->LogAndApply(&edit, &mutex_);
}
if (s.ok()) {
// Commit to the new state
imm_->Unref();
imm_ = NULL;
has_imm_.Release_Store(NULL);
DeleteObsoleteFiles();
}
......
}
WriteLevel0Table之后就表示imm_已经被写入磁盘了。此时它所对应的日志文件就不再需要了。但是怎么找到它所对应的日志文件呢?从之前MakeRoomForWrite中的代码中我们知道,logfile_number_是表示指向当前mem_的log_文件号。而指向当前这个被写入到磁盘的imm_的日志文件号肯定比logfile_number_小。进入DeleteObsoleteFiles();函数我们就可以看到该函数将所有过期日志文件都删掉,过期日志文件的判断标准就是:
((number >= versions_->LogNumber()) ||
(number == versions_->PrevLogNumber()));
因此imm_所对应的日志文件会被删掉。所以最坏的情况是当前数据库中存在两个key-value日志文件,一个是指向mem_的,还在不断写入,一个是指向imm_的,还没来的及删掉。Version里面有一个log_number_的数据成员,这个数据成员所表示的日志文件总是指向最旧的没有写入磁盘的memtable。当imm_没有写入时,他就指向imm_,imm_写入时,它就指向当前的mem_。这样就可以保证,只要memtable在内存中,则它所对应的日志文件不会被删除。
以上就是记录写入key-value时的日志文件操作流程了。
记录版本变化的日志文件 —— MANIFEST文件
这种类型的日志文件信息主要是为了在数据库重启时恢复版本信息。版本信息是非常重要的,它记录着数据库中的各个文件所属level。没有它,系统根本都不能工作。
前面的博文分析过,VersionSet通过LogAndApply函数将VersionEdit应用到当前版本信息,然后生成一个新的版本,并将这个新的版本链接到VersionSet的历史版本链表中。将版本修改信息写入MANIFEST日志文件主要也是在LogAndApply中完成。在LogAndApply中,leveldb首先将版本修改信息应用到当前版本得到一个新的版本V,但是此时还没有将系统的当前版本信息(Current_)指向V,然后将版本修改信息写入到descriptor_log_文件中,最后才将当前版本信息指向这个新生成的版本信息V。
这个套路和前面的key-value日志文件的套路一样:先写日志,然后才写内存。这样是为了防止写内存后误以为已经操作成功,而此时系统崩溃将造成不可恢复的惨状,因为还没有写日志,根本不能恢复。
具体细节看LogAndApply就可以知道了。
CURRENT日志文件
最后一个日志文件类型是CURRENT日志文件。CURRENT日志文件记录的是指向MANIFEST日志文件的指针。其实就是manifest文件的文件名。这个就很简单了,就是在第一次生成MANIFEST日志文件时,将这个日志文件名加入到CURRENT文件中即可。这个我们可以从LogAndApply看到。
下面我们通过解析DB::Open->DBImpl::Recover函数对整个日志系统做一个总结。
DBImpl::Recover函数的实现
Status DBImpl::Recover(VersionEdit* edit, bool *save_manifest) {
mutex_.AssertHeld();
// Ignore error from CreateDir since the creation of the DB is
// committed only when the descriptor is created, and this directory
// may already exist from a previous failed creation attempt.
env_->CreateDir(dbname_);
assert(db_lock_ == NULL);
Status s = env_->LockFile(LockFileName(dbname_), &db_lock_);
if (!s.ok()) {
return s;
}
if (!env_->FileExists(CurrentFileName(dbname_))) {
if (options_.create_if_missing) { // 如果当前目录下没有Current文件,则新建一个
s = NewDB();
if (!s.ok()) {
return s;
}
} else {
return Status::InvalidArgument(
dbname_, "does not exist (create_if_missing is false)");
}
}
这里就是检查CURRENT文件是否存在,本意是想通过CURRENT文件找到manifest日志文件,然后在内存中恢复版本信息。
s = versions_->Recover(save_manifest);
这部分就是根据CURRENT日志文件对版本信息进行恢复。当这个函数返回时,leveldb的Versionset实例中就会存在一个最新的版本信息,这个最新的版本信息就是系统上次关闭时的最新版本信息。根据这个版本信息就可以找到各个level中的sstable文件。
for (size_t i = 0; i < filenames.size(); i++) {
if (ParseFileName(filenames[i], &number, &type)) {
expected.erase(number);
if (type == kLogFile && ((number >= min_log) || (number == prev_log)))
logs.push_back(number);
}
}
这里我们需要注意的是日志文件的数组。这个数组包含了所有未过期的key-value日志文件,这种日志文件的存在主要由于以下几种情况:
其中2可能包含1,因为imm_正在写盘时,客户可能也正在向mem_写入数据。如果此时系统崩了,则在恢复时应该对这两个memtable指向的日志文件进行恢复。
for (size_t i = 0; i < logs.size(); i++) {
s = RecoverLogFile(logs[i], (i == logs.size() - 1), save_manifest, edit,
&max_sequence);
这里就是对所有没过期的key-value日志文件的恢复,他确保了每个写入memtable的键值对最终都将写入磁盘中,不会丢失。在恢复时可能会引起重复写,比如如果上次系统正在写imm_入盘时突然崩溃,则重启时,imm_中那部分已经写盘的数据还会再次写盘。当然,这关系不大,因为后面compaction就好了。
总结
日志系统在数据库系统中至关重要,因为实际当中在向数据库中写入数据时不会直接写盘,而是一般先缓存在内存当中,而这对于用户来说是不可见的,也就是说当用户以为数据已经写入数据库时,实际上只是写入到了内存中,这就导致当系统突然故障停机丢失缓存内容时会出现不一致:用户以为已经写入了数据库,但实际却在用户不知道的情况下丢失了。日志系统保证了用户认为写入成功的数据实际上一定会写入成功,不会在用户不知情的情况下出现丢失,即使系统出现突发性故障停机。leveldb的日志系统主要分为三种日志文件:存储key-value的日志文件,每一个这样的日志文件对应一个memtable,当memtable写盘成功时,响应的日志文件会被删除;存储版本变化信息的manifest日志文件,它记录了数据库运行过程中的每次版本变化信息,这种日志主要是为了在系统重启时恢复数据库上次关闭时的最新版本信息;最后一种是CURRENT日志文件,这种日志文件包含有一个指向当前系统正在使用的manifest日志文件的指针。