数据库3:循序渐进 —— 操作接口
这一节将介绍数据库操作3个接口的实现,分别是Get、Put和Delete。介绍接口实现时,主要介绍数据的写入和读取,而先忽略这些操作可能触发的后台操作。
Put和Delete
Put和Delete的实现惊人的相似:
// db/db_impl.cc
Status DB::Put(const WriteOptions& opt, const Slice& key, const Slice& value) {
WriteBatch batch;
batch.Put(key, value);
return Write(opt, &batch);
}
Status DB::Delete(const WriteOptions& opt, const Slice& key) {
WriteBatch batch;
batch.Delete(key);
return Write(opt, &batch);
}
这两个操作都先生成一个WriteBatch,然后调用Write将内容写入到数据库,只是WriteBatch的内容不同,WriteBatch是一个内存结构,保存了写入的内容。
WriteBatch
WriteBatch只是一个辅助结构,可以将多个Kv的写入按顺序累积起来,然后一次性写入提高效率。
WriteBatch主要由三部分组成:sequence是整个WriteBatch写入时当前的SequenceNumber,按顺序逐个赋予record,比如第一个record的SequenceNumber是sequence,第二个是sequence + 1,以此类推;
count是record的数量;
最后就是一个record数组,record数组有两种类型;
一种是代表写入的Kv,用1 byte的kTypeValue开头,后面跟上两个varstring分别表示键和值;
一种代表删除操作,只需要指定键,用1 byte的kTypeDeletion后跟一个varstring。
调用WriteBatch的Delete和Put时,会更新count的值,并且追加一条record,sequence并不先设置,而是开始写入前才设置成为当前的SequenceNumber。
WriteBatch在内存里面的表示,类似于以下的结构:
struct WriteBatch {
uint64 sequence;
uint32 count;
Record record[count];
};
简单来说,WriteBatch保存了需要写入的内容。
Write
Write就是真实写入的函数了,将一个WriteBatch的内容写入到数据库,LevelDB的写入分为两步:写入数据到日志;
写入数据到MemTable。
写入过程中,如果MemTable太大会触发MemTable写入到SSTable,不过这个后面再介绍,首先只专注于以上两个步骤。
为了实现线程安全,每个线程在写入时,都需要获取锁,但是这样会阻塞其它的线程,降低并发度。针对这个问题LevelDB做了一个优化,写入时当获取锁后,会将WriteBatch放入到一个std::deque DBImpl::writers_里,然后会检查writers_里的第一个元素是不是自己,如果不是的话,就会释放锁。当一个线程检查到writers_头元素是自己时,会再次获取锁,然后将writers_里的数据尽可能多的写入。一次写入会涉及到写日志,占时间比较长,一个线程的数据可能被其它线程批量写入进去了,减少了等待。
总结来说,一个线程的写入有两种情况:一种是恰好自己是头结点,自己写入,另外一种是别的线程帮助自己写入了,自己会检查到写入,然后就可以返回了。
大概的代码流程如下(根据需要删除不重要的代码):
// db/db_impl.cc
// 用来封装一个WriteBatch,用来标识状态
struct DBImpl::Writer {
explicit Writer(port::Mutex* mu): batch(nullptr), sync(false), done(false), cv(mu) {}
Status status; // 写入的状态
WriteBatch* batch; // 对应的WriteBatch
bool sync; // 写日志时,是否需要sync
bool done; // 写入是否要完成
port::CondVar cv; // 用来等待其它线程的通知
};
Status DBImpl::Write(const WriteOptions& options, WriteBatch* updates) {
// step1:构造一个writer,插入到wrtiers_里,注意插入前需要先获取锁
Writer w(&mutex_);
w.batch = updates;
w.sync = options.sync;
w.done = false;
MutexLock l(&mutex_);
writers_.push_back(&w);
// step2: 检查done判断是否完成,或者自己是否是writers_队列里的第一个成员。有可能其它线程写入时把自己的内容也写入了,
// 这样自己就是done,或者当自己是头元素了,表示轮到自己写入了
while (!w.done && &w != writers_.front()) {
w.cv.Wait();
}
// step3: 判断写入是否完成了,完成了就可以返回了
if (w.done) {
return w.status;
}
// step4:如果写入太快,进行限流,如果MemTable满了,生成新的MemTable
Status status = MakeRoomForWrite(updates == nullptr);
if (status.ok() && updates != nullptr) {
// step5:从writers_头部扫描足够多的WriteBatch构造一个WriteBatch updates,last_writer保存了最后一个写入的writer
// 设定新的WriteBatch的SequenceNumber
WriteBatch* updates = BuildBatchGroup(&last_writer);
WriteBatchInternal::SetSequence(updates, last_sequence + 1);
last_sequence += WriteBatchInternal::Count(updates);
// 注意这一步解锁,很关键,因为接下来的写入可能是一个费时的过程,解锁后,其它线程可以Get,其它线程也可以继续将writer
// 插入到writers_里面,但是插入后,因为不是头元素,会等待,所以不会冲突
mutex_.Unlock();
// step6: 写入log,根据选项sync
status = log_->AddRecord(WriteBatchInternal::Contents(updates));
if (status.ok() && options.sync) {
status = logfile_->Sync();
}
// step7: 写入到MemTable里
if (status.ok()) {
status = WriteBatchInternal::InsertInto(updates, mem_);
}
// 加锁需要修改全局的SequenceNumber以及writers_
mutex_.Lock();
// 更新全局的SequenceNumber
versions_->SetLastSequence(last_sequence);
}
// step8: 从writers_队列从头开始,将写入完成的writer标识成done,并且弹出,通知这些writer
// 这样这些writer的线程会被唤醒,发现自己的写入已经完成了,就会返回
while (true) {
Writer* ready = writers_.front();
writers_.pop_front();
if (ready != &w) {
ready->status = status;
ready->done = true;
ready->cv.Signal();
}
if (ready == last_writer) break;
}
// 如果writers_里还有元素,就通知头元素,让它可以进来开始写入
if (!writers_.empty()) {
writers_.front()->cv.Signal();
}
return status;
}
MakeRoomForWrite主要是限流和触发后台线程等工作:首先判断Level 0的文件是否>=8,是的话就sleep 1ms,这里是限流的作用,Level 0的文件太多,说明写入太快,Compaction跟不上写入的速度,而在读取的时候Level 0的文件之间可能有重叠,所以太多的话,影响读取的效率,这算是比较轻微的限流,最多sleep一次;
接下来判断MemTable里是否有空间,有空间的话就可以返回了,写入就可以继续;
如果MemTable没有空间,判断Immutable MemTable是否存在,存在的话,说明上一次写满的MemTable还没有完成写入到SSTable中,说明写入太快了,需要等待Immutable MemTable写入完成;
再判断Level 0的文件数是否>=12,如果太大,说明写入太快了,需要等待Compaction的完成;
到这一步说明可以写入,但是MemTable已经写满了,需要将MemTable变成Immutable MemTable,生成一个新的MemTable,触发后台线程写入到SSTable中。
代码比较简单,就省略了。
接下来是BuildBatchGroup,这个函数是批量写入的关键,它会从writers_头部开始扫描,将尽可能多的writer生成一个新的WriterBatch,将这些writer的内容批量写入。步骤如下:首先计算出一个max_size,表示构造的WriterBatch的最大尺寸;
然后开始扫描writers_数组,直到满足WriterBatch超过max_size,或者;
第一个writer的sync决定了这整个write是不是sync的,如果第一个writer不是sync的,碰到一个sync的writer,表面这个writer无法加入到这个批量写入中,所以扫描就结束了。
// db/db_impl.cc
WriteBatch* DBImpl::BuildBatchGroup(Writer** last_writer) {
Writer* first = writers_.front();
WriteBatch* result = first->batch;
// 计算writebatch的最大size,如果第一个的size比较小的话,限制最大的size,以防小的写入太慢
size_t size = WriteBatchInternal::ByteSize(first->batch);
size_t max_size = 1 << 20;
if (size <= (128 << 10)) {
max_size = size + (128 << 10);
}
*last_writer = first;
std::deque::iterator iter = writers_.begin();
++iter;
for (; iter != writers_.end(); ++iter) {
Writer* w = *iter;
// 如果这是一个sync的写入,但是第一个元素不是sync的话,那么就结束了,因为整体的写入都不是sync的
if (w->sync && !first->sync) {
break;
}
if (w->batch != nullptr) {
size += WriteBatchInternal::ByteSize(w->batch);
// 如果太大,就结束
if (size > max_size) {
break;
}
// 追加到result
if (result == first->batch) {
result = tmp_batch_;
WriteBatchInternal::Append(result, first->batch);
}
WriteBatchInternal::Append(result, w->batch);
}
// 更新最后一个writer
*last_writer = w;
}
return result;
}
Get
了解写入后,再来说读取,LSM Tree里面,读取往往比写入更复杂,写入往往只涉及一次磁盘IO,但是读取可能涉及多次磁盘IO。
LevelDB的数据可能存在三个地方:最新的写入在MemTable里;
如果上一次MemTable写满后,转换为Immutable MemTable,如果还没有完成写入到SSTable里,那么就有可能存在于Immutable MemTable里;
存在于SSTable里,SSTable里的数据从Level 0开始从新变旧,Level越低,数据越新。Level 0里的SSTable比较特殊,因为是MemTable转换过来的,虽然每个SSTable都是有序的,但是每个SSTable的键的范围可能有重叠,也就是一个键可能存在于多个SSTable里,文件名下标越大的SSTable里的键越新。而非level 0的SSTable,都是后台生成的,保证了SSTable之间的数据无重叠,一个键只有可能存在于一个SSTable里。
所以读取一个键时:先读取MemTable,存在就读到数据了;
不存在的话,看看Immutable MemTable是否存在,存在的话,读取数据;
否则,从SSTable的Level 0开始读取,如果Level 0还没有找到话,读Level 1,以此类推,直到读到最高层。
// db/db_impl.cc
Status DBImpl::Get(const ReadOptions& options, const Slice& key, std::string* value) {
Status s;
// 加锁,读取元数据,Ref操作
MutexLock l(&mutex_);
// 设定snapshot,其实就是一个SequenceNumber,这是实现Snapshot的关键,设置成选项中的snapshot,或者
// 当前最近的SequenceNumber
SequenceNumber snapshot;
if (options.snapshot != nullptr) {
snapshot = static_cast(options.snapshot)->sequence_number();
} else {
snapshot = versions_->LastSequence();
}
// mem是MemTable,imm是Immutable MemTable,
// SSTable则由当前的version代表,一个version包含了当前版本的SSTable的集合
MemTable* mem = mem_;
MemTable* imm = imm_;
Version* current = versions_->current();
// 这里都调用了Ref,LevelDB里大量使用了这种引用计数的方式管理对象,这里表示读取需要引用这些对象,假设在读取过程
// 中其他线程发生了MemTable写满了,或者Immutable MemTable写入完成了需要删除了,或者做了一次Compaction,生
// 成了新的Version,所引用的SSTable不再有效了,这些都需要对这些对象做一些改变,比如删除等,但是当前线程还引用着
// 这些对象,所以这些对象还不能被删除。采用引用计数,其它线程删除对象时只是简单的Unref,因为当前线程还引用着这些
// 对象,所以计数>=1,这些对象不会被删除,而当读取结束,调用Unref时,如果对象的计数是0,那么对象会被删除。
mem->Ref();
if (imm != nullptr) imm->Ref();
current->Ref();
{
// 实际读取时解锁
mutex_.Unlock();
// 构造一个Lookup Key搜索MemTable
LookupKey lkey(key, snapshot);
// 搜索MemTable
if (mem->Get(lkey, value, &s)) {
// 搜索Immutable
} else if (imm != nullptr && imm->Get(lkey, value, &s)) {
// 搜索SSTable
} else {
s = current->Get(options, lkey, value, &stats);
}
mutex_.Lock();
}
// Unref释放
mem->Unref();
if (imm != nullptr) imm->Unref();
current->Unref();
return s;
}
读取MemTable
首先来看mem和imm的读取,这其实就是读取Skiplist,具体读取Skiplist的过程不介绍了。
// db/memtable.cc
bool MemTable::Get(const LookupKey& key, std::string* value, Status* s) {
// Lookup Key的内容,搜索的Key
Slice memkey = key.memtable_key();
// 构建迭代器搜索
Table::Iterator iter(&table_);
iter.Seek(memkey.data());
if (iter.Valid()) {
const char* entry = iter.key();
uint32_t key_length;
const char* key_ptr = GetVarint32Ptr(entry, entry + 5, &key_length);
// 看查找到的键里面包含的User Key是否和搜索的User Key相同
// 这里需要这个判断是因为迭代器Seek时,指向大于等于搜索键的位置,所以有可能这个键是大于搜索的键的
// 这边不需要判断SequenceNumber,便可实现snapshot功能,原因是搜索的键里面是包含SequenceNumber
// 的,并且User Key相同时,SequenceNumber大的排在前面。所以Seek时跳过了User Key相同,但是SequenceNumber
// 大于当前搜索的键的SequenceNumber的键,所以找到的就是那个snapshot之前的状态。
if (comparator_.comparator.user_comparator()->Compare(Slice(key_ptr, key_length - 8), key.user_key()) == 0) {
// User Key匹配
const uint64_t tag = DecodeFixed64(key_ptr + key_length - 8);
switch (static_cast(tag & 0xff)) {
// 如果tag是一个插入,那么解析值,返回
case kTypeValue: {
Slice v = GetLengthPrefixedSlice(key_ptr + key_length);
value->assign(v.data(), v.size());
return true;
}
// 如果tag是一个删除,表示键找不到返回
case kTypeDeletion:
*s = Status::NotFound(Slice());
return true;
}
}
}
return false;
}
读取SSTable
再来看SSTable的读取,version保存了SSTable,是一个分层的SSTable的集合,这其实就是LevelDB名字的由来。
version里SSTable的集合有以下特点:数据从Level 0开始由新变旧,所以最新的数据在最低层,也就是如果一个键有多个值的话,新的肯定在低层,旧的在高层;
因为 Level 0里的SSTable是MemTable 写入的,Level 0里的每个SSTable的键之间可能有重叠,所以一个键可能存在于多个SSTable里;
而其它Level,SSTable都是Compaction产生的,键没有重叠,一个键只有可能存在于一个SSTable里;
对于Level 0的SSTable,文件编号越大的,里面的键越新。
如图,红色是需要读取的SSTable,当读取时,Level 0可能有多个SSTable需要读取,而其它Level最多只有一个SSTable需要读取。
一个SSTable在内存里使用FileMetaData来表示:
// db/version_edit.h
struct FileMetaData {
FileMetaData() : refs(0), allowed_seeks(1 << 30), file_size(0) {}
int refs;
int allowed_seeks; // 判断什么时候可以Compaction
uint64_t number; // 文件编号
uint64_t file_size; // 文件大小bytes
InternalKey smallest; // 这个SSTable里最小的Internal Key
InternalKey largest; // 这个SSTable里最大的Internal Key
};
std::vector Version::files_[config::kNumLevels];
可以看到每个FileMetaData都有一个键的范围,所以在读取时可以快速判断键是否可能在这个SSTable里,这样就可以选出相应的键。
而一个Version里的SSTable保存在一个vector数组里,每一个Level对应一个vector,每个vector保存了FileMetaData,对于非Level 0,这些FileMetaData是有序的,也就是第n个SSTable的最大键小于第n + 1个SSTable的最小键,所以可以通过二分搜索找到某个键位于哪个SSTable。
接下来来看看在SSTable集合里是如何查询的:
// db/version_set.cc
Status Version::Get(const ReadOptions& options, const LookupKey& k, std::string* value, GetStats* stats) {
Slice ikey = k.internal_key();
Slice user_key = k.user_key();
const Comparator* ucmp = vset_->icmp_.user_comparator();
Status s;
// 这边从Level 0开始,因为当在低Level发现了一个键后,可以忽略高Level的键
std::vector tmp;
FileMetaData* tmp2;
for (int level = 0; level < config::kNumLevels; level++) {
size_t num_files = files_[level].size();
if (num_files == 0) continue;
// 开始搜索当前的level
FileMetaData* const* files = &files_[level][0];
// 对于Level 0,需要做特殊处理,因为每个文件都有可能包含键,所以需要逐个检查
if (level == 0) {
// 这里将符合键在smallest和largest之间的FileMetaData都加入tmp
tmp.reserve(num_files);
for (uint32_t i = 0; i < num_files; i++) {
FileMetaData* f = files[i];
if (ucmp->Compare(user_key, f->smallest.user_key()) >= 0 && ucmp->Compare(user_key, f->largest.user_key()) <= 0) {
tmp.push_back(f);
}
}
if (tmp.empty()) continue;
// 排序,让最新的文件排在前面,因为后写入的键在更新的文件里,一个个文件搜索时,可以先处理新文件
std::sort(tmp.begin(), tmp.end(), NewestFirst);
files = &tmp[0];
num_files = tmp.size();
} else {
// 对于其它的level,只需要通过二分搜索找到相应的文件。
uint32_t index = FindFile(vset_->icmp_, files_[level], ikey);
// 未找到
if (index >= num_files) {
files = nullptr;
num_files = 0;
} else {
tmp2 = files[index];
if (ucmp->Compare(user_key, tmp2->smallest.user_key()) < 0) {
// 这个文件里最小键大于user_key
files = nullptr;
num_files = 0;
} else {
files = &tmp2;
num_files = 1;
}
}
}
// 经过以上步骤,files开始的num_files个文件就是需要搜索的SSTable,非Level 0层num_files=1
// 开始逐个搜索SSTable
for (uint32_t i = 0; i < num_files; ++i) {
FileMetaData* f = files[i];
Saver saver;
saver.state = kNotFound;
saver.ucmp = ucmp;
saver.user_key = user_key;
saver.value = value;
// 通过table_cache_查询键
s = vset_->table_cache_->Get(options, f->number, f->file_size, ikey,
&saver, SaveValue);
// 检查状态
switch (saver.state) {
case kNotFound:
break; // 没有找到键的话,继续搜索下一个
case kFound:
return s; // 找到的话,就可以返回了
case kDeleted:
s = Status::NotFound(Slice()); // 表示键被删除了,也就是可以确定这个键不存在,返回
return s;
case kCorrupt:
s = Status::Corruption("corrupted key for ", user_key); // 错误也返回
return s;
}
}
}
return Status::NotFound(Slice()); // 搜索完所有的键都没找到就返回。
}
最后再看看如何从一个SSTable里搜索一个键:
// db/table_cache.cc
Status TableCache::Get(const ReadOptions& options, uint64_t file_number,
uint64_t file_size, const Slice& k, void* arg,
void (*handle_result)(void*, const Slice&,
const Slice&)) {
Cache::Handle* handle = nullptr;
// 从表缓存里找到相应的file_number对应的文件
Status s = FindTable(file_number, file_size, &handle);
if (s.ok()) {
Table* t = reinterpret_cast(cache_->Value(handle))->table;
// 实际的查询
s = t->InternalGet(options, k, arg, handle_result);
}
return s;
}
TableCache::Get比较简单,只是从缓存里找到对应的表结构,然后从这个表结构里查询键。
而对于Table::InternalGet,基于前面介绍的SSTable的内存结构是比较好理解的:先搜索索引块,找到对应的索引,根据索引找到对应的Data Block;
查找Data Block。
// table/table.cc
Status Table::InternalGet(const ReadOptions& options, const Slice& k, void* arg,
void (*handle_result)(void*, const Slice&,
const Slice&)) {
Status s;
// 搜索索引
Iterator* iiter = rep_->index_block->NewIterator(rep_->options.comparator);
iiter->Seek(k);
// 找到一个索引项
if (iiter->Valid()) {
Slice handle_value = iiter->value();
FilterBlockReader* filter = rep_->filter;
BlockHandle handle;
// 如果使用了布隆过滤器,则先查找布隆过滤器,如果没有发现,就直接返回了
if (filter != nullptr && handle.DecodeFrom(&handle_value).ok() && !filter->KeyMayMatch(handle.offset(), k)) {
// Not found
} else {
// 读取一个Data Block
Iterator* block_iter = BlockReader(this, options, iiter->value());
block_iter->Seek(k);
if (block_iter->Valid()) {
// 找到对应的键,调用回调函数
(*handle_result)(arg, block_iter->key(), block_iter->value());
}
s = block_iter->status();
delete block_iter;
}
}
if (s.ok()) {
s = iiter->status();
}
delete iiter;
return s;
}
因为SSTable里保存的是Internal Key,但是搜索的是User Key,而Iterator Seek的是第一个大于等于待搜索的键的数据项,如果某个User Key不存在,是会定位到下一个User Key上面的,所以找到Internal Key后,还需要比较里的User Key是否相同,这就是回调函数的作用了。
// db/version_set.cc
static void SaveValue(void* arg, const Slice& ikey, const Slice& v) {
Saver* s = reinterpret_cast(arg);
ParsedInternalKey parsed_key;
if (!ParseInternalKey(ikey, &parsed_key)) {
// 如果无法解析键,表示数据损坏
s->state = kCorrupt;
} else {
// 比较对应的User Key是否相同
if (s->ucmp->Compare(parsed_key.user_key, s->user_key) == 0) {
s->state = (parsed_key.type == kTypeValue) ? kFound : kDeleted;
if (s->state == kFound) {
s->value->assign(v.data(), v.size());
}
}
}
}
参考源码
include/leveldb/db.h db/db_iter.h db/db_iter.cc: 实现了GET/PUT/DELETE
db/version_set.h db/version_set.cc: 实现从一个version的SSTable里读取一个键
db/memtable.h db/memtable.cc: 实现从MemTable读取一个键
inlclude/leveldb/table.h db/table_cache.h db/table_cache.cc db/table.cc: 定义从一个SSTable读取一个键
小结
以上就是LevelDB三个操作接口的实现,事实上是两个,可以看到,还是比较简单的。
对于写入,如果不用sync的方式写入,其实基本就是写内存,而对于sync的方式,每次write都需要做一次磁盘IO,但是磁盘IO是写文件,是顺序IO,所以也是相当快的。而且在写入时,后面来的写请求,都会堆积起来,在后一个写入中批量写入,这样的一次磁盘IO其实是被平摊了。这就是为什么LSM Tree写入快的原因了。
对于读取,如果键刚好在MemTable里,那么就是内存访问会非常快。否则就和需要读取的SSTable数量有关。前面说过SSTable里索引等数据是读入内存的,所以每次读取SSTable时,最多只需要一次磁盘IO,读取一个Data Block。一般Level 0的文件最多为4个,而Level最高为7层,这样就需要10次IO。对于一个不存在的键或者一个很早就写入的键,但是键在多个SSTable的范围内,往往需要花费最大的磁盘IO。然而这是最坏的情景,实际上有优化来减少了磁盘IO:Data Block的缓存,如果在缓存里发现了要读取的Data Block,那么不需要磁盘IO;
布隆过滤器,对于不存在的键有很好的优化,默认情况下99%的正确率,可以宣告一个键不在这个SSTable里,那么也无需缓存,典型的空间换时间。
所以很多读只需要一次磁盘IO,或者不需要磁盘IO。