我们知道文件系统在mount之前先要进行格式化,格式化就是按照一定的规则将一块磁盘进行划分和管理,这些都是元数据信息,并将这些信息固化到磁盘上,当需要存储数据时就按既定的规则写入磁盘中的位置,当需要读取数据时,就去固定的位置读上数据来。如果没有这些元数据信息,就像看一本书没有目录和页码信息,是很难找到任意想要的数据的。任何一种存储系统都有自己的“格式化”,都有自己规定好的数据格式和数据固化布局,LevelDB也不例外,它的数据存储是在磁盘SSTable文件中。下面我们来分析一下LevelDB存储的数据在磁盘SSTable文件中的布局,其数据是如何组织的。
一、LevelDB数据编码方式
LevelDB使用了变长数据类型(Varint)和定长数据类型(Fixed32/Fixed64)来定义数据的。定长数据类型我们容易理解,基本上所有的变成语言提供的基本数据类型都是定长的,那为何LevelDB中还会有变长的数据类型?LevelDB是K-V数据库,输入的key-value有很多是不足4字节或8字节的,因此,为了节省空间,设计成变长编码存储。
1 、变长数据类型(Varint)
什么是 Varint
Varint是一种紧凑的表示数字的方法,它用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数,这样能减少用来表示数字的字节数。
Varint 编码:
举例:查看int32/int64最多需要多少Bytes编码?
计算公式:x ∗ 8 − x > 32,解得x = 5,即:需要5Bytes。y∗8−y > 64,解得y = 10,即:需要10Bytes。所以,32位最多需要5Bytes,64位最多需要10Bytes。
但是对于int32 类型的数字,一般才需要4 Bytes来表示,采用了Varint,最多需要5Bytes,这是不是占多空间了,怎么还会节省空间?别急,Varint最多才需要5Bytes,并不是所有的Varint都需要占用5Bytes,对于很小的int32类型的数字,则可以用1 Byte来表示。
当然凡事都有好的一面,也有不好的一面,采用Varint 编码,大的数字则需要5 Bytes来表示,但从统计学的角度来说,一般不会所有的数据都是大数,因此大多数情况下,采用Varint 后,可以用更少的字节数来表示数字信息。
下面就详细介绍一下 Varint 编码规则
Varint中的每个Byte的最高位bit有特殊的含义:如果该位为1,表示后续的 Byte 也是该数字的一部分;如果该位为0,则表示该数字结束。其余的7 个 bits 都用来表示数值。
由此可知,对于小于128 的数字都可以用1 byte 表示;大于128的数字,比如300,会用两个字节来表示:1010 1100 0000 0010(小端模式)
如何解析这两个字节呢?
1010 1100 & 127 = 0010 1100 = 44(十进制)
0000 0010 << 7 = 1 0000 0000 = 256(十进制)
44+256 = 300
从描述上看,Varint与Utf-8编码有些类似,是变长编码。
LevelDB 中的实现分析
下面来看看Varint在LevelDB中是如何实现的,位置是util\coding.cc
1、计算编码后的长度是几个字节:小于128,直接就是1Byte;大于128,就看能右移几次,每右移一次代表一个字节。
int VarintLength(uint64_t v) {
int len = 1;
while (v >= 128) {
v >>= 7;
len++;
}
return len;
}
2、将一个32位无符号int进行编码并保存到dst指向的空间
char* EncodeVarint32(char* dst, uint32_t v) {
// Operate on characters as unsigneds
unsigned char* ptr = reinterpret_cast<unsigned char*>(dst);
static const int B = 128;
if (v < (1<<7)) {
*(ptr++) = v;
} else if (v < (1<<14)) {
*(ptr++) = v | B;
*(ptr++) = v>>7;
} else if (v < (1<<21)) {
*(ptr++) = v | B;
*(ptr++) = (v>>7) | B;
*(ptr++) = v>>14;
} else if (v < (1<<28)) {
*(ptr++) = v | B;
*(ptr++) = (v>>7) | B;
*(ptr++) = (v>>14) | B;
*(ptr++) = v>>21;
} else {
*(ptr++) = v | B;
*(ptr++) = (v>>7) | B;
*(ptr++) = (v>>14) | B;
*(ptr++) = (v>>21) | B;
*(ptr++) = v>>28;
}
return reinterpret_cast<char*>(ptr);
}
v < (1<<7),即:v<128,直接存入一个字节到dst指向的空间;
v < (1<<14),即:128<=v<16384,分析这个分支发生了什么?注意v是uint32_t, B是int 值为128,ptr是一个指向unsiged char类型的指针变量。128<=v<16384表示需要两个字节表示,首先要清楚,如果一个表达式中既有无符号数又有有符号数时, 那么有符号数int值将会转换为无符号数。
if (v < (1<<14)) {
*(ptr++) = v | B;
*(ptr++) = v>>7;
}
那么对于v | B,B将转换成uint32_t, 那么v | B 就等价于:v | 00000000 00000000 00000000 10000000,结果就是将v的第8位置1后的值。因为需要两个字节表示,肯定会有后续字节,所以第八位一定是1。然后计算*(ptr++) = v | B,由于*ptr为unsigned char类型,而v | B结果是uint32_t,这时会发生截断,ptr即为v | B结果的最后低8位;而(ptr++) = v>>7保存了v剩下的高8位(一个字节)。
因此,最后的else分支就是:第8bit置1后,保存低8位到ptr中,把v不断地右移7bits,第8bit置1后,保存到ptr中,直到v所有地bits位表示完成,返回地址ptr是当前编码后空间地址。
编码一位32位无符号int需要buf 5字节空间,返回地址ptr是当前编码后空间地址,prt-buf就是编码数据的长度。
void PutVarint32(std::string* dst, uint32_t v) {
char buf[5];
char* ptr = EncodeVarint32(buf, v);
dst->append(buf, ptr - buf);
}
同理,编码一位64位无符号int:第8bit置1后,保存低8位到ptr中,把v不断地右移7bits,第8bit置1后,保存到ptr中,直到v所有地bits位表示完成,返回地址ptr是当前编码后空间地址。
char* EncodeVarint64(char* dst, uint64_t v) {
static const int B = 128;
uint8_t* ptr = reinterpret_cast<uint8_t*>(dst);
while (v >= B) {
*(ptr++) = v | B;
v >>= 7;
}
*(ptr++) = static_cast<uint8_t>(v);
return reinterpret_cast<char*>(ptr);
}
编码一位64位无符号int需要buf 10字节空间,返回地址ptr是当前编码后空间地址,prt-buf就是编码数据的长度。
void PutVarint64(std::string* dst, uint64_t v) {
char buf[10];
char* ptr = EncodeVarint64(buf, v);
dst->append(buf, ptr - buf);
}
解析一位64位无符号int
bool GetVarint64(Slice* input, uint64_t* value) {
const char* p = input->data();//Footer起始地址
const char* limit = p + input->size();//Footer结束地址
const char* q = GetVarint64Ptr(p, limit, value);//value已经解析出第一个值,q指针是下一个值的地址
if (q == nullptr) {
return false;
} else {
*input = Slice(q, limit - q);//用下一个值的开始地址和剩余值大小,重新生成一个input
return true;
}
}
const char* GetVarint64Ptr(const char* p, const char* limit, uint64_t* value) {
uint64_t result = 0;
for (uint32_t shift = 0; shift <= 63 && p < limit; shift += 7) {
uint64_t byte = *(reinterpret_cast<const uint8_t*>(p));//get one byte value
p++; //input指针一直在递增!
if (byte & 128) {//if eighth bit is 1, that's means thers are more bytes are present.
// More bytes are present
result |= ((byte & 127) << shift);//save this byte value, and loop again.
} else {
result |= (byte << shift);//if eighth is 0, that's means there is no more bytes left, just save this one byte.
*value = result;//assigned result to *value
return reinterpret_cast<const char*>(p); //最后返回的input指针的当前位置,已经不是开始位置了。
}
}
return nullptr;
}
2 、定长数据类型(Fixed32/Fixed64)
定长数据好办,数据类型占多少字节,就申请多大的buf,uint32_t占4字节。编码也是一样value的每个字节都占1Byte空间,是固定的空间,这个没什么好讲的。unint64_t同理,不再赘述。
void PutFixed32(std::string* dst, uint32_t value) {
char buf[sizeof(value)];
EncodeFixed32(buf, value);
dst->append(buf, sizeof(buf));
}
inline void EncodeFixed32(char* dst, uint32_t value) {
uint8_t* const buffer = reinterpret_cast<uint8_t*>(dst);
// Recent clang and gcc optimize this to a single mov / str instruction.
buffer[0] = static_cast<uint8_t>(value);
buffer[1] = static_cast<uint8_t>(value >> 8);
buffer[2] = static_cast<uint8_t>(value >> 16);
buffer[3] = static_cast<uint8_t>(value >> 24);
}
二、SSTable文件数据格式
接下来,我们来分析下LevelDB存储数据的SSTable文件的数据格式,或者叫数据在SSTable文件中的空间分布。如下所示,是一个LevelDB目录,其中可以看到一个扩展名为.ldb的文件,此文件便是持久化键值对的sstable文件。
[root@zhanglianlei testdb]# ls
000005.ldb 000006.log CURRENT LOCK LOG LOG.old MANIFEST-000004
LevelDB的键值对都持久化到扩展名为ldb的文件中,一个.ldb文件存储了一定键范围内(min, max)的键值对。一个SSTable文件由5部分组成,从上到下看分别是:Data Blocks、Filter Block、Meta Index Block、Index Block和Footer,如下图所示:
1)Footer: 代表了一个SSTable文件的页脚,大小固定,48Bytes,它记录了一个文件最关键的两个信息Meta Index Block和Index Block。一个SSTable文件是不固定的,但是Footer大小是固定的,读取Footer就可以获取这两个关键信息,进而获取整个SSTable文件的元数据信息和数据信息。
2)Meta Index Block: 这是一个控制信息,就是图中的metaindex_handle_,这是一个BlockHandler对象,它记录了FilterBlock对象的偏移起始位置和整个FilterBlock的大小。
3)Index Block: 这也是一个控制信息,就是图中的index_handle_,这也是一个BlockHandler对象,它记录了DataBlock对象的偏移起始位置和整个DataBlock的大小。
4)Filter Block或称Meta Block: 是为了快速判断一个Key是否存在于该SSTable文件的。当查找一个Key的时候,如果Key不在内存Memtable中,那就需要遍历SSTable文件,这显然太慢。为了提高查找的效率,于是就设计了一个Filter Block在SSTable文件中,这样就提高了读取的速度。可以有多个Filter Block,目前只有一个布隆过滤器。
5)Data Block: 是用来存储数据的,Data Block有多个,LevelDB将数据(键值对)存放在多个Data Block里面,即:每个SSTable文件包含多个Data Block。每个Data Block有一定的大小,并且按照键的顺序进行排序,也就是Data Block是按键的大小顺序组织键值对数据的,后一个Data Block的第一个Key大于前一个Data Block的最后一个Key。
下面详细介绍一下这几个数据结构:
1、Footer: 将固定大小的信息存入每个SSTable文件的尾部。
数据结构如下:
// Footer encapsulates the fixed information stored at the tail
// end of every table file.
class Footer {
private:
BlockHandle metaindex_handle_;
BlockHandle index_handle_;
};
结构图如下所示:
BlockHandle的数据结构如下,包含一个偏移offset_和一个size_,这是一个文件指针,offset_是文件的偏移量,直接定位到文件某处,然后读取size_大小数据,size_就是Block的大小,这样就可以拿到整个Block数组的大小。
// BlockHandle is a pointer to the extent of a file that stores a data
// block or a meta block.
class BlockHandle {
private:
uint64_t offset_;
uint64_t size_;
};
BlockHandle的最大编码长度是10+10,所以这是变长Varint编码。
// Maximum encoding length of a BlockHandle
enum { kMaxEncodedLength = 10 + 10 };
Footer包含两个BlockHandle和一个Magic Number,是一个固定大小的数据结构 48 Bytes = 2 * (10+10) + 8,魔数是为了校验功能。
// Encoded length of a Footer. Note that the serialization of a
// Footer will always occupy exactly this many bytes. It consists
// of two block handles and a magic number.
enum { kEncodedLength = 2 * BlockHandle::kMaxEncodedLength + 8 };
Magic Number:
static const uint64_t kTableMagicNumber = 0xdb4775248b80fb57ull;
所以,拿到一个SSTable文件,读取文件末尾的最后48字节,就可以拿到Footer,根据里面的信息就可以拿到Meta Index Block和Index Block。
2、Block: 除了Footer之外,其他对象都是一个Block,Block的通用数据结构如下:
class Block {
private:
const char* data_;
size_t size_;
uint32_t restart_offset_; // Offset in data_ of restart array
bool owned_; // Block owns data_[]
};
此外,Block有一个5-byte的尾部:1-byte type + 32-bit crc
// 1-byte type + 32-bit crc
static const size_t kBlockTrailerSize = 5;
所以一个Block就如下图所示:
3、Data Block的结构
Meta Index Block、Index Block和Data Black都是Block,具有相同的基本结构,所以先介绍下Data Block的格式就十分必要。Data Block的默认大小是4K,但这不是固定的,有可能会超过4K,每次插入一个键值对时都会判断是否超过了48KB,如果插入一个大一点的键值对,就会超过4KB,不过一个键值对只会保存在一个Data Block里面,不会横跨两个Data Block。
由于相邻的键有可能存在相同的前缀,考虑到这一点,LevelDB设置了前缀压缩法,也就是后面的键只需要记录与前一个键不同的部分,以及跟前一个键相同部分的长度,这样通过前一个键就可以还原出后一个键的完整Key,为何要这么设计呢?之所以这样设计是为了节省存储空间,因为Keys之间有相同部分的概率是存在的。
一个KV的格式如下:共享key的长度+非共享key的长度+值value的长度+非共享key的内容+值value
shared_length | non_shared_length | value_length | non_shared_contents | value
通过shared_length和前一个键的值可以得到当前键的前缀,然后根据这个KV里面的后缀non_shared_contents,便可以拼接得到这个完整的键Key。通过这种前缀压缩的方式将多个KV连续存放在Data Block里,但是这样会有一个问题,无论想查找哪一个键Key的值,都需要从Block的第一个键Key开始遍历,依次构造拼接出最终想要的key,那么搜索一个键Key就需要遍历整个Block,这样显然效率会比较低。那怎么办呢?怎么去优化?既然KV的格式已经定了(压缩共享key),要搜索一个Key就需要拼接,那能不能拼接不要无限长,把key一组一组的划分,同一组内key是可以共享的,每一组的第一个key作为起始key就不要共享,该组内后续的key与第一个key共享key,这样key的拼接就是有限次的,查找一个key不需要遍历整个Block,只需要遍历有限个Key,这样效率不会受影响。于是就引入一个叫restart point的概念,记录每个分组的第一个KV的位置,这个位置的KV的shared_length为0,non_shared_length就是整个Key的长度,non_shared_contents就是完整的Key。
什么是restart point?
LevelDB设置了restart point,每16个KV就设置一个restart point,整个KV的shared_length为0,non_shared_length就是整个Key的长度,non_shared_contents就是完整的Key,也就是整个KV不采用前缀共享编码,这样就不需要从Data Block的开头起来构造Key,只需要从每一个restart point处来拼接构造Key。
而一个Data Block又是16个KV的索引,Data Block中的KV就指向具体用户数据Key-Value。搜索时,先对Index Block进行二分查找,找到键对应的Data Block,再对Data Block内部进行二分查找,找到具体的KV,再遍历16个Key,找到Key,或Key不存在。
如下就是一个完整的Data Block的结构:
搜索一个键Key时:
1、对restart point进行二分查找,比较每个restart point对应的Key,查找到最大的小于等于查找Key的那个restart point。
2、从这个restart point对应的KV开始搜索,最多搜索16个Key,找到KV,或者Key不存在。
Block与Data Block的关系如下所示:
4、Index Block
再来看Index Block,首先它是一个块Block,因此它具有Block的基本结构。
Index Block存在的目的是什么呢?前面我们已经分析了Data Block,但是在一个文件中Data Block太多,如何快速的定位到一个Data Block呢?这就是Index Block的设计目的,简单理解就是Data Block的索引目录。
既然是Data Block的索引目录,那么必然会指向某一个Data Block,所以其value就是一BlockHandle(offset_, size_)。而key就是大于等于对应的Data Block中最后一个键,这样一些列的KV数据存在于Index Block中。为了便于管理这些KV数据,仍然使用restart point的方式,这样能够快速定位到一条KV数据。
在Index Block中Key的格式也符合:共享key的长度+非共享key的长度+值value的长度+非共享key的内容+值value
0 | non_shared_length | value_length | non_shared_contents | block_handle
因为Index Block中的这些key是不连续的KV数据,因此Index Block中的共享key长度为0,也就是不采用前缀压缩方式。因此,Index Block中的restart point是一个稠密索引,每个KV都对应一个restart point。
总上所述,Index Block的结构图如下所示:
5、Meta Index Block
Meta Index Block也是一个块Block,因此它具有Block的基本结构。
目前这里只有一个KV,Key是filter.leveldb.BuiltinBloomFilter2,Value是Filter Handle(一个BlockHandle),指向一个Filter Block。
6、Filter Block
Filter Block就是Meta Index Block中记录的布隆过滤器了。
为什么要在SSTable文件中放置一个布隆过滤器?
我们知道布隆过滤器是一种概率型的数据结构,特点是高效的插入和查询,它的作用是为了快速判定一个Key是否存在。这不是精确判断:如果一个Key判定存在,实际不一定存在;但是,如果判定一个Key不存在,那就一定不存在,其结果是假阳性的。利用这个特性,可以快速的判定一个Key是否存在,如果不存在,就不需要再去查找了,直接返回错误;如果判定存在,则需要进一步查到确定Key是否存在。
在一个SSTable文件中,正常的查找流程如下:
1)对Index Block进行二分搜索,查到键所属的Data Block;
2)读取Data Block,对restart point进行二分搜索,查找对应的键所属的restart point;
3)定位到restart point处,再遍历16个Keys进行键的查找。
理想情况下,Index Block可以缓存在内存中,但是Data Block数据量太大,可能不会缓存在内存中,这样就需要一次磁盘IO,查询效率必然不高。
如何避免磁盘IO呢?怎么才能放到内存中来查找呢?布隆过滤器可以解决这个问题。布隆过滤器比较小,可以缓存在内存中,这样就可以通过布隆过滤器快速判断对应的键有没有在这个SSTable里。如果判断键在SSTable,那也只有很小的概率是键不在这个SSTable里。这是典型的用空间换时间的思想,选择布隆过滤器是因为布隆过滤器的空间占用非常小,可以加载到内存中,进行快速判定。
布隆过滤器
1、什么是布隆过滤器
布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。
2、布隆过滤器的优缺点:
优点:
1)时间复杂度低,增加和查询元素的时间复杂为O(N),(N为哈希函数的个数,通常情况比较小)
2)保密性强,布隆过滤器不存储元素本身
3)存储空间小,如果允许存在一定的误判,布隆过滤器是非常节省空间的(相比其他数据结构如Set集合)
缺点:
1)有点一定的误判率,但是可以通过调整参数来降低
2)无法获取元素本身
3)很难删除元素
3、布隆过滤器的使用场景
布隆过滤器可以告诉我们 “某样东西一定不存在或者可能存在”,也就是说布隆过滤器说这个数不存在则一定不存,布隆过滤器说这个数存在可能不存在。利用这个判断是否存在的特点可以做很多有趣的事情。
知道了布隆过滤器的作用,接下来,我们分析LevelDB中布隆过滤器是如何布局的。LevelDB采用了多个布隆过滤器,默认情况下,每2KB的数据开启一个布隆过滤器(如下定义),因此,布隆过滤器也必然组成一个数组结构,last_word就是偏移数组的位置,数组的每个值指向一个布隆过滤器的位置。
// Generate new filter every 2KB of data
static const size_t kFilterBaseLg = 11;
static const size_t kFilterBase = 1 << kFilterBaseLg;
布隆过滤器的数据结构:
class FilterBlockReader {
private:
const FilterPolicy* policy_;
const char* data_; // Pointer to filter data (at block-start)
const char* offset_; // Pointer to beginning of offset array (at block-end)
size_t num_; // Number of entries in offset array
size_t base_lg_; // Encoding parameter (see kFilterBaseLg in .cc file)
};
last_word是偏移数组的大小,DataSize - 5byte - last_word就是offset_指向的位置。
如上便是一个SSTable文件的数据分布,知道了这些后,LevelDB的读、写操作、压缩操作便非常容易理解了。
因为Index Block和Filter Block数据量并不大,因此,会读到内存中,并构建了LRUCache,这样就能快速查找一个key是否存在于一个SSTable文件中了。