这是本人在阅读leveldb源代码的基础上写的读书笔记,现贡献出来供大家交流之用。
内容主要来源于leveldb官网的文档和阅读leveldb的源码。
转载请注明出处,谢谢
互联网业务中大量的数据都是简单地key-value类型,查询时不需要复杂的关系数据块支持。在大量涌现出来的NoSql开源产品中,Leveldb是一个轻量级的,快速的以存储为目的的key-value存储引擎。
调研leveldb中各种存储机制的实现,调研leveldb的适用场景,为后续key-value存储系统开发提供存储引擎方面的参考。
Leveldb是Google开源的一个快速,轻量级的key-value存储引擎,以库的形式提供,没有提供上层c/s架构和网络通信的功能。
Leveldb存储引擎的功能如下:
ü 提供key-value存储,key和value是任意的二进制数据。
ü 数据时按照key有序存储的,可以修改排序规则。
ü 只提供了Put/Get/Delete等基本操作接口,支持批量原子操作。
ü 支持快照功能。
ü 支持数据的正向和反向遍历访问。
ü 支持数据压缩功能(Snappy压缩)。
ü 支持多线程同步,但不支持多进程同时访问。
Leveldb和传统的存储引擎,比如Innodb,BerkeleyDb最大的区别是leveldb的数据存储方式采用的是LSM(log-structured-merge)的实现方法,传统的存储引擎大多采用的是B+树系列的方法。
磁盘的性能主要受限于磁盘的寻道时间,优化磁盘数据访问的方法是尽量减少磁盘的IO次数。磁盘数据访问效率取决于磁盘IO次数,而磁盘IO次数又取决于数据在磁盘上的组织方式。
磁盘数据存储大多采用B+树类型数据结构,这种数据结构针对磁盘数据的存储和访问进行了优化,减少访问数据时磁盘IO次数。
B+树是一种专门针对磁盘存储而优化的N叉排序树,以树节点为单位存储在磁盘中,从根开始查找所需数据所在的节点编号和磁盘位置,将其加载到内存中然后继续查找,直到找到所需的数据。
目前数据库多采用两级索引的B+树,树的层次最多三层。因此可能需要5次磁盘访问才能更新一条记录(三次磁盘访问获得数据索引及行ID,然后再进行一次数据文件读操作及一次数据文件写操作)。
但是由于每次磁盘访问都是随机的,而传统机械硬盘在数据随机访问时性能较差,每次数据访问都需要多次访问磁盘影响数据访问性能。
LSM树可以看作是一个N阶合并树。数据写操作(包括插入、修改、删除)都在内存中进行,并且都会创建一个新记录(修改会记录新的数据值,而删除会记录一个删除标志),这些数据在内存中仍然还是一棵排序树,当数据量超过设定的内存阈值后,会将这棵排序树和磁盘上最新的排序树合并。当这棵排序树的数据量也超过设定阈值后,和磁盘上下一级的排序树合并。合并过程中,会用最新更新的数据覆盖旧的数据(或者记录为不同版本)。
在需要进行读操作时,总是从内存中的排序树开始搜索,如果没有找到,就从磁盘上的排序树顺序查找。
在LSM树上进行一次数据更新不需要磁盘访问,在内存即可完成,速度远快于B+树。当数据访问以写操作为主,而读操作则集中在最近写入的数据上时,使用LSM树可以极大程度地减少磁盘的访问次数,加快访问速度。
Leveldb采用LSM思想设计存储结构。各个存储文件也是分层的,新插入的值放在内存表中,称为memtable,该表写满时变为immutable table,并建立新的memtable接收写操作,而immutable table是不可变更的,会通过Compaction过程写入level0,数据被组织成sst的数据文件。level0的文件会通过后台的Compaction过程写入level1,level1的文件又会写入level2,依次类推。
sst(sortedtable)文件格式见后续的leveldb文件格式章节。
内存中的memtable的实现是通过SkipList(跳表)的数据结构实现的。
跳表是平衡树的一种替代的数据结构,但是和红黑树不相同的是,跳表对于树的平衡的实现是基于一种随机化的算法的,这样也就是说跳表的插入和删除的工作是比较简单的。
简单说一下跳表的核心思想:
如果是说链表是排序的,并且节点中还存储了指向前面第二个节点的指针的话,那么在查找一个节点时,仅仅需要遍历N/2个节点即可。
这基本上就是跳表的核心思想,其实也是一种通过“空间来换取时间”的一个算法,通过在每个节点中增加了向前的指针,从而提升查找的效率。
在跳表中,每个节点除了存储指向直接后继的节点之外,还随机的指向后代节点。如果一个节点存在k个向前的指针的话,那么陈该节点是k层的节点。一个跳表的层MaxLevel义为跳表中所有节点中最大的层数。而每个节点的层数是在插入该节点时随机生成的,范围在[0,MaxLevel]。
下面给出一个完整的跳表的图示:
在leveldb中,跳表的实现代码在/db/skiplist.h中,而在该结构的实现中,内存管理使用了arena内存管理,是leveldb实现的一个简易内存池。在memtable操作中分配的内存会在memtable被删除时统一释放,leveldb没有提供删除单个skiplist节点的接口。
leveldb中有这几类文件,
ü 数据文件以.sst结尾,用来存储数据和索引。
ü 日志文件以.log结尾,用来存储最近的数据更新操作。采用追加写的形式,每次更新操作都被追加到log文件的末尾,每个log文件对应当前的memtable,更新操作先写入log文件,然后更新memtable。当memtable被写入sst数据文件后,相应的log文件会被删除。
ü Manifest文件,Manifest文件列出了每个层(level)的数据文件构成,每个文件的key的范围以及其他的元信息。Manifest文件格式同log文件,对leveldb的数据文件级别更改(数据文件的增加和删除等)操作会追加到Manifest文件末尾。每次打开leveldb会对Manifest文件进行重构,重新生成一份精简的Manifest文件。
ü Current文件是一个简单的文本文件,其内容是当前使用的Manifest文件名。
ü 信息日至文件,存放leveldb的操作日志。
ü 其他文件,比如LOCK文件等,用来保证同一时刻只有一个进程打开该数据库。
leveldb的日志文件包括写数据的日志文件和Manifest文件,这些文件均采用追加写的方式增加记录。
其中写操作日志文件保存leveldb的写操作日志,用于在leveldb启动时进行内存数据(memtable)恢复,写日志文件是和memtable一一对应的,当memtable被Compact成一个表数据文件之后,对应的写操作日志文件会被删除。
Manifest文件也是一个日志文件,保存对leveldb对数据文件(sst)在文件级别所做的更改,比如增加了数据文件,删除了数据文件等。用于在leveldb启动时恢复当前的数据文件元信息。
日志文件被划分成32K大小的block,改大小可调。每个block中是一系列的记录。每条记录的格式是
record:=
checksum:uint32 //存放该记录data的crc32校验值
length:uint16 //data长度
type:uint8 //FULL,FIRST,MIDDLE,LAST,记录类型
data:uint8[length] //记录内容
写操作日志和Manifest文件写入的内容格式是LogRecord和VersionEdit格式化后形成的字符串,存放在每条记录的data字段中。
一条日志记录的头部信息占用7个字节,如果一个Block的剩余空间不足7个字节时,该Block尾部被填补为0,新的记录从下一个Block开始;如果一个Block尾部恰好只剩下7个字节,则填出一个长度为0的起始Record在该Block。
leveldb的数据存放在数据表中,对应于磁盘上的数据文件,以.sst结尾。数据文件一旦写入完成,其内容不可更改,当sst的数据被合并到新的sst文件之后,无用的sst文件会被删除。
sst文件的文件格式如下:
<beginning_of_file>
[data block 1]
[data block 2]
...
[data block N]
[meta block 1]
...
[meta block K]
[metaindex block]
[index block]
[Footer] (fixed size; starts at file_size - sizeof(Footer))
<end_of_file>
一个sst数据文件包含多个data block,多个种类的metablock,一个metaindex block和一个index block,文件的末尾存放的是一个固定长度的Footer类型。
其中,data block存放的是用户存储的key-value数据,data block是leveldb磁盘IO读取数据的基本单位,data block的默认大小为4kB,可以通过参数调整,此处的4kB大小指的是Block中未压缩的数据大小,数据存入sst之前默认会进行snappy压缩,压缩后的数据会小于4KB。各个Block数据在sst文件中是有序存放的。
Meta block存放另外的元信息数据,当前的leveldb(1.12.0版本)使用该block存放filterpolicy产生的filter数据,在查找数据时利用bloomfilter算法可以显著减少不存在的key导致的磁盘IO。
各个metablock会存放index到metaindexblock中。
每个block会存放一个index到index Block中,相当于该block的一个索引,存放的也是key/value结果,其中key是大于该block最大key的最短前缀,而value则是该Block的一个位置索引,结构为struct{offset,size};
Footer是一个固定的结构,存放了indexBlockHander+metaBlockHandler和一个8字节的magicNumber。
dataBlock中存储的每个key/value对儿也有自身的结构。每个key/value对儿称为一个record,需要注意的是,其中存储的key不同于用户提供的key,内部存储的key是将用户提供的key,操作的sequencenum和存储的值类型(普通value还是删除)进行编码之后形成的internal key。
Leveldb中的记录是key有序存储的,所以相邻的key有很大概率共享前缀。为了减少存储空间,leveldb的key采用前缀压缩存储,同时为了加快检索速度和避免丢失大量数据,每隔一定数量的record就会存储一个完整的key字符串,这种完整的record地址被称为Restart Point默认是每16个记录存储一个完整的key。
每个record的结构如下:
record:
shared_bytes: varint32 //key共享前缀长度,完整的key为0
unshared_bytes: varint32 //key私有数据长度
value_length: varint32 //value数据长度
key_delta: char[unshared_bytes] //key私有数据
value: char[value_length] //value
每个未压缩block结尾的结构如下:
blocktailer:
restarts: uint32[num_restarts] //restart pointer数组
num_restarts: uint32 //restart point数组长度
在存入sst数据文件之前,还要对以上结果进行snappy压缩,压缩后的数据作为content,再加上type和crc32作为block的完整数据存入sst文件,其中type标记该block是否进行了压缩。
Current文件时一个文本文件,文件内容是当前使用的Manifest文件的文件名,leveldb初始化时通过Current文件找到上次关闭时使用的manifest文件,然后进行数据初始化和恢复。Leveldb初始化完成时会将当前快照写入新的manifest文件并更新Current文件内容。
leveldb可以保留数据的对个版本,每次查询操作默认总是在最新版本上进行,而所有的写操作都是在最新版本进行。Leveldb的多版本控制通过Version,VersionSet和VersionEdit结构来实现。每个Version结构代表了leveldb所存储数据的一个视图,或者说一个版本。从本质上来看,Version是由磁盘文件构成的,注意Version不包含内存中的memtable和immutable table。
因为leveldb的数据文件一旦写入完成,其内容不会发生更改,每个Compaction过程会将现有的sst文件merge生成新的sst文件,或者将内存中的immutable table生成sst文件,每个Compaction过程会产生新的数据文件,旧的数据文件不再需要。这种文件级别的更改会形成一个VersionEdit结构写入manifest文件,供leveldb下次启动时回放;同时会将生成的VersionEdit结构和当前的Version进行合并操作形成新的Version来反映leveldb最新的数据文件信息,可以看出VersionEdit包含的是相邻版本之间的变化信息。
Leveldb可以保存多个版本,当形成新的Version时,旧的Version有可能正在被检索,所以无法必须保留。Leveldb采用VersionSet结构来管理多个Version结构,VersionSet本质上就是一个Version结构的双向链表。
为了管理Version和sst文件的生命周期,leveldb广泛采用了引用计数的方法。每生成一个新的Version,Version的引用计数被标记为1,当检索该Version时,该Version的引用计数会增加1;当产生了新的Version后,当前Version的引用计数会被减1,Version引用计数为0时会被删除,并且将其管理的数据文件的引用计数减少1,当数据文件的引用计数变为0时,数据文件会被删除。
这几个数据结构的关系简单描述如下:
VersionSet=Version1+Version2+Version3+...+currentVersion
Vn=Vn-1+VersionEdit
Leveldb支持快照功能。可以通过db->GetSnapshot()来建立新的当前快照。在检索时通过option->snapshot字段来指明检索的快照,默认是在当前数据上进行检索。
Levendb的快照实现并不是新建Version或者增加当前的Version的引用计数来实现的。因为Version只是反映了磁盘文件信息,并没有反映内存中的memtable和immutable table信息。
Leveldb的快照功能是通过写入序列号来实现的,leveldb将所有的写入操作(包括删除操作)都进行了序列号,每个写入操作都会有一个序列号,一个uint64类型的整数,高56位存储的是写入操作的序列号,低8位存储的是操作类型(Put/Delete)。Leveldb会将用户提供的key和序列号格式化后作为internalkey保存,我们记写入操作的序列号为seqw,用户查询操作会带上一个当前序列号或者快照的序列号进行查询,记查询时带的序列号为seqr,查询过程中只会查找序列号小于seqr的最大序列号的记录,如果某个记录的seqw>seqr,则在查询时该记录会被忽略。
这种机制是的leveldb可以保留多个版本的数据,保留的数据版本数理论上不受限制,另外,这种快照机制实现简单。
使用序列号来实现快照的方法对leveldb数据Compact过程也会造成影响,见Compaction相关部分。
leveldb的数据存储采用LSM的思想,LSM思想和log&dump思想很相近,大体来讲都是变随机写入为顺序写入,记录写入操作日志,一旦日志被以追加写的形式写入硬盘,就返回写入成功,由后台线程将写入日志作用于原有的磁盘文件生成新的磁盘数据。
Leveldb在内存中维护一个数据结构memtable,采用skiplist来实现,保存当前写入的数据,当数据达到一定规模后变为不可写的内存表immutable table。新的写入操作会写入新的memtable,而immutable table会被后台线程写入到数据文件。
Leveldb的数据文件时按层存放的,level0,level1,…,level7。默认配置的最高层级是7,内存中的immutable总是写入level0,除level0之外的各个层leveli的所有数据文件的key范围都是互相不相交的。
当满足一定条件时,leveli的数据文件会和leveli+1的数据文件进行merge,产生新的leveli+1层级的文件,这个磁盘文件的merge过程和immutable的dump过程叫做Compaction,在leveldb中是由一个单独的后台线程来完成的。
进行Compaction操作的条件如下:
1> 产生了新的immutable table需要写入数据文件;
2> 某个level的数据规模过大;
3> 某个文件被无效查询的次数过多;
a) 在文件i中查询key,没有找到key,这次查询称为文件i的无效查询。
4> 手动compaction;
满足以上条件是会启动Compaction过程,详细的Compaction过程见代码分析章节。
为了提高leveldb的数据读写效率,leveldb内部实现了cache功能模块。
Leveldb的Cache分为两个,block cache和table cahce。Block cache存放的是sst文件中的一个data block,这个cache的作用是缓存sst文件中的数据,减少磁盘IO。按照LRU(least-recently-used)的策略进行淘汰,这个cache由用户指定大小,要配置该Cache,则需要用户在初始化用特定Cache结构初始化options.block_cache字段,否则leveldb会自动建立一个8M的Blockcache使用。
Leveldb实现的另一个cache是tablecache,该cache是leveldb内部管理的,没有对外提供配置结构,每个table(sst文件)在打开时需要读indexblock,metaindexblock,建立相应的内存结构,leveldb会将这些结构进行缓存,以备将来使用,也是采用LRU的删除策略。
由于leveldb的代码比较多,有17000行左右,并且leveldb是一个存储引擎库,代码组织比较松散,本节先分析leveldb的代码结构及典型的访问流程,然后以流程为切入点,重点分析leveldb的初始化过程,读写流程及Compact过程,为了使得流程更加清晰,分析过程中使用的代码在原有的leveldb源代码基础上删掉了很多与理解流程关系不大的代码。
Leveldb的源代码组织结构如下:
核心目录包括
db目录,存放leveldb的实现代码。
include目录,存放leveldb的对位接口头文件。
port目录,存放leveldb的平台相关代码,锁和条件变量,原子操作的平台实现。
util目录,存放工具类代码,内存池,bloomfilter,cache,整形变长编码,crc32,hash,随机数,以及env的平台相关代码。
table目录,存放table相关的读写代码。
总的源代码行数在17000行左右。
对leveldb的典型访问是先打开db,进行读写操作,然后关闭db,代码示例如下:
#include
#include"leveldb/db.h"
leveldb::DB* db;
leveldb::Options options;
options.create_if_missing = true;
//打开db
leveldb::Status status = leveldb::DB::Open(options, "/tmp/testdb", &db);
assert(status.ok());
std::string value;
//读写db
leveldb::Status s = db->Get(leveldb::ReadOptions(), key1, &value);
if (s.ok()) s = db->Put(leveldb::WriteOptions(), key2, value);
if (s.ok()) s = db->Delete(leveldb::WriteOptions(), key1);
//关闭db
delete db;
DB::Open函数进行数据块初始化,打开或新建数据库,并且进行数据库恢复操作。
leveldb是C++编写的,db.h中定义了leveldb对外接口,定义了class DB,这个类只是一个接口类,具体的实现是在DBImpl类中实现的。DB::Open函数创建的是一个DBImpl类,具体的操作由DBImpl类来处理。
//open
Status DB::Open(const Options& options, const std::string& dbname,DB** dbptr) {
DBImpl* impl = new DBImpl(options, dbname);
//调用DBImpl的恢复数据接口,读取元数据,恢复日志数据
Status s = impl->Recover(&edit);
uint64_t new_log_number = impl->versions_->NewFileNumber();
//创建新的写操作日志文件
options.env->NewWritableFile(LogFileName(dbname, new_log_number),&lfile);
//添加VersionEdit,初始化时会将现在的VersionSet的状态写入新的manifest文件,并更新Current文件
impl->versions_->LogAndApply(&edit, &impl->mutex_);
//删除无用文件
impl->DeleteObsoleteFiles();
//查看是否启动后台打包进程
impl->MaybeScheduleCompaction();
}
Class DB只是leveldb对外的接口类,具体的实现是在类DBImpl中。DB::Open接口先建立了DBImpl实例,然后调用DBImpl的recove接口恢复数据,数据恢复完成后,创建新的写操作日志文件,并将现有的Version数据结构写入新的manifest文件,删除无用的文件,然后查看是否需要进行Compaction过程。
下边我们看下DBImpl::Recover的流程:
//recover
Status DBImpl::Recover(VersionEdit* edit) {
//建立进程锁,防止别的进程打开
env_->LockFile(LockFileName(dbname_), &db_lock_);
s = versions_->Recover();
//查找出有用的文件
versions_->AddLiveFiles(&expected);
//恢复其中尚未格式化成sst的log文件
for (size_t i = 0; i < logs.size(); i++) {
//读取日志文件,重建memtab,并将满的memtab格式化成sst,
//有可能修改edit,增加文件
s = RecoverLogFile(logs[i], edit, &max_sequence);
versions_->MarkFileNumberUsed(logs[i]);
}
}
DBImpl::Recover会调用VersionSet::Recover接口进行磁盘数据恢复,然后恢复写操作日志文件,重建memtable并写入磁盘。
我们看下VersionSet::Recover接口:
// versionsetrecover,从Manifest文件中恢复磁盘文件。
Status VersionSet::Recover() {
std::string current;
//读取current
Status s = ReadFileToString(env_, CurrentFileName(dbname_), ¤t);
std::string dscname = dbname_ + "/" + current;
//获得manifestfile
SequentialFile* file;
s = env_->NewSequentialFile(dscname, &file);
//读取Manifest文件,将各个VersionEdit的修改聚合到builder中,
while (reader.ReadRecord(&record, &scratch) && s.ok()) {
VersionEdit edit;
s = edit.DecodeFrom(record);
//利用builder来记录各个VersionEdit
builder.Apply(&edit);
}
delete file;
//创建新的Version并加入当前的VersionSet成为Current
Version* v = new Version(this);
builder.SaveTo(v);
AppendVersion(v);
}
VersionSet::Recover函数先读取curent文件,获取manifest文件名,然后读取manifest文件,获取各个VersionEdit,然后进行回放,生成leveldb上次关闭时的CurrentVersion作为当前的Version。
综合以上的流程代码片段,leveldb的数据初始化流程简略如下:
调用VersionSet::Recove接口,通过Current文件读取manifest文件并回放其中的VersionEdit,取得leveldb关闭时的磁盘文件状态作为当前Version,并将该状态写入新的manifest文件。
然后再恢复上次关闭leveldb时还没写入sst的log文件,生成新的memtable并且dump到磁盘。
然后再打开新的log文件,删除磁盘上的无用数据,完成leveldb的初始化操作。
Leveldb的写数据流程入口为DBImpl::Put和DBImpl::Delete,这两个文件时DBImpl::Write接口的封装,将写操作封装成WriteBatch传入DBImpl::Write进行操作,可见leveldb在内部是将单独的写操作也作为只有一个操作的批量写操作来进行的。
DBImpl::Write流程如下:
// Put,Delete最终调用Write进行写
DBImpl::Write(const WriteOptions& options, WriteBatch* my_batch){
//将写任务加入等待队列
Writer w(&mutex_);
w.batch = my_batch;
writers_.push_back(&w);
//查看是否可以写,如果mem,imm没有空间写,则调用后台打包进程
//有可能阻塞在这里。
MakeRoomForWrite(my_batch == NULL);
//从等待写队列中获取尽可能多的写任务
WriteBatch* updates = BuildBatchGroup(&last_writer);
//设置writebatch的起始sequence
WriteBatchInternal::SetSequence(updates, last_sequence + 1);
//写成功后的sequence
last_sequence += WriteBatchInternal::Count(updates);
//写入日志文件
log_->AddRecord(WriteBatchInternal::Contents(updates));
//写入memtable
WriteBatchInternal::InsertInto(updates, mem_);
//设置现在的最新更新sequence
versions_->SetLastSequence(last_sequence);
//在写的过程中别的线程又增加了写任务,通知线程进行写操作
if (!writers_.empty()) {
writers_.front()->cv.Signal();
}
}
Leveldb的写流程比较简单明了,将写封装成一个Writer任务压入队列(多线程同步),然后调用MakeRoomForWrite查看是否允许写,需要注意的是不允许写时,线程可能阻塞在该函数处;允许写时会获取write任务队列中尽可能对的写任务(要求sync选项一致)一次性写入,先写入log文件,然后再写入memtable就可以了,必要时唤醒别的等待线程。
需要注意一下MakeRoomForWrite函数,该函数会检查是否进行Compaction流程,该函数的定义如下:
//判断是否可写,为写操作作准备,必要时阻塞
Status DBImpl::MakeRoomForWrite(bool force) {
while (true) {
if (!bg_error_.ok()) {
s = bg_error_;//后台Compact出错
break;
}else if ( allow_delay && versions_->NumLevelFiles(0) >= config::kL0_SlowdownWritesTrigger) {
//如果L0文件多,则睡眠1ms
env_->SleepForMicroseconds(1000);
allow_delay = false;
}else if (!force &&
(mem_->ApproximateMemoryUsage() <= options_.write_buffer_size)) {
//memtable有空间,可以写
break;
}else if (imm_ != NULL) {
//imutable还没有被写入磁盘,等待后台线程写入磁盘后才能进行写。阻塞
bg_cv_.Wait();
}else if (versions_->NumLevelFiles(0) >= config::kL0_StopWritesTrigger) {
bg_cv_.Wait();//等待compact
} else {
//可写
uint64_t new_log_number = versions_->NewFileNumber();
s = env_->NewWritableFile(LogFileName(dbname_, new_log_number), &lfile);
log_ = new log::Writer(lfile);
imm_ = mem_;
mem_ = new MemTable(internal_comparator_);
MaybeScheduleCompaction();//检查是否启动后台compact
}
}
return s;
}
该函数检查是否可写,
如果后台Compact失败,则返回错误,
如果level0的文件数达到配置的SlowdownWritesTrigger(默认为8),则对每个写操作都延迟1ms,
如果level0的文件数达到配置的kL0_StopWritesTrigger(默认为12),则阻塞写操作,等待后台Compact结束。
如果memtable不满,则直接返回,可写。
如果memtable已满,并且immutable table不为空,阻塞,等待Compact结束。
否则,将memtable改为immutable table,新建memtable,返回可写。
Leveldb的读数据入口为DBImpl::Get函数,函数流程如下:
Status DBImpl::Get(const ReadOptions& options,const Slice& key, std::string* value) {
//获取版本号
snapshot = versions_->LastSequence();
//三个查找源,memtable,immutable,sstable
MemTable* mem = mem_;
MemTable* imm = imm_;
Version* current = versions_->current();
// First look in the memtable, then in the immutablememtable (if any).
//构造查找操作所需的内部key
LookupKey lkey(key, snapshot);
//在memtable中查找
if (mem->Get(lkey, value, &s)) {
// Done
} else if (imm != NULL && imm->Get(lkey, value, &s)) {////在imutable中查找
// Done
} else {
//在磁盘文件中查找,当前Version
s = current->Get(options, lkey, value, &stats);
have_stat_update = true;
}
//判断是否需要调度后台打包线程
if (have_stat_update &¤t->UpdateStats(stats)) {
MaybeScheduleCompaction();
}
}
该函数的流程也比较清晰,首先获取当前的版本号,然后分别在三个数据源memtable,immutable table,和sst表中找,返回之前再判断一下是否需要启动后台Compact任务。
memtable和immutable table都是内存中的skiplist,比较简单,我们重点分析下磁盘文件的查找。
磁盘文件的查找是通过Version::Get来进行的,函数定义如下:
//在当前版本的磁盘文件中查找
Status Version::Get(const ReadOptions& options, const LookupKey& k, std::string* value, GetStats* stats) {
//查找用户提供的key可能在的文件,通过各个level的文件的最小值,最大值来判断
//按层查找
for (int level = 0; level < config::kNumLevels; level++) {
//查找用户提供的key可能在的文件
uint32_t index = FindFile(vset_->icmp_, files_[level], ikey);
FileMetaData* f = files[i];
//在table_cache中查找key对应的value
s=vset_->table_cache_->Get(options, f->number, f->file_size,
ikey, &saver, SaveValue);
}
return s;
}
该函数首先查找key可能存在的sst表,然后调用table_cache->Get进行查找。
主要的操作集中在TableCache::Get函数中,该函数定义如下:
//在table_cache中查找key
Status TableCache::Get(const ReadOptions& options,uint64_t file_number,
uint64_t file_size,const Slice& k,void* arg,
void (*saver)(void*, const Slice&, const Slice&)) {
Cache::Handle* handle = NULL;
//查找table,没有则新建table结构并插入table_cache
FindTable(file_number, file_size, &handle);
Table* t = reinterpret_cast<TableAndFile*>(cache_->Value(handle))->table;
//在table中查找
s = t->InternalGet(options, k, arg, saver);
}
该函数流程很简单,先从table_cache中获取Table结构,没有则新建Table结构加入table_cache,然后调用Table::Get在具体的sst表中查找。
进一步查看Table::Get函数:
//在table中查找
Table::InternalGet(options, k, arg, saver){
Iterator* iiter = rep_->index_block->NewIterator(rep_->options.comparator);
//在索引中找,是否存在某个块可能包含这个key
iiter->Seek(k);
FilterBlockReader* filter = rep_->filter;
//filter
if (filter != NULL &&
handle.DecodeFrom(&handle_value).ok() &&
!filter->KeyMayMatch(handle.offset(), k)) {
//not found
}else{
//在具体的block中找
Iterator* block_iter = BlockReader(this, options, iiter->value());
block_iter->Seek(k);
if (block_iter->Valid()) {
(*saver)(arg, block_iter->key(), block_iter->value());
}
s = block_iter->status();
}
return s;
}
该函数的流程也比较清晰,Table::Get函数先在table的indexblock中查找该key所处的block,然后利用filter来过滤,最后在具体的block中查找。在查找过程中广泛使用了Iterator机制。
综合以上的分析,leveldb读写数据的流程如下:
1. 利用当前操作序列号构造查找key;
2. 在memtable和immutable table中查找;
3. 在当前Version的磁盘文件中查找,在查找时会利用TableCache来查找和管理Table数据结构。
4. 查找sst表时会从低层向高层查找,找到则返回。
5. 查找过程中各个block会通过filter进行过滤,减少不必要的磁盘IO。
6. 查找过程中会记录第一个进行了磁盘无效查找的sst,更新该文件的无效查找次数,当达到一定次数时会出发该文件的compact操作。
7. 查找结束后会调用MaybeScheduleCompaction判断是否需要出发Compact过程。
Leveldb进行Compaction的入口函数是DBImpl::MaybeScheduleCompaction,该函数在每次leveldb进行读写操作时都有可能被调用。该函数的流程如下:
//每次读和写操作都有可能会调用该函数
void DBImpl::MaybeScheduleCompaction() {
mutex_.AssertHeld();
if (bg_compaction_scheduled_) {
// Already scheduled
} else if (shutting_down_.Acquire_Load()) {
// DB is being deleted; no more backgroundcompactions
} else if (imm_ == NULL && manual_compaction_ == NULL && !versions_->NeedsCompaction()) {
//调用打包线程的情形,1>imm不为空;2>手工打包;3>某层文件数过多;4>某个文件被无用查询的次数过多[查找了该文件但没找到key对应的value];
// No work to be done
} else {
bg_compaction_scheduled_ = true;
//新建后台任务并进行调度
env_->Schedule(&DBImpl::BGWork, this);
}
}
该函数首先判断是否需要启动后台Compact任务,启动Compact的条件如下:
1.imm不为空;2.手工Compact请求;3.某层文件数过多;4.某个文件被无效查询次数过多。并且后台线程不在运行并且数据库不处于关闭状态。
满足条件时会调用Env::Schedule()函数启动Compact过程。
PosixEnv::Schedule的大体流程如下:
//调度后台任务,将任务压入后台队列,建立/唤醒后台线程进行处理
void PosixEnv::Schedule(void (*function)(void*), void* arg) {
PthreadCall("lock", pthread_mutex_lock(&mu_));
// Start background thread if necessary
if (!started_bgthread_) {
started_bgthread_ = true;
pthread_create(&bgthread_, NULL, &PosixEnv::BGThreadWrapper, this));
}
//后台任务队列
if (queue_.empty()) {
pthread_cond_signal(&bgsignal_)
}
queue_.push_back(BGItem());
queue_.back().function = function;
queue_.back().arg = arg;
pthread_mutex_unlock(&mu_);
}
该函数很简单,如果没有后台线程,则创建后台线程,否则新建一个后台执行任务BGItem压入后台线程任务队列,然后唤醒后台线程。
我们看下后台线程的执行流程:
//后台执行线程,不断地从后台任务队列中拿到任务,然后执行任务
void PosixEnv::BGThread() {
while (true) {
pthread_mutex_lock(&mu_);
while (queue_.empty()) {
pthread_cond_wait(&bgsignal_, &mu_);
}
void (*function)(void*) = queue_.front().function;
void* arg = queue_.front().arg;
queue_.pop_front();
pthread_mutex_unlock(&mu_);
(*function)(arg);
}
}
该函数很简单,不停的从后台任务队列中获取任务,然后执行。
关键在于后台任务的分析,后台任务最重调用的函数是BackgroundCompaction,该函数的定义如下:
//后台Compaction任务
Status DBImpl::BackgroundCompaction() {
if (imm_ != NULL) {
return CompactMemTable();
}
Compaction* c;
bool is_manual = (manual_compaction_!= NULL);
//取得手动打包对象
if (is_manual) {
ManualCompaction* m = manual_compaction_;
c = versions_->CompactRange(m->level, m->begin, m->end);
}else{
//取得自动打包对象
c = versions_->PickCompaction();
}
//进行compaction
status = DoCompactionWork(compact);
CleanupCompaction(compact);
// input的文件引用计数减少1
c->ReleaseInputs();
//删除无用文件
DeleteObsoleteFiles();
//标记手动Compaction任务完成
if (is_manual) {
ManualCompaction* m = manual_compaction_;
m->done = true;
}
}
该函数流程也比较好理解,首先生成一个Compaction对象,然后调用DoCompactionWork函数进行Compact操作,完成后文件引用计数减少1。
我们再跟踪下DoCompactionWork函数,Compaction的主要工作在该函数中完成。
//具体的Compaction过程,不允许覆盖snapshot。
Status DBImpl::DoCompactionWork(CompactionState*compact) {
//将所有的input文件的内容访问封装进Iterator,通过该
// iter,将所有input的表key按照从小到大进行遍历
Iterator* input =versions_->MakeInputIterator(compact->compaction);
//遍历所有内容,关闭db时马上退出
for (; input->Valid() && !shutting_down_.Acquire_Load(); ) {
//如果存在imutable,则打断当前Compaction,优先进行immutable的Compaction
if (has_imm_.NoBarrier_Load() != NULL) {
CompactMemTable();
}
//标记一个key/value对是否应该被丢弃
bool drop = false;
//新出现的key/value对
if (!has_current_user_key ||user_comparator()->Compare(ikey.user_key,
Slice(current_user_key)) != 0) {
current_user_key.assign(ikey.user_key.data(), ikey.user_key.size());
has_current_user_key = true;
last_sequence_for_key =kMaxSequenceNumber;
}
//如果该key/value之前出现过,并且之前出现的sequence小于snapshot的id,可以丢弃该key
if (last_sequence_for_key <= compact->smallest_snapshot) {
drop = true;
}else if (ikey.type == kTypeDeletion &&
ikey.sequence<= compact->smallest_snapshot &&
compact->compaction->IsBaseLevelForKey(ikey.user_key)) {
drop = true;
}
last_sequence_for_key = ikey.sequence;
//将key/value对儿写入builder
if (!drop) {
compact->builder->Add(key,input->value());
}
//将完整的builder输出到sstable文件中
if (compact->builder->FileSize() >=compact->compaction->MaxOutputFileSize()) {
status =FinishCompactionOutputFile(compact, input);
}
input->Next();
}
// compaction后,生成VersionEdit并且合并到Current中
InstallCompactionResults(compact);
}
该函数的定义很长,简化后流程依然比较复杂。首先通过Iterator访问所有的InputFile,大大简化了对数据的merge过程,数据的merge在Iterator中完成,每次iter->Next调用总是返回下一个最小的key。
如果Compact过程中db被关闭则马上退出。
如果Compact过程中生成了immutable table,则终端当前的Compact,优先将immutable table输出到sst表后再继续Compact流程。
在Compact过程中需要充分考虑到对Snapshot的影响。如果用户通过GetSnapshot建立了快照,则更高版本的数据不能覆盖该Snapshot的数据。在不影响快照的情况下,高版本的数据可以覆盖低版本的数据。
输出的数据利用TableBuilder,输出到新的sst表中。
Compact过程完成后,通过InstallCompactionResults会生成VersionEdit,并作用到Current的Version上形成新的Version。
leveldb内存中的memtable满后会通过CompactMemtable写入level0中的sst表,Compaction过程也会对sst表进行写操作,本节分析下sst表的写操作过程。
//build table
Status BuildTable(const std::string& dbname,
Env* env,
const Options& options,
TableCache* table_cache,
Iterator* iter,
FileMetaData* meta) {
//获得新建表名字
std::string fname = TableFileName(dbname, meta->number);
//建立新的表文件,后续写入数据
env->NewWritableFile(fname, &file);
//建立TableBuilder
TableBuilder* builder = new TableBuilder(options, file);
//将key/value对加入builder
for (; iter->Valid(); iter->Next()) {
builder->Add(key, iter->value());
}
//构建indexhandler,metahandler,写入文件
builder->Finish();
//写入文件
file->Sync();
//将表结构加入表缓存
table_cache->NewIterator(ReadOptions(),
meta->number,
meta->file_size);
}
该函数利用iter向TableBuilder中加入key/value对儿,然后写入文件并同步,
将新生成的Table结构加入tablecache以备后用。
TableBuilder::Add函数流程如下:
//向tablebuilder中添加记录
void TableBuilder::Add(const Slice& key, const Slice& value) {
if (r->pending_index_entry) {//新的block开始
r->options.comparator->FindShortestSeparator(&r->last_key, key);
r->pending_handle.EncodeTo(&handle_encoding);
r->index_block.Add(r->last_key, Slice(handle_encoding));
r->pending_index_entry = false;
}
//计算filter
if (r->filter_block != NULL) {
r->filter_block->AddKey(key);
}
//加入blockbuilder
r->last_key.assign(key.data(), key.size());
r->num_entries++;
r->data_block.Add(key, value);
// block大于配置的尺寸(默认为4k)则结束该block,输出后开启新的Block。
if (estimated_block_size >= r->options.block_size) {
Flush();
}
}
将Block结构写入文件的函数流程如下:
//将block写入文件
void TableBuilder::WriteBlock(BlockBuilder* block, BlockHandle* handle) {
//取得block格式化数据
Slice raw = block->Finish();
//获取是否压缩配置选项
CompressionType type = r->options.compression;
if (port::Snappy_Compress(raw.data(), raw.size(), compressed) &&
compressed->size() < raw.size() - (raw.size() / 8u)) {
//压缩<12.5%时不会进行压缩。
block_contents = *compressed;
}
//进行压缩后,然后写入文件,blockdata+type+crc32
WriteRawBlock(block_contents, type, handle);
}
而TableBuilder::Finish的函数定义如下:
//将元信息写入文件并结束
Status TableBuilder::Finish() {
Rep* r = rep_;
Flush();//将block数据写入,可能不是满的block
// Write filter block
WriteRawBlock(r->filter_block->Finish(), kNoCompression,&filter_block_handle);
// Write metaindex block
if (ok()) {
BlockBuilder meta_index_block(&r->options);
if (r->filter_block != NULL) {
filter_block_handle.EncodeTo(&handle_encoding);
meta_index_block.Add(key, handle_encoding);
}
}
WriteBlock(&meta_index_block, &metaindex_block_handle);
// Write index block
if (ok()) {
if (r->pending_index_entry) {
r->index_block.Add(r->last_key, Slice(handle_encoding));
}
WriteBlock(&r->index_block, &index_block_handle);
}
// Write footer
Footer footer;
footer.set_metaindex_handle(metaindex_block_handle);
footer.set_index_handle(index_block_handle);
footer.EncodeTo(&footer_encoding);
r->status = r->file->Append(footer_encoding);
return r->status;
}
而表的读取操作已经在leveldb的数据读取流程中讲述,此处不再重复。
使用Leveldb作为key-value存储系统的存储引擎,一个关键的问题是如何进行数据迁移。存储系统的节点间的数据迁移,总体要求是自动完成,在数据迁移期间不影响节点的读写,如果要节点停止写服务,也要使停写时间最小化。
存储集群扩容时需要进行数据迁移。
一种可行的采用leveldb的存储节点之间的数据迁移方案如下:
S:数据源节点,D:数据迁移目的节点
1> D向S发送请求,请求迁移部分数据,需要明确告诉S节点需要迁移的key范围,以及日志接收端口。
2> S节点接受到请求后进行如下操作:
a) 建立新的Snapshot,S1。
b) 建立标记位,后续新增的在迁移范围内的写操作在本节点继续执行之外,还需要发送给D节点。
3> S节点启动后台线程,在S1基础上建立iterator,轮训数据,然后发送给D节点。同时,新增的写操作的日志也需要发送给D节点。
4> D节点迁移过程中的行为
a) 接收到普通的数据,写入本地数据库;
b) 接收到的是新的写操作日志,写入本地日志文件。
c) 普通数据全部接收完成后,反演新的写操作日志文件。
d) b,c完成后告诉S节点停止迁移数据范围内的写操作。
5> S节点接收到D节点发送的迁移完成命令后停止已迁移数据范围的服务。
删除S1,并且轮训现有数据,删除已迁移数据。
6> 客户端再方位S节点,发现key已经不由S节点负责,则联系元数据节点,更新元数据,然后访问D节点。
这种迁移方案简单易行,并且迁移过程中不妨碍S节点的读写服务,D节点发现数据前已完成后通知S节点停服务,当S节点也确定所有数据都迁移完成后,D节点开始进行服务。这期间的停写服务的时间应该在秒级别。
本方案还有一个可优化的地方,就是迁移数据时可以分别向多个数据节点(同一个group)请求数据,加快数据迁移速度,但是写日志的还是从主节点同步,保证一致性。
Leveldb是一个轻量级,高效,快速的key/value存储引擎。通过阅读leveldb的代码可以看到leveldb的代码组织良好,实现高效,代码流程相对清晰,不依赖其他的外部库,比较独立,很适合在此基础上进行二次开发。
Leveldb官网上给出来的性能测试数据是在数据集完全放在内存的前提下测出来的,大规模数据存储不能以官网的高性能数据作为参考。
Leveldb支持Put/Get/Delete的简单接口,支持快照,在进行后台Compact时不会阻塞读写操作。
支持批量的原子操作,支持遍历访问。
Leveldb实现中广泛使用了Iterator来简化数据的遍历和Merge过程,代码实现可读性很高。
http://leveldb.googlecode.com/svn/trunk/doc/index.html
leveldb源代码