LevelDB源码解析 | 04.3 SST之布隆过滤器

目录

布隆过滤器原理

bloom filter的实现

bloom filter的使用

过滤器的格式

filter block的构造

使用过滤器的判别过程


在前面关于SST文件的章节中,我们提到SST文件中包含index block和data block,在查询数据中先利用index block定位到相应的data block,然后再到data block中去查询数据。但是如果该key在该data block中并不存在,此番查询便是浪费功夫。那有没有更快速地直接判断一个key在一个集合中是否存在、而无需遍历查询的方式呢?答案之一就是布隆过滤器。

相关代码文件:

  • include/leveldb/filter_policy.h、util/filter_policy.cc:过滤器的抽象类接口
  • util/bloom.cc:布隆过滤器的具体实现
  • table/filter_block.h、table/filter_block.cc:读写filter block的类FilterBlockReader和FilterBlockBuilder

布隆过滤器原理

布隆过滤器用一个位图记录一个集合中元素的存在情况,该位图每一位的值只能为0或者1(好像说了句废话hhh anyway)。假设位图的大小为m,集合中的元素总数为n。

除此之外,布隆过滤器需要事先确定k个相互独立的哈希函数

位图的设置过程如下:每当有一个元素加入集合,该元素分别经过k个哈希函数的运算并将运算结果对m取模,得到k个下标,然后在位图中将这k个位置置为1

判断一个元素是否存在的逻辑:给定一个元素,依然用同样k个哈希函数运算得到k个下标,然后去看位图中k个下标处的值:

  • 若k个下标并非全部为1,则说明元素一定不存在
  • 若k个下标全部为1,元素可能存在,也可能不存在。(为什么呢?因为某个1可能是另外一个元素贡献的,而非待查元素)

举个例子,如下图所示,假设位图大小m=10,元素个数n=3,哈希函数个数k=2。元素Peach因为计算出位置有0,说明该元素一定不存在;元素Grape虽然两个位置均为1,但是是虚警,其实元素并不存在

LevelDB源码解析 | 04.3 SST之布隆过滤器_第1张图片

由此我们可以发现,布隆过滤器对于元素不存在的事实判断是准确的,但是对于元素的存在有虚警的情况。

那么虚警概率是多少呢?给定m,n和k,虚警概率的计算公式为:

可见,m越大、n越小、k越大,虚警概率就越低

给定m和n,为了最小化虚警概率,k的取值应为:

LevelDB源码解析 | 04.3 SST之布隆过滤器_第2张图片

bloom filter的实现

布隆过滤器的实现在util/bloom.cc中的BloomFilterPolicy类

两个成员变量的含义分别为:

  • bits_per_key_:即为原理中提到的m/n,即位图大小/集合元素个数
  • k_:哈希函数的个数

在构造函数中,可以看到给定m/n,哈希函数数目k被设置为能最小化虚警概率的m/n*ln2

class BloomFilterPolicy : public FilterPolicy {
 public:
  explicit BloomFilterPolicy(int bits_per_key) : bits_per_key_(bits_per_key) {
    // We intentionally round down to reduce probing cost a little bit
    k_ = static_cast(bits_per_key * 0.69);  // 0.69 =~ ln(2)
    if (k_ < 1) k_ = 1;
    if (k_ > 30) k_ = 30;
  }
  void CreateFilter(const Slice* keys, int n, std::string* dst);
  bool KeyMayMatch(const Slice& key, const Slice& bloom_filter);


private:
  size_t bits_per_key_;
  size_t k_;
}

BloomFilterPolicy类有两个核心方法:

  • BloomFilterPolicy::CreateFilter:根据集合构建过滤器位图
  • BloomFilterPolicy::KeyMayMatch:给定位图判断元素的存在与否

下面看一下它们的实现逻辑:

BloomFilterPolicy::CreateFilter

函数功能是:给定一组元素keys,计算出布隆过滤器位图并追加到已有的过滤器dst后面

大体逻辑就是遍历集合中的所有元素,利用哈希函数计算出需要置1的k个位图下标。这里采用了论文[Kirsch,Mitzenmacher 2006]的优化方法,可以只计算一次哈希函数并且利用移位操作得到k个下标。

需要注意要将bit下标转化为对于字符串的byte操作(第25行):bitpos是计算出的位图下标,bitpos/8是指该下标位于第几个字符,bitpos%8是指该下标在字符中的第几个bit

具体可以看下面的代码和注释

void CreateFilter(const Slice* keys, int n, std::string* dst) const override {
  // 计算位图的bit数
  size_t bits = n * bits_per_key_;


  // 如果位图位数太小,调大以降低虚警概率
  if (bits < 64) bits = 64;
  
  // 因为是用字符串来存,所以向上取到8的倍数
  size_t bytes = (bits + 7) / 8;
  bits = bytes * 8;


  const size_t init_size = dst->size();
  dst->resize(init_size + bytes, 0);
  dst->push_back(static_cast(k_));  // 最后加一个字符存哈希函数数目
  char* array = &(*dst)[init_size]; // 新过滤器的起始位置
  for (int i = 0; i < n; i++) {
    // Use double-hashing to generate a sequence of hash values.
    // See analysis in [Kirsch,Mitzenmacher 2006].
    uint32_t h = BloomHash(keys[i]);
    const uint32_t delta = (h >> 17) | (h << 15);  // Rotate right 17 bits
    for (size_t j = 0; j < k_; j++) {
      const uint32_t bitpos = h % bits;
      array[bitpos / 8] |= (1 << (bitpos % 8));
      h += delta;
    }
  }
}

BloomFilterPolicy::KeyMayMatch

函数功能是:给定一个key,和布隆过滤器的位图,判断元素的存在与否

逻辑基本上就是CreateFilter函数的逆过程,

具体可以看下面的代码和注释

bool KeyMayMatch(const Slice& key, const Slice& bloom_filter) const override {
  const size_t len = bloom_filter.size();
  if (len < 2) return false;


  const char* array = bloom_filter.data();
  const size_t bits = (len - 1) * 8;


  // 从布隆过滤器的最后一个字符解码出哈希函数个数k
  const size_t k = array[len - 1];
  if (k > 30) {
    // Reserved for potentially new encodings for short bloom filters.
    // Consider it a match.
    return true;
  }


  uint32_t h = BloomHash(key);
  const uint32_t delta = (h >> 17) | (h << 15);  // Rotate right 17 bits
  for (size_t j = 0; j < k; j++) {
    const uint32_t bitpos = h % bits;
    // 只要有一位为0就说明不存在,返回false
    if ((array[bitpos / 8] & (1 << (bitpos % 8))) == 0) return false;
    h += delta;
  }
  return true;
}

bloom filter的使用

过滤器的格式

我们先介绍一下SST文件中和过滤器相关的模块,主要有两个

  • filter block:filter block里包含多个布隆过滤器,每个data block里的数据对应一个布隆过滤器。多个过滤器的后面还写入了每个过滤器的偏移量和相关参数,便于读取的时候解码。每一个filter的内容基本上就是位图(以及哈希函数个数)
  • metaindex block:metaindex block是对filter block的索引,这个block里面其实只有一条记录,其中键是过滤器的名字,值是filter block的handle

LevelDB源码解析 | 04.3 SST之布隆过滤器_第3张图片

filter block的构造

布隆过滤器的位图是写在SST文件中的filter block。这里用于构建filter block的工具类是FilterBlockBuilder。如果为SST文件维护一个全局的布隆过滤器,那么在保证较低的虚警率的情况下便需要一个较大的位图,因此LevelDB选择在一个SST文件内维护多个局部的布隆过滤器,事实上是每一个data block对应一个位图。

FilterBlockBuilder类的声明如下,主要成员变量的含义为:

  • keys_ & start_:每一轮次暂存一个data block里的元素
  • result_ & filter_offsets_:最终的布隆过滤器的结果(包含多个位图)
class FilterBlockBuilder {
 public:
  void StartBlock(uint64_t block_offset);
  void AddKey(const Slice& key);
  Slice Finish();


 private:
  void GenerateFilter();


  const FilterPolicy* policy_;
  
  // 中间暂存每一轮次key的地方
  std::string keys_;             // Flattened key contents
  std::vector start_;    // Starting index in keys_ of each key
  // 最终存放多个过滤器的地方
  std::string result_;           // Filter data computed so far
  std::vector filter_offsets_;


  std::vector tmp_keys_;  // policy_->CreateFilter() argument
};

在TableBuilder::Add中,每次添加一个键值对到data block的同时,也会通过调用AddKey函数将该key添加至FilterBlockBuilder里

void TableBuilder::Add(const Slice& key, const Slice& value) {
  ...
  if (r->filter_block != nullptr) {
    r->filter_block->AddKey(key);
  }
  ...
  if (estimated_block_size >= r->options.block_size) {
    Flush();
  }
}


void FilterBlockBuilder::AddKey(const Slice& key) {
  Slice k = key;
  start_.push_back(keys_.size());
  keys_.append(k.data(), k.size());
}

从FilterBlockBuilder::AddKey函数可以看到,AddKey只是把该key暂存到builder里,并没有真的构建过滤器,那么构建过滤器的时机是什么呢?答案在TableBuilder::Flush()里,当一个data block写满后,会调用Flush,而Flush函数里会调用StartBlock函数,如下:

void TableBuilder::Flush() {
  ...
  if (r->filter_block != nullptr) {
    r->filter_block->StartBlock(r->offset);
  }
}

// Generate new filter every 2KB of data
static const size_t kFilterBaseLg = 11;
static const size_t kFilterBase = 1 << kFilterBaseLg;


void FilterBlockBuilder::StartBlock(uint64_t block_offset) {
  uint64_t filter_index = (block_offset / kFilterBase);
  assert(filter_index >= filter_offsets_.size());
  while (filter_index > filter_offsets_.size()) {
    GenerateFilter();
  }
}

void FilterBlockBuilder::GenerateFilter() {
  const size_t num_keys = start_.size();
  if (num_keys == 0) {
    // Fast path if there are no keys for this filter
    filter_offsets_.push_back(result_.size());
    return;
  }


  // Make list of keys from flattened key structure
  start_.push_back(keys_.size());  // Simplify length computation
  tmp_keys_.resize(num_keys);
  for (size_t i = 0; i < num_keys; i++) {
    const char* base = keys_.data() + start_[i];
    size_t length = start_[i + 1] - start_[i];
    tmp_keys_[i] = Slice(base, length);
  }


  // Generate filter for current set of keys and append to result_.
  filter_offsets_.push_back(result_.size());
  policy_->CreateFilter(&tmp_keys_[0], static_cast(num_keys), &result_);


  tmp_keys_.clear();
  keys_.clear();
  start_.clear();
}

主要看一下GenerateFilter函数的逻辑:它会构造出这一轮暂存的元素集合,并且调用BloomFilterPolicy::CreateFilter接口计算出这一轮的布隆过滤器位图并追加到result_上

这里有一个比较迷惑的参数是kFilterBase,看起来它的本意是每攒2KB的数据就计算一轮新的布隆过滤器位图,然而事实上StartBlock的调用时机是随着TableBuilder::Flush的,也就是只有当一个data block被写满,才会发起一轮位图的计算

假设第一个data block的大小为6KB,那计算出来的filter_index是6/2=3,也就是下面GenerateFilter会循环3次,6KB数据都会被计算到第一轮的位图里,而后两次都是轮空。

但是在这种实现下,data block的offset和filter的index依然是可以严格对应起来的,所以没有问题。

filter block的落盘时机:在TableBuilder::Finish里

主要逻辑:

  • 先写filter block
    • 调用FilterBlockBuilder::Finish函数:把多个布隆过滤器的位图以及每个位图的起始偏移量拼到一起
    • 调用WriteRawBlock写入文件
  • 再写metaindex block
    • 类似于index block。键是过滤器的名字,即"filter.leveldb.BuiltinBloomFilter2",值是filter block的handle
Status TableBuilder::Finish() {
  ...
  
  BlockHandle filter_block_handle, metaindex_block_handle, index_block_handle;


  // Write filter block
  if (ok() && r->filter_block != nullptr) {
    WriteRawBlock(r->filter_block->Finish(), kNoCompression,
                  &filter_block_handle);
  }
  // Write metaindex block
  if (ok()) {
    BlockBuilder meta_index_block(&r->options);
    if (r->filter_block != nullptr) {
      // 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);
  }
  ...
}

使用过滤器的判别过程

读取filter block分为两步,(1)从SST中读出metaindex block;(2)根据metaindex block中的handle读出filter block

读取过程的起点要追溯到读SST文件的地方,也就是Version::Get,调到TableCache::Get,

TableCache::Get里构造Table的时候,先读出footer、然后读出index block(和本节关联不大)。有一个关键是调用Table::ReadMeta,函数逻辑很简单,其实就是先把metaindex block读出来,然后根据里面的handle把filter block读出来,最后根据block contents构建FilterBlockReader

Status Table::Open(const Options& options, RandomAccessFile* file,
                   uint64_t size, Table** table) {
  // 读取footer
  *table = nullptr;
  if (size < Footer::kEncodedLength) {
    return Status::Corruption("file is too short to be an sstable");
  }


  char footer_space[Footer::kEncodedLength];
  Slice footer_input;
  Status s = file->Read(size - Footer::kEncodedLength, Footer::kEncodedLength,
                        &footer_input, footer_space);
  if (!s.ok()) return s;


  Footer footer;
  s = footer.DecodeFrom(&footer_input);
  if (!s.ok()) return s;


  // Read the index block
  BlockContents index_block_contents;
  ReadOptions opt;
  if (options.paranoid_checks) {
    opt.verify_checksums = true;
  }
  s = ReadBlock(file, opt, footer.index_handle(), &index_block_contents);


  if (s.ok()) {
    // We've successfully read the footer and the index block: we're
    // ready to serve requests.
    Block* index_block = new Block(index_block_contents);
    Rep* rep = new Table::Rep;
    rep->options = options;
    rep->file = file;
    rep->metaindex_handle = footer.metaindex_handle();
    rep->index_block = index_block;
    rep->cache_id = (options.block_cache ? options.block_cache->NewId() : 0);
    rep->filter_data = nullptr;
    rep->filter = nullptr;
    *table = new Table(rep);
    // 
    (*table)->ReadMeta(footer);
  }


  return s;
}

然后从table里读取数据的函数是Table::InternalGet,我们来看一下这里和filter有关的逻辑

这里主要调用到了FilterBlockReader::KeyMayMatch

Status Table::InternalGet(const ReadOptions& options, const Slice& k, void* arg,
                          void (*handle_result)(void*, const Slice&,
                                                const Slice&)) {
  Iterator* iiter = rep_->index_block->NewIterator(rep_->options.comparator);
  iiter->Seek(k);
  // 找到key可能存在的data block
  if (iiter->Valid()) {
    Slice handle_value = iiter->value();
    FilterBlockReader* filter = rep_->filter;
    BlockHandle handle;
    if (filter != nullptr && handle.DecodeFrom(&handle_value).ok() &&
        !filter->KeyMayMatch(handle.offset(), k)) {
      // Not found
    } 
    ...
  }
}

FilterBlockReader::KeyMayMatch函数传入的offset参数指的是待查询的data block的起始偏移量,FilterBlockReader::KeyMayMatch会根据data block的偏移量计算出filter的index,然后从filter block里把相应的位图片段挑出来进行检测。

你可能感兴趣的:(架构,c++,数据库,sstable,布隆过滤器)