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层和level+1层的边界进行扩展,有一种情况是user_key相同的两个key刚好作为sstable文件ss1和ss2的边界,即
- 在不扩大边界的情况下,在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。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。