7. LevelDB源码剖析之SSTable

7.1 基本原理

上一章提到的MemTable是内存表,当内存表增长到一定程度时(memtable.size> Options::write_buffer_size),Compact动作会将当前的MemTable数据持久化,持久化的文件称之为SSTable(Sorted String Table)。
LevelDB中的SSTable分为不同的层级,这也是LevelDB称之为Level DB的原因,当前版本的最大层级为7(0-6),level-0的数据最新,level-6的数据最旧。除此之外,Compact动作会将多个SSTable合并成少量的几个SSTable,以剔除无效数据,保证数据访问效率并降低磁盘占用。

7.2 物理布局

在存储设备上,一个SSTable被划分为多个Block数据块。每个Block中存储的可能是用户数据、索引数据或任何其他数据。Block除了数据块外,每个Block尾部还带了额外信息,布局如下:

7. LevelDB源码剖析之SSTable_第1张图片
Block组成
  • Compression Type标识Block中的数据是否被压缩,采用了何种压缩算法。
  • CRC则是Block的数字签名,用于校验数据的有效性。

Block是SSTable物理布局的关键。来看Block结构:

7. LevelDB源码剖析之SSTable_第2张图片
Block的物理布局

Block内部由以下两部分组成:

  • 数据记录:每一个Record代表了一条用户记录(Key-Value对)。严格上讲,并不是完整的用户记录,LevelDB在Key上Block做了优化。
  • 重启点信息:亦即索引信息,用于Record快速定位。如Restart[0]永远指向block的相对偏移0,Restart[1]指向重启点Record4的相对偏移。LevelDB在Key存储上做了优化,每个重启点指向的第一条Record记录了完整的Key值,而本重启点之内的其他key仅包含和前一条的差异项。

通过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);

  //1. 构建Restart Point
  size_t shared = 0;

  //每block_restart_interval条记录创建一个重启点
  if (counter_ < options_->block_restart_interval)  //配置参数,默认为16
  {
    //尚未达到重启点间隔,沿用当前的重启点
    // 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
  {
    //触发并创建新的重启点
    //此时,shared = 0; 重启点中将保存完整key
    // Restart compression
    restarts_.push_back(buffer_.size());  //buffer_.size()为当前数据块偏移
    counter_ = 0;
  }
  const size_t non_shared = key.size() - shared;

  //2. 记录数据
  // shared size | no shared size | value size | no shared key data | value data
  // Add "" 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_.resize(shared);
  last_key_.append(key.data() + shared, non_shared);
  assert(Slice(last_key_) == key);
  counter_++;
}

Buffer_代表当前数据块,restart_中则包含了重启点信息。当向block中新增一条记录时,首先设置重启点信息,包括:是否创建新的重启点,当前key和last key中公共部分大小。重启点信息整理完毕后,插入Record信息,Record信息的结构如下:

Record: shared size | no shared size | value size | no shared key data | value data

再来看Block构建完成时调用的Finish方法:

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_);
    }

此处和图3.1一致,在所有Record之后记录重启点信息,包括每条重启点信息(block中相对偏移)及重启点数量。

重启点机制主要有两点好处:

  1. 索引信息:用于快速定位,读取时通过重启点的二分查找先获取查找数据所属的重启点,随后在重启点内部遍历,时间复杂度为Log(n)。
  2. 空间压缩:有序key值使得相邻记录的key值的重叠度极高,通过上述方式可以有效降低持久化设备占用。

至此,SSTable的物理布局已然清晰。

7.3 逻辑布局

刚刚看过Block的结构,SSTable所有的数据存储都是以Block为基本单元进行的。现在来看SSTable的逻辑布局,这次我们先从实现说起:

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);
  }

  //1. 构建Index
  if (r->pending_index_entry)
  {
    assert(r->data_block.empty());
    r->options.comparator->FindShortestSeparator(&r->last_key, key);
    std::string handle_encoding;
    r->pending_handle.EncodeTo(&handle_encoding);
    r->index_block.Add(r->last_key, Slice(handle_encoding));
    r->pending_index_entry = false;
  }

  //2. 构建过滤器
  if (r->filter_block != NULL)
  {
    r->filter_block->AddKey(key);
  }

  //3. 记录数据
  r->last_key.assign(key.data(), key.size());
  r->num_entries++;
  r->data_block.Add(key, value);

  //4. 数据块(Data Block)大小已达上限,写入文件
  const size_t estimated_block_size = r->data_block.CurrentSizeEstimate();
  if (estimated_block_size >= r->options.block_size)
  {
    Flush();
  }
}

BlockBuilder 用于构建Block,TableBuidler则用于构建Table。Table构建过程中包含如下部分:

  1. 构建Index。Block中采用了重启点机制实现索引功能,在保证性能的同时又降低了磁盘占用。那么此处为何没有采用类似的机制呢?

实际上,此处索引键值的存储也做了优化,具体实现在FindShortestSeparator中,其目的在于获取最短的可以做为索引的“key”值。
举例来说,“helloworld”和”hellozoomer”之间最短的key值可以是”hellox”。
除此之外,另一个FindShortSuccessor方法则更极端,用于找到比指定key值大的最小key,如传入“helloworld”,返回的key值可能是“i”而已。作者专门为此抽象了两个接口,放置于Compactor中,可见其对编码也是是有“洁癖”的。

  1. 构建过滤器。在LevelDB的早期版本是没有过滤器的,1.2中引入过滤器,并默认提供布隆过滤器。过滤器的目的同样在于在于快速定位,LevelDB中,每2k大小构造一个过滤器。
// Generate new filter every 2KB of data
static const size_t kFilterBaseLg = 11;
static const size_t kFilterBase = 1 << kFilterBaseLg;
  1. 记录数据。通过block builder将key-value数据插入到data block中。
  2. 数据块刷新。当数据块(Data Block)大小已达上限时,写入文件。

除了data block写入文件,其他构建块均存储于内存中,此时并未真正持久化。真正持久化动作在Finish中:

Status TableBuilder::Finish()
{
  //1. Data Block
  Rep *r = rep_;
  Flush();
  assert(!r->closed);
  r->closed = true;

  BlockHandle filter_block_handle, metaindex_block_handle, index_block_handle;

  //2. Write filter block
  if (ok() && r->filter_block != NULL)
  {
    WriteRawBlock(r->filter_block->Finish(), kNoCompression,
                  &filter_block_handle);
  }

  //3. Write metaindex block
  if (ok())
  {
    BlockBuilder meta_index_block(&r->options);
    if (r->filter_block != NULL)
    {
      // Add mapping from "filter.Name" to location of filter data
      std::string key = "filter.";
      key.append(r->options.filter_policy->Name());
      std::string handle_encoding;
      filter_block_handle.EncodeTo(&handle_encoding);
      meta_index_block.Add(key, handle_encoding);
    }

    // TODO(postrelease): Add stats and other meta blocks
    WriteBlock(&meta_index_block, &metaindex_block_handle);
  }

  //4. Write index block
  if (ok())
  {
    if (r->pending_index_entry)
    {
      r->options.comparator->FindShortSuccessor(&r->last_key);
      std::string handle_encoding;
      r->pending_handle.EncodeTo(&handle_encoding);
      r->index_block.Add(r->last_key, Slice(handle_encoding));
      r->pending_index_entry = false;
    }
    WriteBlock(&r->index_block, &index_block_handle);
  }

  //5. Write footer
  if (ok())
  {
    Footer footer;
    footer.set_metaindex_handle(metaindex_block_handle);
    footer.set_index_handle(index_block_handle);
    std::string footer_encoding;
    footer.EncodeTo(&footer_encoding);
    r->status = r->file->Append(footer_encoding);
    if (r->status.ok())
    {
      r->offset += footer_encoding.size();
    }
  }
  return r->status;
}

通过Finish方法,我们可以一窥SSTable的全貌:

7. LevelDB源码剖析之SSTable_第3张图片
SSTable逻辑布局
  1. Data Block:数据块,用户数据存放于此。
  2. Filter Blck:过滤器数据块。
  3. Meta Block:元数据块,当前存放过滤器数据。
  4. Index Block:索引块,用于用户数据快速定位。
  5. Footer:SSTable的注脚区,见下图。
  • Meta Index Block Handle指出了Meta Index Block的起始位置和大小。
  • Index Block Handle指出了Index Block的起始地址和大小。
  • 上述两个字段可以理解为索引的索引,是为了正确读出索引值而设立的,后面跟着一个填充区和魔数。


    7. LevelDB源码剖析之SSTable_第4张图片
    SSTable Footer

本节最后,总结如下:

  1. SSTable一旦创建后,将只存在查询行为,在键值查找或SSTable遍历时,必定从重启点开始查找,因此除重启点位置的Record为完整key外,其他均为差异项亦可快速定位。
  1. Table、Block一旦创建后无法修改,TableBuilder负责Table创建,BlockBuilder负责。Table、Block最重要的接口为Iterator* NewIterator(...) const,用于查找、遍历数据。LevelDB中的Iterator稍显复杂,后面会统一备忘。
  2. Table、Block各自采用了类似的索引机制,并形成了Table到Block的多级索引。重启点、Table的索引机制在保证性能的同时又降低了存储空间。
    表3.1、图3.2中一直强调SSTable中存储的是Block,这种描述并不十分准确。表3.1中讲到,SSTable中存储了“Compression Type(是否压缩)”,如果数据被压缩,SSTable中存储的并不是Block数据本身,而是压缩后的数据,使用时则需先对Block解压。

7.4 总结

LevelDB中,所有数据最终被持久化为SSTable。LevelDB之所以称为“Level DB”是因为数据是分层的,每次被压缩的数据level提升一级。每一层的数据都比下一层更新(MemTable中的最新),各层数据均以SSTable格式存储。


转载请注明:【随安居士】http://www.jianshu.com/p/1ed0b94e8a2c

你可能感兴趣的:(7. LevelDB源码剖析之SSTable)