关于KV数据库leveldb的介绍,网上已经太多了,这里只是自己再学习源码过程中,整理的笔记,磁盘存储和内存存储的结构用了伪代码表示出来了,首先是内存中存储结构,然后是log文件存储结构和磁盘数据sst文件存储结构。
MemTable存储格式
MemTable底层是用skiplist(跳跃表)进行存储, 数据全部存储在内存中, 具体结构设计如下:
class MemTable
{
enum ValueType
{
kTypeDeletion = 0x0, /*正常标记*/
kTypeValue = 0x1 /*已删除标记*/
};
/*跳跃表中存储的实体信息*/
struct Entity
{
/*key长度*/
key_size;
/*key数据*/
key_bytes;
/*标识是否删除ValueType中一个*/
type;
/*value长度*/
value_size;
/*value数据*/
value_bytes;
};
SkipList<Entity*, KeyComparator> table_;
}
Log文件
Log文件存储在磁盘上, 用于数据恢复使用, 写入数据前先写入log文件, 与mysql方式类似, 写入的实体格式Entity如下, 写入块以32KB为单位, 如果一个块空间有足够空间容纳新写入的Entity, 则直接写入, 并将记录类型type置为KFullType; 如果无法完整写入, 则写入Entity开始部分的块类型为kFirstType, 写入中间部分块类型为kMiddleType, 写入最后部分块类型为kLastType. 一个块内可能写入多个Entity, 一个Entity可能写入多个块中,块方式写入之后,在读取日志进行恢复数据时, 变得很方便, 直接按块大小读取, 加快访问速度.
Entity实体结构示意图
| HEADER | key, value对 |
|--------------|------------|----------|------------|-----------|-------------|------------|-------------|......|
| checksum | length | type | val_type | key_size | key_bytes | val_size | val_bytes |......|
struct Entity
{
/*主要标识一个Entity是否在当前块中的*/
enum RecordType
{
kZeroType = 0,
kFullType = 1,
kFirstType = 2,
kMiddleType = 3,
kLastType = 4
};
struct Header
{
/*32位crc校验码, 对写入数据校验*/
int4 checksum;
/*日志块长度*/
int2 length;
/*RecordType中一种*/
int1 type;
};
/*键值对可以批量写入, 因此一次可能有N个键值对*/
struct KeyValuePair
{
/*标识值被删除,还是正常状态*/
val_type;
/*键长度*/
key_size;
/*键内容*/
key_bytes;
/*key对应的值长度*/
val_size;
/*key对应的值内容*/
val_bytes;
}[N];
};
SST文件存储格式
SST文件存储最终落入磁盘的数据, 数据是只读的, 数据默认是压缩存储. 下面是伪代码的存储数据结构, 文件依次存储数据块, 数据块索引, 过滤器,文件尾。
1.key共享存储:block中存储的一条条记录, 每条记录中一个KV对, 假如存储key为user1, user2, user3, 则首先存入user1, shared_size值为0,non_shared_size为0,后面依次存入value长度和值,存入user2时,由于user1和user2是共享user部分,因此user2中shared_size为4,non_shared_size为1,后面依次存入value长度,non_shared值也就是1,value值,后面存入user3时,同理,shared_size为4,non_shared_size为1,后面依次存入value长度,non_shared值也就是3,value值,由于SST中存储的key值都是有序的,key如果相似的,这种存储可以节省很多空间。
2.重启点:block最后存储了一个重启点数组,默认间隔16条记录插入一个重启点,插入重启点位置的key是一个完整的key,没有共享字段,插入重启点是为了加快block中查找key的速度,block中进行查找时,我们首先在重启点数组中利用二分查找,找到距离查找小于key最近的重启点,然后顺着重启点依次查找,直到找到key,或者没有找到。
3.过滤器BlockMeta:为了减少操作磁盘次数,leveldb加入了过滤器,创建db的时候可以指定过滤器,leveldb实现了布隆过滤器供使用。BlcokMeta中每条记录对应一个BlockData的过滤器,查找时,如果过滤器中没有找到则直接返回,否则在BlockData中进行查找。
4.块索引BlcokIndex:存储块对应的索引,其中key为前一个块中最后一个key和后一个块中第一个key之间的一个值,比如block1中最后key为user1, block2中最小key为user5,索引的key值为user2;如果block2中最小的key为user2,则索引的key只能为user1。
/*M个数据块, 存储具体数据*/
struct Block
{
/*每个数据块中存储N条记录*/
struct Record
{
/*Key中共享字段长度*/
size_t shared_size;
/*Key中独有字段长度*/
size_t non_shared_size;
/*Key对应的Value字段长度*/
size_t value_size;
/*Key中独有字段内容*/
byte non_shared_bytes[non_shared_size];
/*Key对应Value字段内容*/
byte value_bytes[value_size];
}[N];
/*重启点数组方式保存, 长度和重启点都已固定大小存储,值表示重启点距离block开始位置的偏移量*/
uint32 restarts[restart_num];
/*重启点个数*/
uint32 restart_num;
/*标识是否进行压缩*/
byte type;
/*数据校验码, 如果压缩数据, 则校验码是数据压缩之后的校验码, 校验数据的完整性*/
uint32 crc;
};
class table
{
/*存放数据的数据块*/
Block BlocKData[N];
/*存放Data数据块对应的索引, 每个记录对应一个Block, 其中value存储的是块相对于文件头的偏移量*/
Block BlockIndex;
/*存储过滤规则,默认没有,一般使用布隆过滤器,可能为空,里面每条记录对应一个block生成的过滤器*/
Block BlcokMeta;
struct Footer
{
/*过滤器数据相对于文件头的偏移量*/
uint64 metaindex_offset;
/*过滤器数据长度*/
uint64 metaindex_size;
/*BlockIndex数据相对于文件头的偏移量*/
uint64 blockindex_offset;
/*BlockIndex数据长度*/
uint64 blockindex_size;
/*文件尾部填充的魔数*/
uint64 magic_number;
}
};
LRU缓存
leveldb中读性能比不了内存数据库,由于分层存储,为了尽量减少磁盘操作,实现了一套缓存机制,缓存以查找的key作为hash,对应值为key所在的table指针。缓存做了两级,外层是是固定大小为16的hash表,hash表中每条记录中对应一个随元素数量增长的hash表, 两层hash一方面可以减少hash碰撞次数, 另一方面rehash时减少copy内存的长度, 内层的缓存操作是需要加锁的, 分层之后减少锁的竞争次数.
分层
leveldb磁盘存储的文件分为level-0到level-6, 每一层中有若干个文件, 所有文件长度和最大限制如下, 默认存储总量10TB左右. 其中level-0中默认最大文件个数限制为4
level-0 10M
level-1 100M
level-2 1000M
level-3 10000M
level-4 100000M
level-5 1000000M
level-6 10000000M
合并
leveldb数据存储分为两部分内存中MemTable和磁盘上Table文件, 合并的过程就是将内存数据合并入磁盘中, 磁盘中低层数据向高层合并.向数据库中写入一个key时, 首先将Key和Value值写入log文件中, 然后检查MemTable中数据大小, 如果大于临界值(默认4M), 则重新创建MemTable, 将Key插入, 原来的MemTable则保存在Imm中, 只用于查询使用, 检查是否需要进行合并操作, 流程如下.
1.判断Imm是否为空, Imm非空先遍历Imm中数据依次写入sst文件中, 然后挑选合适的level进行合并, 从level-0开始遍历到level-6, 挑选过程如下, 挑选结束后直接将生成的sst文件添加进挑选的level.
a) 由于level-0不同文件中存在重叠key, 因此单独判断Imm中key和level0中key是否重叠, 重叠则直接将Imm中数据合并入level-0中, 否则继续向下;
b) 假设遍历到level-1层发现key和Imm中key有重叠, 则直接将Imm合并入level-0层; 否则继续向下.
c) 假如遍历到level-1层发现key和Imm中key没有重叠, 但是level-2层中key与Imm中key重叠文件长度大于kMaxGrandParentOverlapBytes(默认20M), 则直接合并入level-0层, 避免level-1和level-2层重叠太多,后面产生过多的合并操作. 否则level+1后继续步骤b进行遍历.
2.Imm为空时, 则需要合并磁盘中的数据是否需要合并, 每次修改VersionSet集合中的文件时,都会对每层数据评估得出一个score, 评估出下次最合适合并的level,
level-0层 : score=文件个数/文件最大总数.
level-1~6层, score=文件总长度/本层文件最大长度.
根据获取的score值, 得出本次最需要合并的level, 如果level中文件在level+1中key没有重叠, 则直接将level中文件移除, 并添加到level+1中; level和level+1中key存在重叠, 则需要使用合并迭代器, 包含了level和level+1层需要合并的文件迭代器(可能包含多个文件), 每次合并迭代器迭代一次, 选择两层中最小的key, 插入到新的输出文件, 如果当前遍历的key已经被删除或者不是最新的, 则直接忽略. 最终生成一个新的文件, 插入到level+1层.
查找元素
查找元素过程, 首先在MemTable中查找, 找到则返回, 否则在Imm中查找, 找到则返回, 否则继续开始在level0~6中进行查找, 首先在每一层中使用二分查找key所在的文件, 文件找到之后, 通过快索引二分查找key所在的块, 通过块中的过滤器(一般是布隆过滤), 匹配key值是否存在, 不存在直接返回查找不到, 否则通过重启点二分查找key所在的记录, 从而定位key是否存在, 存在返回key对应的value, 否则返回查找不到.
添加删除修改元素
leveldb添加元素,只需要将元素添加进MemTable中即可, 添加元素时会生成一个内部key, 包含是否删除元素标志和唯一的序列号, 通过删除标志确定是否为删除元素, 通过序列号可以确定元素是否为最新元素, 进行合并操作时可以判断元素状态. leveldb删除元素时并不会对原来的元素进行修改移除, 只是插入一个设置删除标志位的新元素, 合并时会移除原来的元素, 更新操作操作一样, 同样插入一个新元素, 合并时通过序列号确定元素是否为最新的, 从而移除老的元素.
Version管理
leveldb中文件版本信息和数据库的信息都写入在MANIFEST-xxxxx文件中, 文件及其重要, 包含每一层的所有文件的描述, 日志文件序号, 插入key的序列号等信息, 丢失之后数据库基本废掉. VersionSet版本集合操作版本信息, VersionEdit保存了Version的修改信息, 以追加的方式添加在MANIFEST-xxxxx文件中, 因此MANIFEST-xxxxx文件中还保存有历史版本信息, 每次数据库重启都需要重新读取MANIFEST-xxxxx文件并将所有的版本信息读出, 并执行相应的VersionEdit, 生成当前版本Version. 每次进行合并操作都会生成一个VersionEdit, 追加到VersionSet中, 并写入MANIFEST-xxxxx文件中.