levedb 导入 mysql_[LevelDB] 数据库3:循序渐进 —— 操作接口

数据库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。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值