【leveldb源码分析】

leveldb源码分析

b站 硬核课堂
https://space.bilibili.com/1324259795?spm_id_from=333.337.0.0
在这里插入图片描述

三种key

在这里插入图片描述

User Key

第一种是User Key,这种是最简单的情况,也就是读写键值对时提供的键,只是一个简单的字符串,一般用Slice来表示。

比如调用db->Put(key, value)插入一个Kv,这个键就是一个User Key。说简单点,应用程序和数据库之间的交互都是使用User Key来进行。

Internal Key

是SSTable里实际存储的键值,也就是这个持久化有序的Map的键。
可以看到Internal Key在User Key的后面增加了一个64位的整数,并且将这个整数分为两部分,低位的一个字节是一个ValueType,高位的7个字节是一个SequenceNumber。

ValueType是为了区分一个键是插入还是删除,删除其实也是一条数据的插入,但是是一条特殊的插入,通过在User Key后面附上kTypeDeletion来说明要删除这个键,而kTypeValue说明是插入这个键。

SequenceNumber是一个版本号,是全局的,每次有一个键写入时,都会加一,每一个Internal Key里面都包含了不同的SequenceNumber。SequenceNumber是单调递增的,SequenceNumber越大,表示这键越新,如果User Key相同,就会覆盖旧的键。所以就算User Key相同,对应的Internal Key也是不同的,Internal Key是全局唯一的。当我们更新一个User Key多次时,数据库里面可能保存了多个User Key,但是它们所在的Internal Key是不同的,并且SequenceNumber可以决定写入的顺序。

Lookup Key

Lookup Key其实就是简单的在Internal Key前面加上键的长度,使用varint32编码,主要用在MemTable的查找上。为什么需要Lookup Key呢
在这里插入图片描述

这主要还是要从MemTable的存储说起。MemTable的底层是一个Skiplist,而LevelDB的Skiplist只存储了一个键,而没有值。而LevelDB在存储Kv时,是将键和值编码在一起存储的,使用的就是字符串的长度前缀编码。所以在MemTable里查找Key时,提供的Lookup Key就是编码值的一个前缀,刚好可以定位MemTable里相应的键

版本管理

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;    // 文件大小
  InternalKey smallest;  // 文件里最小的internal key
  InternalKey largest;   // 文件里最大的internal key
};

class Version {
  VersionSet* vset_;  // 版本集的引用
  Version* next_;     // 版本在版本集里的next_指针
  Version* prev_;     // 版本在版本集里的prev_指针
  int refs_;          // 引用计数

  // SSTable的信息,每一项代表相应Level的SSTable信息
  // 除了Level 0外,每个Level里的文件都是按照最小键的顺序排列的,并且没有重叠
  // 通过这个数据项,搜索SSTable时,就可以从Level 0开始搜索
  std::vector<FileMetaData*> files_[config::kNumLevels];
}

VersionSet
VersionSet还保存了一些全局的只有一份的元数据。VersionSet只有一个实例。

class VersionSet {
    Env* const env_;                      // 封装部分操作系统调用,包括文件、线程操作等
    const std::string dbname_;            // 数据库名称,Open时传入
    const Options* const options_;        // 数据库选项,Open时传入
    TableCache* const table_cache_;       // 打开的SSTable的缓存,Open时创建
    const InternalKeyComparator icmp_;    // 根据User Key生成的Internal Key的Comparator
    uint64_t next_file_number_;           // ldb、log和MANIFEST生成新文件时都有一个序号单调递增
    uint64_t manifest_file_number_;       // 当前的MANIFEST的编号
    uint64_t last_sequence_;              // 上一个使用的SequenceNumber
    uint64_t log_number_;                 // 当前的日志的编号

    WritableFile* descriptor_file_;       // MANIFEST打开的文件描述符
    log::Writer* descriptor_log_;         // MANIFEST实际存储的格式是WAL日志的格式,所以这里用来写入数据
    Version dummy_versions_;              // Version链表的头结点
    Version* current_;                    // 当前的Version

    // 这是用来记录Compact的进度,Compact总是从某一Level的最小的键开始到某个键结束,
    // 下次再从下一个键开始,所以这个就是下一次这个Level从哪个键开始Compact
    std::string compact_pointer_[config::kNumLevels];
}

https://zhuanlan.zhihu.com/p/299015729

并发

当一个进程打开一个LevelDB数据库时,会获取这个数据库的一个文件锁,其它进程就没法获取这个文件锁了。所以一个LevelDB数据库只支持一个进程同时访问,但是这一个进程里面可以同时有多个线程并发访问。对于leveldb::DB里的很多方法,都是线程安全的,在这些方法内都有加锁的步骤。但是对于其它的一些对象,比如WriteBatch,如果多线程并发访问,需要自己同步。

迭代器

compaction

当MemTable写满后,需要将MemTable的数据写入磁盘,生成一个Level 0的SSTable;
Level 0的SSTable的键范围可能有重叠,读取一个Key的时候,可能需要读取多个SSTable,Compaction将Level 0的SSTable推向Level 1,使得Level 0的SSTable数量保持在一个较低的水平;
经过Compaction后,Level 0以上的层的SSTable是有序没有重叠的,一个Key只有可能在一个文件里面;
Level 1的文件数量可能太多,导致一次Compaction消耗太多磁盘IO,所以需要将Level 1的文件继续Compaction到更高Level去
在这里插入图片描述
Major Compaction其实就是一个归并排序的过程,对多个输入的SSTable,多路归并,输出多个连续的SSTable,代替原来的文件。根据Level 0的特殊性,可以分为两种类型。

Level 0 -> Level 1

在这里插入图片描述
如图,假设需要Compaction键范围是800-2500的Level 0的文件到Level 1,会通过以下步骤选择文件:
1)首先选定800-2500的SSTable;
2)Level 0的文件是重叠的,所以只Compaction 800-2500的文件的话,可能造成有更旧的数据在Level 0,而新的数据在Level 1,这样就会导致读取的时候出错,还需要将和800-2500有重叠的文件加入,这需要选定0-1314,2400-2712的文件;
3)最终Level 0选定了3个文件,键的范围是0-2712;
4)要保证Level 1的文件之间都是有序的并且没有重叠,那么需要选定Level 1和0-2712范围有重叠的文件,这样就选择下面4个红色文件;
5)最终选择了7个红色的文件,做多路归并,生成了7个紫色的文件,这些文件逐个生成,有序,并且键无重叠;
6)用7个紫色文件代替7个红色文件,做一次版本变更,就成功将Level 0的SSTable移动到了Level 1。
7)经过这样的文件选择和多路归并,Level 1的文件依然是有序并且无重叠的。

Level n -> Level n + 1 (n > 0)

在这里插入图片描述

触发方式

LevelDB里有三种方式选择Compaction,分别是:

Manual Compaction;
Size Compaction;
Seek Compaction。

Manual Compaction
Size Compaction

对于Level 0的SSTable,因为键范围可能有重叠,所以需要控制文件不超过4个,而对于Level n(n > 0)的SSTable,总大小不能超过10^nMB,一旦这些条件不满足了,需要Compaction,将文件推向更高的Level,使得条件继续满足。
Size Compaction是对一整个Level进行的,一个Level的SSTable可能会很多,无法在一次Compaction中完成,需要分多次完成。第一次完成最小键到某个键的范围内的Compaction,下一次再从某个键继续完成,以此类推。那么,需要记录下一次这个Level的Compaction从哪个键开始,以下结构便记录这个进度信息。

Seek Compaction

比较复杂

Compaction实现

DBImpl::BackgroundCompaction()

Compaction类
class Compaction {
    ...
    int level_;                      // Compaction文件所在的Level
    uint64_t max_output_file_size_;  // 生成的文件的最大值
    Version* input_version_;         // Compaction发生时的Version
    VersionEdit edit_;               // Compaction结果保存
    std::vector<FileMetaData*> inputs_[2];
}

Compaction的文件在两个Level,假设为level_ 和level_ + 1,选定一个或几个SSTable Compaction时,就是选定了level_ 的文件。
然后调用void VersionSet::SetupOtherInputs(Compaction* c)可以获取到level_ + 1中与level_中选定的文件有重叠的文件,这样输入的SSTable就选好了,一次Compactdoin要做的工作也就确定了。

PickCompaction()

Manual Compaction;
Size Compaction;
Seek Compaction。
对于前两种Compaction,是对一整个Level进行Compaction的,需要根据之前的进度,选择一个SSTable文件,而Seek Compaction的SSTable文件只有一个,是确定的。

选择Compaction时,需要选择某一个需要Compaction的文件,然后需要找到上一个Level和这个文件有重叠的文件,这些文件一起进行多路合并。

DBImpl::DoCompactionWork

完成实际的多路归并过程,生成新的版本。
迭代按照Internal Key的顺序进行,多个连续的Internal Key里面可能包含相同的User Key,按照SequenceNumber降序排列;
相同的User Key里只有第一个User Key是有效的,因为它的SequenceNumber是最大的,覆盖了旧的User Key,但是无法只保留第一个User Key,因为LevelDB支持多版本,旧的User Key可能依然有线程可以引用,但是不再引用的User Key可以安全的删除;
碰到一个删除时,并且它的SequenceNumber <= 最新的Snapshot,会判断更高Level是否有这个User Key存在。如果存在,那么无法丢弃这个删除操作,因为一旦丢弃了,更高Level原被删除的User Key又可见了。如果不存在,那么可以安全的丢弃这个删除操作,这个键就找不到了;
对于生成的SSTable文件,设置两个上限,哪个先达到,都会开始新的SSTable。一个就是2MB,另外一个就是判断上一Level和这个文件的重叠的文件数量,不超过10个,这是为了控制这个生成的文件Compaction的时候,不会和太多的上层文件重叠。

class Compaction {
  int level_;
  uint64_t max_output_file_size_;
  Version* input_version_;
  VersionEdit edit_;
  std::vector<FileMetaData*> inputs_[2];
  
}

Snapshot

LevelDB支持快照功能。快照是一个一致性视图,当创建一个快照时,就给那个时刻的数据库状态打了个快照,以后的更新插入删除在这个快照下是不可见的,类似于MVCC的功能。

leveldb::ReadOptions options;
options.snapshot = db->GetSnapshot();
... 对db做一些修改 ...
leveldb::Iterator* iter = db->NewIterator(options);
... 使用的是还是快照创建的时候的状态 ...
delete iter;
db->ReleaseSnapshot(options.snapshot);

数据块

LevelDB将相邻的数据存储到一个Data Block里,多个Data Block组成一个SSTable。LevelDB里压缩、读取和缓存的单位都是Data Block。默认的块大小是4K,块越大,顺序读效率越高,块越小,随机读效率越高。

leveldb::Options options;
options.block_size = 8192;
leveldb::DB* db;
leveldb::DB::Open(options, name, &db)

Recover

LevelDB打开需要做以下事情:

如果数据库目录不存在,创建目录;
加文件锁,锁住整个数据库;
读取MANIFEST文件,恢复系统关闭时的元数据,也就是版本信息,或者新建MAINFEST文件;
如果上一次关闭时,MemTable里有数据,或者Immutable MemTable写入到SSTable未完成,那么需要做数据恢复,从WAL恢复数据;
创建数据库相关的内存数据结构,如Version、VersionSet等。
在这里插入图片描述
用DBImpl::Recover来完成主要的工作,如果调用成功,则创建MemTable和WAL相关的数据结构,重写MANIFEST文件。
创建数据库目录;
对这个数据库里面的LOCK文件加文件锁,LevelDB是单进程多线程的,需要保证每次只有一个进程能够打开数据库,方式就是使用了文件锁,如果有其它进程打开了数据库,那么加锁就会失败;
如果数据库不存在,那么调用DBImpl::NewDB创建新的数据库;
调用VersionSet::Recover来读取MANIFEST,恢复版本信息;
根据版本信息,搜索数据库目录,找到关闭时没有写入到SSTable的日志,按日志写入顺序逐个恢复日志数据。DBImpl::RecoverLogFile会创建一个MemTable,开始读取日志信息,将日志的数据插入到MemTable,并根据需要调用DBImpl::WriteLevel0Table将MemTable写入到SSTable中

缓存

SSTable里的Data Block要被访问时,需要先从磁盘读取出来,然后解压缩。LevelDB提供了缓存,可以缓存解压后的Data Block,减少磁盘IO。

LevelDB提供了一个LRU Cache,给缓存设置一定的空间大小,并且缓存最近使用的Data Block。也可以提供自己的缓存策略,只需要实现Cache接口就行。LevelDB默认情况下使用了一个8MB的LRU Cache。

分段锁缓存

ShardedLRUCache包含一个LRUCache缓存数组,大小是16,根据缓存键的Hash值的高4位进行哈希,将缓存项分布到不同的LRUCache里,这样当并发操作时,很有可能缓存项不在同一个LRUCache里,不会冲突,大大提高了并发度;
ShardedLRUCache的实现只是简单的将对缓存的操作代理到相应的LRUCache里。

LruCache

Table缓存

SSTable的文件名类似于000005.ldb,前缀部分就是一个file_number,Table就是用这个file_number作为键来缓存的。Table的缓存存储在TableCache类里面。

Data Block缓存

当要获取一个Data Block时:
从这个Data Block的索引项出发,根据索引得到offset,然后根据Table得到cache_id,这样就得到了缓存键;
在缓存里读取Data Block,如果存在就可以返回;
否则从文件里读取Data Block,这里根据选项fill_cache,可以决定是否插入到缓存。

布隆过滤器

除了缓存可以提高读取的效率,布隆过滤器也可以提高读取的效率。当需要读取一个键时,就算这个键不在一个Data Block,依然需要读出这个Data Block,才知道这个键是否存在。有了布隆过滤器,可以先读取布隆过滤器。

基础库

area 内存管理

数据库内存分配非常重要,尤其是插入一个键值对时,需要分配内存给这个键值对。如果直接使用malloc/free或者new/delete碰到很小的键值对时,每个调用平均的开销比较大,而且会产生很多内存碎片。

LevelDB只有一处使用了自己的内存管理,就是MemTable,MemTable使用一个Skiplist存储最新插入的键值对。LevelDB为每个MemTable都绑定了一个Arena来管理内存,其它地方则直接使用malloc/free,因为这些地方都使用了比较大块的内存或者新建销毁不频繁。

内存分配经常采用的一种方式,就是首先使用new预分配一块比较大的内存,需要使用小块内存时,从这块大内存里面继续分配,这时候分配可能只是移动指针或者更新变量的事情了,非常高效。

area内存管理之道
https://zhuanlan.zhihu.com/p/210100808

编码

coding文件
https://zhuanlan.zhihu.com/p/216143729
对于LevelDB,有以下几点:
整数分为32位整数和64位整数;
整数分为定长整数和变长整数;
整数采用Little Endian的方式存储;
字符串采用长度前缀编码的方式存储,所以字符串里面可以出现任何字符,包括\0

Slice

字符串Slice。https://zhuanlan.zhihu.com/p/219788172
Slice是一个简单的对象,只包含了指向字符串的指针和字符串的长度。
相对于拷贝string,拷贝Slice会轻量级很多,只需要拷贝指针和长度;
char []无法保存二进制字节,而Slice则没有这个问题;
Slice的底层可以是char,也可以是string;
多个Slice可以指向同一个字符串。

思考点:slice和字符串的右值引用有啥异同点??

Comparator

定义了一个Comparator接口,用户可以提供自己的规则实现这个接口。

// include/leveldb/comparator.h

class LEVELDB_EXPORT Comparator {
public:
    virtual ~Comparator();

    virtual int Compare(const Slice& a, const Slice& b) const = 0;

    virtual const char* Name() const = 0;

    virtual void FindShortestSeparator(std::string* start, const Slice& limit) const = 0;

    virtual void FindShortSuccessor(std::string* key) const = 0;
};

LevelDB定义了一个默认的比较器BytewiseComparatorImpl,实现了基于二进制字节的比较,这个比较器的Name是leveldb.BytewiseComparator

Status

Status定义很多操作的返回码,很多操作需要通过返回的status来判断下一步的行为。
Status的状态都由一个私有变量char * state_定义:
如果状态为OK,则此变量为null;
否则,状态由一个状态码code和一个message组成;
state_[0…3]是message的长度;
state_[4]第4个字节是code的值,预先定义了一些状态,由enum Code定义;
state_[5…]就是message的值了,从构造函数看出,可以同时提供两个msg,这两个msg会通过:分隔;
另外为每个enum Code都定义了两个快捷函数,一个是生成一个这个错误的status,一个是判断是否是某个错误。

Env

LevelDB是一个数据库函数库,数据库总是需要操作文件和线程,这就需要做很多系统调用。各个操作系统的系统调用方式不一样,为了跨平台支持,LevelDB对这些系统调用做了一层封装,提供了统一的接口来操作。
include/leveldb/env.c:env相关的接口定义
util/env_posix.cc:Posix系统相关的封装,包括文件操作,文件锁,后台线程创建

Options

Options定义了操作数据库的选项,定义了3个struct来操作:
Options定义打开数据库的选项;
ReadOptions定义读操作相关的选项;
WriteOptions定义写操作相关的选项。

struct LEVELDB_EXPORT Options {
	const Comparator* comparator;
	Env* env;
	Logger* info_log = nullptr;
	size_t write_buffer_size = 4 * 1024 * 1024;
	Cache* block_cache = nullptr;
	size_t block_size = 4 * 1024;
	size_t max_file_size = 2 * 1024 * 1024;
	CompressionType compression = kSnappyCompression;
	const FilterPolicy* filter_policy = nullptr;
}

struct LEVELDB_EXPORT ReadOptions {
	bool verify_checksums = false;
	bool fill_cache = true;
	const Snapshot* snapshot = nullptr;
}

struct LEVELDB_EXPORT WriteOptions {
  WriteOptions() = default;
  bool sync = false;
}

WAL

持久化的存储系统为了防止机器掉电或系统宕机造成正在写入的数据丢失,在写操作时通常都会先写日志,将要写入的数据先保存下来,若发生机器掉电或系统宕机,机器恢复后,可以读该日志文件恢复待写入的数据。LevelDB也是如此,LevelDB在写入内存MemTable之前,先写入Log日志文件,再写内存MemTable。当发生以下异常情况时,均可以通过Log日志文件进行恢复。
这种日志技术在数据库里面很常用(Redis里的Aof,Innodb里的Redo Log都是这样的技术),一般称为WAL
在这里插入图片描述
在这里插入图片描述

log内容

在这里插入图片描述

Memtable

内存数据结构,
这是常驻内存的C0树,写操作数据的落脚点。写操作并不是直接将数据写入磁盘文件,而是写入内存的MemTable后即表示写成功,然后返回。MemTable就是一个在内存中进行数据组织和维护的数据结构,其本质是一个跳表SkipList,而之所以选用跳表这种数据结构,是由于其应用场景决定的。跳表SkipList这种数据结构的设计来源于数组的二分查找算法,把指针通过设计成数组的方式实现了数组二分查找的高效,使用了用空间换时间的思想。跳表SkipList在查找效率上可比拟二叉查找树,绝大多数情况下时间复杂度为O(log n)。这契合了LevelDB快速查找Key的需要。在MemTable中,所有的数据按用户定义的排序方法排序后有序存储,当其数据容量达到阈值(默认是4MB)时,则将其转化了一个只读的MemTable,即:Immutable MemTable。同时创建一个新的MemTable供用户继续读写。

LevelDB存储的是Kv,而SkipList存储的是键,所以在MemTable里需要做一个转换。
对于插入操作,MemTable会把键和值编码在一个字符串里面,如下图所示,先是键,再是值,使用了字符串的长度前缀编码,然后将这个字符串插入到SkipList里。

而对于查找操作,只会按照前缀编码键,构造上面图的前半部分,而SkipList::Iterator::Seek的实现,会将迭代器定位到大于等于查找键的第一个键的位置,读出这个键,然后比对里面的键和查找的键是否相同,相同的话,才会读取对应的值,否则就是键不存在。

跳表

内存分配

Arena内存管理

Immutable MemTable

当MemTable数据容量达到阈值(默认是4MB)时,则将其转化了一个只读的MemTable,即:Immutable MemTable。这两者的结构定义完全一样,区别只是Immutable MemTable只读。后台的Compaction线程会将Immutable MemTable中的内容,创建一个SSTable文件,持久化到该磁盘文件中。

class MemTable {
	explicit MemTable(const InternalKeyComparator& comparator);
	void Ref() { ++refs_; }
	Iterator* NewIterator();
	void Add(SequenceNumber seq, ValueType type, const Slice& key,
           const Slice& value);
	bool Get(const LookupKey& key, std::string* value, Status* s);
	
  public:
  	struct KeyComparator {
    	const InternalKeyComparator comparator;
    	explicit KeyComparator(const InternalKeyComparator& c) : comparator(c) {}
    	int operator()(const char* a, const char* b) const;
  	};
  	typedef SkipList<const char*, KeyComparator> Table;
	KeyComparator comparator_;
	int refs_;
	Arena arena_;
	Table table_;
}

SSTable

LevelDB中的数据都是通过SSTable存储的。何谓SSTable?就是Sorted String Table,有序的固化表文件,有序体现在Key是按序存储的,也体现在除了Level-0之外,其他Level中的SSTable文件之间也是Key有序的,即:Key不重叠。

读变慢了。以前读的时候,只需要读取MemTable,现在还需要读取SSTable。随着SSTable不断地写入,SSTable会越来越多,当查找一个键时,可能需要读取多个SSTable,这就涉及了多次随机读,读取效率会很低。

在这里插入图片描述

读取一个键

要读取一个SSTable,首先需要打开这个SSTable,打开会有以下步骤:
读取Footer,根据里面的读取Meta Index Block和Index Block,将Index Block的内容缓存到内存中;
根据Meta Index Block读取布隆过滤器的数据,缓存到内存中。
读取一个键的步骤如下:

根据键对Index Block的restart point进行二分搜索,找到这个键对应的Data Block的BlockHandler;
根据BlockHandler的偏移计算出布隆过滤器的编号,读取相应的布隆过滤器;
通过布隆过滤器的数据判断键是否存在,不存在就结束;
否则读取对应的Data Block;
对Data Block里的restart point进行二分搜索,找到搜索键对应的restart point;
对这个restart point对应的键进行搜索,最多搜索16个键,找到键或者找不到键。
通过以上步骤可以看到Index Block和布隆过滤器的内容都是缓存在内存里的,所以当一个键在SSTable不存在时,99%的概率是不需要磁盘IO的。

block

在这里插入图片描述
LevelDB设置了restart point,每16个Kv里第一个Kv是一个restart point,这个Kv的shared_key_length始终为0,也就是这个Kv不采用前缀编码,non_shared_key_content里的内容就是整个键的内容。这样就不需要从每一个Data Block的开头开始构造键了,只需要从每一个restart point开始构造。另外在每个Data Block的末尾存储了一个restart point数组,指向了每一个restart point所在Kv的在块中的偏移,这样便可以支持二分搜索,搜索出键属于哪一个restart point的组里,然后去搜索这个组里面的16个Kv就可以找到这个键。restart point数组就像是一个Data Block的稀疏索引,可以加快键的查找。
根据这个结构,读取一个Data Block末尾的4个字可以获取restart_point_count,每个restart point的大小是4字节,从末尾restart_point_count * 4 + 4开始读取restart_point_count * 4个字节就可以读取到restart point的偏移数组。

搜索一个键:
对restart point进行二分搜索,找到最大的小于等于搜索键的restart point;
从这个restart point对应的键开始遍历,最多遍历16个键,找到Kv,或者确定键不存在。

优化算法

相邻的键很有可能包含相同的前缀,考虑到这个,Data Block做了优化,采用了前缀压缩,也就是后面一个键只需要记录前面一个键不同的部分,以及和前面一个键相同部分的长度,就可以通过前面一个键恢复出后一个键,这样可以节省空间。

Data Block

每个Data Block具有一定的大小,并且按照键的顺序进行排序。也就是后面一个Data Block的第一个键大于前面一个Data Block的最后一个键。
可压缩

Filter Block

存储了布隆过滤器的二进制数据
在这里插入图片描述
baselg表示的是多少数据开启一个布隆过滤器,默认是11,表示每2KB(2 << 11)的数据开启一个布隆过滤器。
这里的2KB是严格的间隔,这样查找一个键时,先查找到Index Block里相应的Data Block的偏移,根据这个偏移量,查找到对应的布隆过滤器,这就是这个Block里的键生成的布隆过滤器。对于Block大小为4K,而布隆过滤器每2K开启的情况,比如第一个Block的偏移是0,那么对应的就是第0个布隆过滤器,而第二个Block偏移是4K,对应的是第2(4k / 2k)个布隆过滤器,这样的情况下第1个布隆过滤器实际就是空的。offset_array_offset指向一个filter offset数组,这个数组保存了每个过滤器的开始位置,这样就可以快速定位到某个过滤器。

Meta Index Block

Meta Index Block指向Filter Block,是Filter Block的索引。不过目前只有一个Filter Block,也就是里面只有一个Kv。键是Filter Block的名字,而值是一个BlockHandler,指向对应的Filter Block。
在这里插入图片描述

Index Block

存储了指向每一个Data Block的指针的数组。
这个指针的键大于等于对应的Data Block的最后一个键,并且小于下一个Data Block的第一个键。
通过Index Block可以实现二分搜索,快速定位键属于哪个Data Block,而不需要扫描所有的DataBlock

知道Data Block的结构后,Index Block就非常简单了,它其实就是存储了一个Kv数组,每一个Kv对应一个Data Block,其中键大于等于对应的Data Block中最后一个键,值为一个BlockHandler,可以定位到一个Data Block。Index Block就是Data Block的索引,搜索时可以对Index Block二分搜索,找到键对应的Data Block。
在这里插入图片描述

Footer

因为Meta Index Block和Index Block的大小是不固定的,没法直接定位到这两个Block,所以最后有一个大小固定的Footer,保存两个BlockHandler,分别指向Meta Index Block和Index Block。

class BlockHandle {
	private:
  		uint64_t offset_;
  		uint64_t size_;
};
class Footer {
	public:
		enum { kEncodedLength = 2 * BlockHandle::kMaxEncodedLength + 8 };
	private:
  		BlockHandle metaindex_handle_;
  		BlockHandle index_handle_;
}

拿到一个SSTable后,读取最后32个字节,就可以得到Footer,根据里面的信息就可以读取Meta Index Block和Index Block。

B+树对比

B+ Tree,这是一种古老的磁盘数据结构,现在很多数据库依然在采用,具有很好的读取性能。B+ Tree其实是一种多级索引,设计成这样是为了支持快速读取,同时也支持更新,但是B+ Tree更新的开销会比较大。

Compaction

所有的SSTable文件本身是不可修改的,Compaction压缩线程会把多个SSTable文件归并后产生新的SStable文件,并删除旧的SSTable文件。由于Level-0是直接由内存Immutable MemTable中的数据转化而来,所以Level-0中的SSTable文件中的Key是存在重叠的,不同的SSTable文件也存在Key重叠的情况,因此Level-0会有很多的限制条件,1)Level-0中文件的个数达到4个时,会触发压缩Compaction;2)Level-0中文件的个数达到8个时,写入操作将会受到限制;3)Level-0中文件的个数达到12个时,写入操作将会被停止。

后期归并生成的SSTable文件在Level-i层,这就是LevelDB的名字的由来。而之所以叫leveled,而不是tiered,是因为第i+1层的数据量是i层的倍数。 这种设计哲学为LevelDB带来了许多优势,简化了很多设计。

Level 0的SSTable会Compaction到Level 1,而Level 1的SSTable会Compaction到Level 2,随着Level的增大,每一层的文件总大小会以10倍增大,这样不但可以有大量的存储空间,而且每一次Compaction涉及的SSTable的数量都是可控的。Compaction实际上就是对输入的多个SSTable进行多路归并的过程。

随着Level的增多,读取更复杂了。要先读取MemTable,再读取Level 0的文件,Level 0可能有多个文件的键范围包括这个查找键,还需要读取Level 0以上的文件,每一Level最多有一个文件的键范围包括查找键。不过Level的数量有限,Level 0的文件数量也有限,所以需要读取的SSTable的数量依然是常数级,配合缓存、布隆过滤器等优化技术,可以提高读的性能。这是在读取性能和后台操作性能之间的折中,为了让写操作成为顺序写,而做的牺牲。

Manifest文件

LevelDB中有版本Version的概念,一个版本Version主要记录了每一层Level中所有文件的元数据。std::vector<FileMetaData*> files_[config::kNumLevels];,而一个文件的元数据主要信息包含:文件号、文件大小、最大的Key和最小的Key等。每次Compaction完成,LevelDB都会创建又给新的Version,newVersion=oldVersion+VersionEdit,其中VersionEdit是指在oldVersion基础上变化的内容(新增或减少的SSTable文件)。Manifest文件就是用来记录这些VersionEdit信息的。一个VersionEdit信息会被编码成一条Record记录,写入Manifest文件,每条记录包括:1)新增哪些SSTable文件;2)删除哪些SSTable文件;3)当前Compaction的指针下标;4)日志文件编号;5)操作SequenceNumber等信息。通过这些信息LevelDB在启动时便可以基于一个空的Version,不断地Apply这些记录,最终得到一个上次运行结束时的版本信息。

Current文件

这个文件中只有一个信息,就是记录当前的Manifest文件名。

因为每次LevelDB启动时,都会创建一个新的Manifest文件。因此数据目录可能会存在多个Manifest文件。Current则用来指出哪个Manifest文件才是我们关心的那个Manifest文件。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
要阅读Leveldb源码,你可以按照以下步骤进行: 1. 确保你对C++语言有基本的了解。Leveldb是用C++编写的,因此你需要熟悉C++的语法和面向对象编程的概念。 2. 阅读Leveldb的README文件。这个文件提供了关于Leveldb的基本信息,如其用途、功能和性能特征。同时,它还列出了Leveldb的依赖关系,这对于理解源码以及构建和运行Leveldb非常重要。 3. 了解Leveldb的核心概念和数据结构。Leveldb是一个高效的键值存储库,它使用了一些关键的数据结构,如有序字符串表(Skip List)和持久化存储。 4. 查看Leveldb的目录结构。Leveldb源码包含了一些核心文件和目录,如“db”目录下的文件是Leveldb的核心实现。理解源码的组织结构可以帮助你快速找到感兴趣的部分。 5. 阅读核心文件的源码。从“db/db_impl.cc”文件开始,这个文件是Leveldb的主要实现。阅读这个文件可以帮助你了解Leveldb如何管理内存、实施并发控制和实现持久化存储。 6. 跟踪函数调用和数据流。了解Leveldb的主要功能是如何通过函数调用进行实现的很重要。你可以使用调试器或添加日志输出来跟踪函数调用和数据流,这有助于你了解代码的执行流程和逻辑。 7. 阅读Leveldb的测试用例。Leveldb源码中包含了大量的测试用例,这些用例对于理解Leveldb的不同功能和特性非常有帮助。通过阅读和运行这些测试用例,你可以对Leveldb的行为有更深入的了解。 8. 参考文档和论文。如果你想更深入地了解Leveldb的实现原理和技术细节,可以查阅Leveldb的官方文档或相关的论文。这些文档可以为你提供更详细的信息和背景知识。 最后,要理解Leveldb源码并不是一件简单的任务,需要投入大量的时间和精力。所以,建议你在阅读源码之前,对C++和数据库原理有一定的了解和经验,同时也要具备耐心和持续学习的精神。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值