LevelDB作为一个数据存储引擎,存储的数据大部分是在磁盘上的,而磁盘上数据的表现形式就是文件,也就是本章要介绍的SST文件,SSTable 是 Sorted String Table 的简称,SST的生成时机有两个,一是内存中的Immutable Memtable Flush到磁盘上会生成SST文件,二是在Compaction的时候相邻层级的SST文件合并生成新的SST文件,而这两者都是通过TableBuilder来生成SST文件的,本片将会介绍SST文件的生成过程以及文件结构
我们先看一下SST文件的整体结构,实际上SST文件是由一系列的Block和末尾的一个Footer组成的,而Block又分为Data Block(用于存储用户实际数据的), Filter Block(实际上就是一个过滤器,可以快速的判断一个Key是否在某个Data Block当中), MetaIndex Block(用于存储一些Meta信息,目前存储的是Filter的名称,以及Filter Block的起始位置和Size),最后就是Index Block(存储了该SST文件中每个Data Block的索引Key和Block的起始位置还有Size)
/*
* 下图就是整个Table在文件中的物理布局形式, 我们读取到该文件之后首先从最后48字节中
* 读取到该Table的Footer.
* 通过Footer中我们可以获取到IndexBlock在文件中的起始位置和该Block的大小, 通过解析
* Index Block, 我们可以获取到文件中每个Raw Block在文件中的位置信息和每个Raw Block
* 的索引Key, 这样就可以快速的定位到我们当前需要查询的Key可能存在于哪个Raw Block当
* 中.
* 通过Footer我们还可以获取到MetaIndex Block, MetaIndex Block中含有Filter Block在
* 文件中的起始位置和该Block的大小, 我们通过解析Filter Block可以获取到对应于每段
* Raw Block的布隆过滤器的关键字符串, 通过这个字符串我们可以判断对应Raw Block是否
* 存在我们需要查询的Key.
* 至此, 一个Table建立的过程以及最后Table的结构已经完全介绍完毕.
*
* |-------------------|---
* | Data Block 1 | \
* |-------------------| |
* | Data Block 2 | |
* |-------------------| --> Data Block中存储了真实的数据以及重启点等信息
* | Data Block 3 | | 具体可以看本文件中TableBuilder::Add()方法上
* |-------------------| | 方有注释
* | ... | /
* |-------------------|---
* | result_ | \
* |-------------------| |
* 4 Bytes | offsets_[0] | |
* |-------------------| |
* 4 Bytes | offsets_[1] | |
* |-------------------| |
* 4 Bytes | offsets_[2] | |
* |-------------------| --> 这部分是Filter Block, 其中result_是由各个Data Block中的Key通过Hash计算出来的特征字符串拼接
* | ... | | 来的, 下面有一个offset_数组, offset_[0]记录result_中属于Data Block 1的特征字符串的起始位置,
* |-------------------| | last_word记录的是result_字符串的大小
* 4 Bytes | last_word | |
* |-------------------| |
* 1 Byte | kNoCompression | |
* |-------------------| |
* 4 Bytes | CRC | /
* |-------------------|---
* | MetaIndex Block | --> 用于记录Filter的名称,以及上方Filter Block的起始位置和大小(该Block尾部也包含压缩方式和CRC)
* |-------------------|---
* | Pending Entry 1 | \
* |-------------------| |
* | Pending Entry 2 | |
* |-------------------| |
* | Pending Entry 3 | |
* |-------------------| --> 这部分是Index Block, 其中的Pending Entry 1中包含Raw Block 1的索引Key, 以及Raw Block 1在文件
* | ... | | 中的位置信息, 以及Raw Block 1的大小.
* |-------------------| |
* 1 Bytes | kNoCompression | |
* |-------------------| |
* 4 Bytes | CRC | /
* |-------------------|---
* 48 Bytes | Footer | --> 包含MetaIndex Block的Index Block的索引信息(在文件中的位置以及大小), 和魔数, 具体细节可以看format.cc的Footer::EncodeTo();
* |-------------------|
*
* 其中RawBlock和MetaIndexBlock还有IndexBlock内部的结构都是一样的,将每个KV编码成一个条目之后添加到Block当中
* 然后如果前一个条目和当前条目有前缀重叠部分, 那么就会通过共享前缀来节约空间, 每隔固定的条目该Block都会强制
* 加入一个重启点, 该Block的尾部会加入重启点数组, 以及重启点数组的大小
*/
通过上面的介绍,我们了解到实际上SST文件大部分是由一个个的Block构成的,而在LevelDB中将数据转换成文件里面的Block就是由BlockBuilder来完成的了, 下面我们先来看一下BlockBuilder的定义.
class BlockBuilder {
public:
explicit BlockBuilder(const Options* options);
void Reset();
void Add(const Slice& key, const Slice& value);
Slice Finish();
size_t CurrentSizeEstimate() const;
bool empty() const {
return buffer_.empty();
}
private:
const Options* options_;
std::string buffer_; // 存储当前Block内容的buffer
std::vector<uint32_t> restarts_; // 重启点,后面会介绍
int counter_; // 当前这个Block里面有多少条记录
bool finished_; // Has Finish() been called?
std::string last_key_; // 记录当前这个Block最后一个key
// No copying allowed
BlockBuilder(const BlockBuilder&);
void operator=(const BlockBuilder&);
};
实际上BlockBuilder内部就是维护了一个buffer_用于存储当前Block的内容,另外作为数据存储引擎,存储相同量级的数据,能尽量减少磁盘空间占用是很重要的,通过前面的博客我们了解到LevelDB中的数据都是按照Key有序存储的,LevelDB很好的利用了相邻的Key可能有相同前缀的这个特点来减少数据存储量,实际上就是前缀压缩机制,当前Key如果和前一个Key拥有相同的前缀,那么当前Key只需要记录和前一个Key不同的尾缀部分以及记录一些额外信息,这样就能通过前一个Key和自身的额外信息就能拼凑出当前Key.
这么做能很好的降低了数据的存储, 但是也引入了一个风险,也就是如果最开头的Key被损坏了,后面的所有Key都将无法恢复,为了降低数据损坏的风险,LevelDB在Block中引入的重启点的概念,也就是每隔固定条目的数据会强制插入一个重启点,重启点位置的Key完整的存储自身的数据,而不是需要根据前一个Key才能拼凑出当前Key, 说了这么多,可能有点抽象,下面我们通过BlockBuilder里面最重要的Add()方法来了解一下,重启点是怎么有效的节省空间以及数据是怎么样被添加到Block里的.
/*
* 这个方法的作用是向当前的block中添加一条Entry,由于我们调用这个方法添加key
* 实际上是字典有序的(从memtable中遍历得到的自然是有序的), 所以前一个添加的key
* 和后一个key可能会存在"部分前缀的重叠"(abcdd和abccc重叠部分就是abc), 为了节
* 约空间, 后一个key可以只存储和前一个key不同的部分(这个例子中后一个key只需要
* 存储cc即可), 这种做法有利有弊,既然只存储了和前一个key不同的部分,那么我们
* 需要一些额外的空间来存储一些其他的数据,比如说共享长度(shared), 非共享长度
* (non_shared)等等 这些数据都是用一个int32_t类型的数字进行存储,在memtable.cc
* 中提到过, 这是一种紧凑型的数字表示法, 下面附上的记录在内存中的表现形式和example.
*
* e.g..
*
* key: Axl value: vv
* key: Axlaa value: vv
* key: Axlab value: vv
* key: Axlbb value: vv
*
* ******************************** Entry Format ********************************
* | <Shared> | <Non Shared> | <Value Size> | <Unprefixed Key> | <Value> |
* 1 ~ 5 Bytes 1 ~ 5 Bytes 1 ~ 5 Bytes non_shared Bytes value_size Bytes
*
* 0 3 2 Axl vv
* 3 2 2 aa vv
* 4 1 2 b vv
* 3 2 2 bb vv
*
*
* (真实的key, 尾部还会带上SequenceNumber和ValueType,这里为了example更清晰便没有展示出来)
*
*
* 这里还有一重启点(restarts_)的概念, 重启点存在的目的是为了避免最开始的记录损坏,
* 而其后面的所有数据都无法恢复的情况发生, 为了降低这个风险, leveldb引入了重启点,
* 也就是每隔固定的条数(block_restart_interval)的Entry, 都强制加入一个重启点, 重
* 启点指向的Entry会完整的记录自身的key(shared为0, 不再依赖上一条Entry, 前面的Entry
* 损坏也不会影响重启点指向的Entry的读取)
*
* 当前所有的重启点会有序的记录在restarts_集合当中, 最后Flush到文件的时候这个重启点
* 集合以及集合大小会写在当前block的尾部.
*/
void BlockBuilder::Add(const Slice& key, const Slice& value) {
Slice last_key_piece(last_key_);
assert(!finished_);
assert(counter_ <= options_->block_restart_interval);
assert(buffer_.empty() // No values yet?
|| options_->comparator->Compare(key, last_key_piece) > 0);
size_t shared = 0;
// 如果当前的counter_已经到达了block_restart_interval上限的时候, 那么
// shared就为0, 下一条recored不再共享前缀
if (counter_ < options_->block_restart_interval) {
// See how much sharing to do with previous string
const size_t min_length = std::min(last_key_piece.size(), key.size());
while ((shared < min_length) && (last_key_piece[shared] == key[shared])) {
shared++;
}
} else {
// Restart compression
restarts_.push_back(buffer_.size());
counter_ = 0;
}
const size_t non_shared = key.size() - shared;
// Add "<shared><non_shared><value_size>" to buffer_
PutVarint32(&buffer_, shared);
PutVarint32(&buffer_, non_shared);
PutVarint32(&buffer_, value.size());
// Add string delta to buffer_ followed by value
buffer_.append(key.data() + shared, non_shared);
buffer_.append(value.data(), value.size());
// Update state
// 这里不将last_key_直接赋值为key的原因是为了减少内存拷贝
last_key_.resize(shared);
last_key_.append(key.data() + shared, non_shared);
assert(Slice(last_key_) == key);
counter_++;
}
通过上面的Add()方法, 我们已经能把key/value有效的,节约空间的存储到BlockBuilder中的buffer当中去了,并且把当前Block的所有重启点加入到了restarts_集合当中,由于LevelDB中Block的大小是可配置的(默认是4KB), 所以当一个Block中buffer_的体积大于等于options.blocksize的时候便要调用Finish方法将重启点信息追加到当前Block的末尾, 准备将这个Block写入磁盘了, 下面是BlockBuilder的Finish()方法, 实现很简单就是将所有重启点依次append到buffer_后面,并且在末尾再追加重启点数量方便向前查找, 刚看这段代码的时候疑惑为什么记录数字不用LevelDB的紧凑型数字表示法,后来想明白了.
/*
* 如果在这里采用紧凑类型的数字表示法,那么每个int32_t数据
* 类型在当前block所占用的空间为1 ~ 5 Bytes大小不等,这样
* 我们便无法准确获取到存储重启点数据的位置,按照以下方法
* 就很好找了,我们读取当前block的最后4 Bytes,然后解析为
* 一个int32_t类型的数字n,这个n就是我们重启点数组的大小,
* 然后再往前读取4 * n Bytes, 这些就是重启点的数据了
*/
Slice BlockBuilder::Finish() {
// Append restart array
for (size_t i = 0; i < restarts_.size(); i++) {
PutFixed32(&buffer_, restarts_[i]);
}
PutFixed32(&buffer_, restarts_.size());
finished_ = true;
return Slice(buffer_);
}
通过BlockBuilder的Add()以及Finish()方法解读,我们对于SST文件中的Block已经有了一个大概的了解, 下面列出SST文件中Block的结构,让我们有一个更加直观的了解, 可以看到SST文件的Block末尾还加入了1 Byte用于记录这个Block的压缩方式(目前LevelDB只支持不压缩和Snappy压缩), 以及4 Bytes的数据校验码, 这个校验码是根据这个Block的内容生成的uint32_t类型的整数,用于判别数据是否在生成以及传输中出错.
/*
* Raw Block
*
* |-------------------|
* | Entry 1 | Entry1, Entry2... 是按照字典序进
* |-------------------| 行排列的, 其中含有shared,
* | Entry 2 | non_shared, value_size... 等等一
* |-------------------| 些字段,实际上存储的就是KV键值对,
* | Entry 3 | 详细可看block_builder.cc的Add()方
* |-------------------| 法上有注释.
* | Entry 4 |
* |-------------------|
* | ... |
* |-------------------|
* 4 Bytes | restarts[0] | leveldb中每隔固定条数的Entry会强制加入一个重启
* |-------------------| 这里存储的数组restarts实际上就是指向这些重启点
* 4 Bytes | restarts[1] | 的(restarts[0]的值永远是0,因为block->Reset()
* |-------------------| 的时候First restart point is at offset 0)
* 4 Bytes | restarts[2] |
* |-------------------|
* 4 Bytes | 3 | 重启点数组的大小
* |-------------------|
* 1 Byte | CompressionType | 数据的压缩方式, 是kSnappy或者kNo
* |-------------------|
* 4 Bytes | CRC | 根据上方除了CompressionType计算出来的一个校验值
* |-------------------|
*
* 上图是一个完整的Raw Block.
*
* 对于Index Block: 每当写完一个完整的Raw Block都会计算出一个索引key(大
* 于或者等于当前Block中最大Key的那个Key), 以及当前Data Block在文件中距
* 离文件起始位置的偏移量以及当前Data Block(在这里Data Block去掉CompressionType
* 和CRC我们称为Data Block)的大小, 我们会将索引key, 当前data block的偏移量
* 以及当前data block的大小当做一条Entry写入到Index Block当中.
*/
文章开头有提到过,LevelDB的SST文件是由TableBuilder生成的,而TableBuilder内部实际上是持有了BlockBuilder(用于写data_block以及index_block)和FilterBlockBuilder(用于写filter_block),外界通过调用TableBuilder的Add()方法,向TableBuilder的data_block中添加数据,并且在必要的时候更新Index_block以及filter_block中的内容
从下面列出的TableBuilder::Add()方法可以看出,每当写完一个data_block并且刷盘之后,都会计算出这个data_block的索引key出来,并且将这个索引key以及这个data_block在SST文件中的offset和size信息添加到indexblock当中,后续查找数据我们就可以通过这个indexblock快速的定位到我们需要查找的key可能当前SST文件的哪个Block当中了
void TableBuilder::Add(const Slice& key, const Slice& value) {
Rep* r = rep_;
assert(!r->closed);
if (!ok()) return;
if (r->num_entries > 0) {
assert(r->options.comparator->Compare(key, Slice(r->last_key)) > 0);
}
// 当我们写完一个Data Block以后要记录下一些数据
if (r->pending_index_entry) {
assert(r->data_block.empty());
// 计算出大于或者等于当前Block中最大Key的那个Key(我们称为索引Key)
// 但是计算出来的这个key要比下一个Block中最小的Key要小
r->options.comparator->FindShortestSeparator(&r->last_key, key);
std::string handle_encoding;
// 记录当前Raw Block在文件中的位置距离文件起始位置的偏移量
// 记录当前block_content的size (注意,这里不是整个Raw Block的
// 大小而是KV数据和restarts_这些数据的大小, 我们称为Data Block)
r->pending_handle.EncodeTo(&handle_encoding);
// 将我们计算出来的当前Block的索引Key, 以及当前Block距离文件起始
// 位置的偏移量和Block大小添加到index_block当中(实际上index_block
// 的结构和Data Block是一模一样的, 就是会有重启点, 和共享前缀这些)
r->index_block.Add(r->last_key, Slice(handle_encoding));
// 将其置为false, 表示当前Raw Block已经处理完毕
r->pending_index_entry = false;
}
if (r->filter_block != NULL) {
r->filter_block->AddKey(key);
}
r->last_key.assign(key.data(), key.size());
r->num_entries++;
r->data_block.Add(key, value);
// 每个Block中存储了多条record, 以及重启点数组, 还有重启点数组
// 的大小(uint32_t), 在option中我们配置block_size, 实际上在文件
// 当中每个block的大小并不是一定是我们的block_size.
const size_t estimated_block_size = r->data_block.CurrentSizeEstimate();
if (estimated_block_size >= r->options.block_size) {
Flush();
}
}
对于TableBuilder::Finish()方法这里就不贴代码了, 由于数据(data_block)已经写完了,接下来依次在SST文件后面追加filter_block(用于快速判断一个key是否可能存在于当前的SST文件当中), metaindex_block(用于记录当前filter的名称,以及 filter_block的位置信息offset和size),index_block(用于记录SST文件中各个data_block的索引key以及对应的位置信息offset和size),最后会在SST文件的尾部添加Footer信息,下面来看一下Footer的格式
可以看到Footer里面记录了meta index block的位置信息和index block的位置信息, 所以当我要在SST文件中找一个key的时候首先是通过Footer里面的Index Block offset和Index Block size找到Index Block, 然后Index Block里面就有当前这个SST文件的所有Data Block的索引Key, 以及Block的位置信息,这样就能快速的定位到我们要查找的数据.
/*
* 用来编码文件的Footer, Footer包含Meta Block
* 在文件中的位置以及Meta Block的大小以及
* Index Block在文件中的位置和Index Block的大
* 小以及文件尾部的"魔数"
*
* Q: 为什么要添加Padding?
* A: 由于这里记录这些Block的位置以及大小是使用
* 的紧凑型的数字表示法, 所以表示一个int64_t
* 的数字使用的空间在1 ~ 10个Bytes不等, 为了
* 我们从尾部能快速的找到Footer的在文件中的起
* 始位置(8 + 4 * 10)Bytes, 所以我们在这里加
* 入了Padding进行填充对齐
*
* |---------------------------|
* 1 ~ 10 Bytes | Meta Index Block offset |
* |---------------------------|
* 1 ~ 10 Bytes | Meta Index Block size |
* |---------------------------|
* 1 ~ 10 Bytes | Index Block offset |
* |---------------------------|
* 1 ~ 10 Bytes | Index Block size |
* |---------------------------|
* | Padding |
* |---------------------------|
* 8 Bytes | MagicNumber |
* |---------------------------|
*/
void Footer::EncodeTo(std::string* dst) const {
const size_t original_size = dst->size();
metaindex_handle_.EncodeTo(dst);
index_handle_.EncodeTo(dst);
dst->resize(2 * BlockHandle::kMaxEncodedLength); // Padding
PutFixed32(dst, static_cast<uint32_t>(kTableMagicNumber & 0xffffffffu));
PutFixed32(dst, static_cast<uint32_t>(kTableMagicNumber >> 32));
assert(dst->size() == original_size + kEncodedLength);
(void)original_size; // Disable unused variable warning.
}
通过上述介绍,不难发现SST文件的创建过程是非常快速的,因为新添加的数据首先都是先存入一个Block里,然后依次向文件末尾追加Block,是一个顺序写的过程,但是如果是在LevelDB中的sst文件中查找一个key速度就没那么快了,首先要通过Index Block来查找Key可能在哪个Data Block中,然后再在Data Block中进行查找,另外LevelDB的sst文件是有层级结构的,Level0层的sst文件由于是不同时刻的Immutable Memtable直接Flush而成,所以文件和文件之间可能存在overlap,如果需要在Level0层的sst文件中查找某一个Key的数据,可能需要查找这一层的所有sst文件才能确定结果,不过对此LevelDB也引入了一系列策略来提高查询效率,比如布隆过滤器.