RocksDB 最適化ソリューション (1): Indexing SST

Google LevelDB は LSM-Tree 実装の例です。ただし、オープンソースがリリースされた後、軽量で簡潔なスタイルを維持するために、バグの修正は別として、大幅な更新と反復は行われていません。産業環境での多様な負荷に対応できるようにするために、Facebook (Meta) は、Fork LevelDB の後にさまざまな最適化を行いました。ハードウェアに関しては、フラッシュ メモリや高速ディスク、マルチコア CPU など、最新のハードウェアをより効果的に使用できます。 SST インデックス、インデックス シャーディング、プレフィックス ブルーム フィルター、列ファミリー 待機。

この一連の記事は、RocksDB シリーズのブログに基づいており、ソース コードといくつかの使用経験を組み合わせて、いくつかの興味深い最適化ポイントを共有し、すべての人に刺激を与えることを望んでいました。レベルには制限があり、不適切である場合は、議論のためにメッセージを残してください。

この記事は、RocksDB 最適化シリーズの最初の記事であり、ディープ クエリのパフォーマンスを最適化するために、さまざまなレベルの SST が特定の方法でインデックス化されています。

バックグラウンド

まず、前回の記事で描いたLevelDBのアーキテクチャ図。

LevelDB アーキテクチャ図

その中で、LevelDB の aGet()のようになります。

  1. 変更可能な memtable

  2. 不変の memtable の配列

  3. 階層的に編成された一連の SST ファイル

その中で、レイヤ 0 の SST ファイルは、不変の memtable から直接フラッシュされ、それらのキー範囲 ( と で定義されるFileMetaData.smallest)のほとんどFileMetaData.largestが互いにオーバーラップします。したがって、レベル 0 では、すべての SST ファイルが 1 つずつ検索されます。

他の層の SSTは、上位層の SSTの継続的な圧縮に由来するため、上位層から下位層にデータを継続的にシンクします。圧縮プロセスは、複数の SST を一緒にマージし、「水分」(重複キー) を絞り出し、複数の SST ファイルに分割するため、1 つのレイヤーの下で、すべての SST ファイル キー範囲がばらばらで順序付けられます。この構成により、レイヤー内で検索するときに、O(log(N))非線形検索 (複雑さ) の代わりにバイナリ検索 (複雑さ) を実行できます。O(N)

具体的な検索方法は、検索対象の値 x を対象とし、各ファイルFileMetaData.largestから構成されるバイナリオブジェクトとし、検索範囲を連続的に絞り込んで対象の SST ファイルを決定する方法です。x を含むキー範囲に対応する SST ファイルが見つからない場合、または対応する SST ファイルに x がない場合、検索は下位層に進みます。

ただし、大量のデータの場合、下層のファイルの数は依然として非常に多く、頻度の高い Get 操作では、バイナリ検索であっても、各層での比較の数は直線的に増加します。 . たとえば、ファンアウト比が 10 の場合、つまり、下層の SST ファイルの数がすぐ上の層の 10 倍である場合、3 層目には 1000 近くのファイルが存在し、最悪の場合、 、約10回の比較が必要です。最初はそれほど多くないように見えるかもしれませんが、数百万 QPS の場合、これはかなりのオーバーヘッドです。

最適化

分析する

LevelDB のバージョン メカニズムでは、各バージョン (マニフェスト ファイルによって保存される VersionSet) 内の SST ファイルの数と場所が固定されており、異なるレイヤーの SST ファイルの相対位置も固定されていることがわかります。

そして、各層で検索する際、実際には最初から二分する必要はなく、上位層で分岐された位置情報の一部を再利用することができますしたがって、検索ツリー (B+ ツリーなど) に似たレベル間インデックス構造を追加して、基礎となる二分範囲を縮小できます。このアイデアは、フラクショナル カスケードと呼ばれます。

SST インデックス作成後にスケマティック ダイアグラムを検索

举个例子,如上图,1 层有 2 个 SST 文件,2 层有 8 个 SST 文件。假设现在我们想要查找 80,首先在第一层中所有文件的 FileMetaData.largest 中搜索,可以得到候选文件 file 1,但通过与其上下界比较发现小于其下界。于是继续向 2 层搜索,如果 SST 上没有索引,我们需要对所有八个候选文件进行二分,但如果有索引,如上图,我们只需要对前三个文件进行二分。由此,每层搜索的比较次数可以做到常数级别 N(扇出因子,即 RocksDB 中 max_bytes_for_level_multiplier 配置项),不会随着层次的加深而线性增加( log(N^L) = N*L)。

实现

RocksDB 中的版本是在每次 compaction 后确定的,只有 compaction 才会改变 SST。因此可以在 compaction 构建 SST 时构建相邻层间的 SST 索引。构建时,每个 SST 文件上下界各对应一个指针。那么如何确定每个指针的指向位置呢?与查找过程类似,拿着上界或者下界对应的 key,在下层所有 SST 文件的 FileMetaData.largest 构成的数组中进行搜索,将其指向下层文件该值对应的右界文件。这么选择原因在于,可以通过该指针(如 100 的指针),将下一层中指向的文件(如 file3)右边的文件(file4,file5,…)全部砍掉,从而减小搜索范围。

举个例子,如上图中的 1 层中 file1 的上下界分别为 100 和 200 。对于 100,搜索后落到 2 层中的 file 3 中,则其指向 file 3;对于 200 ,搜索后落到 210 左边,则指向 file 4。

代码

但在 RocksDB 实际代码中(该功能自 3.0 引入),对于任意一个上层文件,实际上是记下了四个索引。通过细分界定范围,可以进一步减小搜索范围。其基本思想是构建 FileIndexer 的时候多花一倍时间、多算一倍的边界,在查找时,就可以更进一步缩小查找范围。鉴于 SST 都是一次构建,多次查询,这种 tradeoff 是值得的。

struct IndexUnit {
    IndexUnit()
      : smallest_lb(0), largest_lb(0), smallest_rb(-1), largest_rb(-1) {}
    // Point to a left most file in a lower level that may contain a key,
    // which compares greater than smallest of a FileMetaData (upper level)
    // 下层文件中比 FileMetaData.smallest 大的第一个文件下标。 
    // target > FileMetaData.smallest 时搜索用。
    int32_t smallest_lb;

    // Point to a left most file in a lower level that may contain a key,
    // which compares greater than largest of a FileMetaData (upper level)
    // 下层文件中比 FileMetaData.largest 大的第一个文件下标。 
    // target > FileMetaData.largest 时搜索用。
    int32_t largest_lb;

    // Point to a right most file in a lower level that may contain a key,
    // which compares smaller than smallest of a FileMetaData (upper level)
    // 下层文件中比 FileMetaData.smallest 小的最后一个文件下标。 
    // target < FileMetaData.smallest 时搜索用。
    int32_t smallest_rb;

    // Point to a right most file in a lower level that may contain a key,
    // which compares smaller than largest of a FileMetaData (upper level)
    // 下层文件中比 FileMetaData.largest 小的最后一个文件下标。 
    // target < FileMetaData.largest 时搜索用。
    int32_t largest_rb;
  };

复制代码

上面注释看起来比较拗口,但是从如何对其使用入手,就能比较容易理解了。假设我们现在要查找的值为 x,待比较的某个上层文件上下界为[smallest, largest] ,则该上下界将键空间切分为五个区间:(-∞, smallest), smallest, (smallest, largest), largest, (largest, +∞)。仅考虑该文件搜索结束就要去下层搜索的情况,则有:

  1. 如果 x < smallest,则需要在下层中比 smallest 小的最右边那个文件(smallest_rb)找。

  2. 如果 x == smallest,即 smallest ≤ x ≤ smallest,则需要在下层比 smallest 大的最左边的文件(smallest_lb)开始找,到比 smallest 小的最右边的文件(smallest_rb)停止。

  3. 如果 smallest < x < largest,则需要在下层比 smallest 大的最左边的文件(smallest_lb)开始找,到比 largest 小的最右边的文件(largest_rb)停止。

  4. 如果 x == largest,即 largest ≤ x ≤ largest,则需要在下层比 largest 大的最左边的文件(largest_lb)开始找,到比 largest 小的最右边的文件(largest_rb)停止。

  5. 如果 x > largest,则需要在下层比 largest 大的最左边那个文件(largest_lb)开始找。

对应代码如下:

if (cmp_smallest < 0) { // target < smallest,使用 smallest_rb 作为右界
  *left_bound = (level > 0 && file_index > 0)
                    ? index_units[file_index - 1].largest_lb
                    : 0;
  *right_bound = index.smallest_rb;
} else if (cmp_smallest == 0) {
  *left_bound = index.smallest_lb;
  *right_bound = index.smallest_rb;
} else if (cmp_smallest > 0 && cmp_largest < 0) {
  *left_bound = index.smallest_lb;
  *right_bound = index.largest_rb;
} else if (cmp_largest == 0) {
  *left_bound = index.largest_lb;
  *right_bound = index.largest_rb;
} else if (cmp_largest > 0) {
  *left_bound = index.largest_lb;
  *right_bound = level_rb_[level + 1];
} else {
  assert(false);
}

复制代码

**注:**当 x == smallest 或者 x == largest 时,对于 RocksDB 使用场景来说,上层已经找到了,无须再往查找下层。但该 FileIndexer 的应用场景更泛化一些,比如你可以使用其往下层寻找所有等于给定值的结果。

下面,仍以之前例子,来使用图解释下:

create-indexing-sst.png

RocksDB 中 SST 建立索引

可以看出,当上层文件边界(如 100)落到下层文件内(如 file 3 [95, 110])时,该边界 lb 和 rb 指针指向相同,蜕化为单指针;当文件边界(如 400)落到下层文件空隙内(如 file 7 和 file 8 之间),lb 和 rb 才指向不同,从而在搜索时,相对单指针,总体上减少一个待扫描文件。

另一方面,注意到,当上层文件边界值(如 400)落在下层文件空隙内时(即该值在下层肯定不存在),有 largest_rb < largest_lb ,如果搜索值是 400,则利用此指针直接导致 left_bound > right_bound,搜索结束。

在具体实现时, RocksDB 使用了双指针比较法,一个指针迭代上层,一个指针迭代下层,一次迭代即可为所有文件建立一种索引。为减少代码中的分支判断,使逻辑清晰,RocksDB 将建立过程拆为了四趟,分别构建上述四种指针,逻辑封装在了两个函数中:CalculateLBCalculateRB

下面以 CalculateLB 代码为例,简单注释分析:

// 计算 lower_files 中比某值大的最左(从左到右第一个)文件下标
void FileIndexer::CalculateLB(
    const std::vector<FileMetaData*>& upper_files, // 上层 SST 文件
    const std::vector<FileMetaData*>& lower_files, // 下层 SST 文件
    IndexLevel* index_level,
    std::function<int(const FileMetaData*, const FileMetaData*)> cmp_op,
    std::function<void(IndexUnit*, int32_t)> set_index) {
  const int32_t upper_size = static_cast<int32_t>(upper_files.size());
  const int32_t lower_size = static_cast<int32_t>(lower_files.size());
  int32_t upper_idx = 0;
  int32_t lower_idx = 0;

  IndexUnit* index = index_level->index_units;
  while (upper_idx < upper_size && lower_idx < lower_size) { // 双指针比较法
    // 总是跟 lower_files[lower_idx].largest 比较
    int cmp = cmp_op(upper_files[upper_idx], lower_files[lower_idx]);

    if (cmp == 0) {
      set_index(&index[upper_idx], lower_idx);
      ++upper_idx;
    } else if (cmp > 0) {
      // 下层 lower_idx 处文件的最大值比给定值小,则不满足条件
      ++lower_idx;
    } else {
      // 下层 lower_idx 处文件的最大值相对给定值第一次变大,满足条件,设置索引
      set_index(&index[upper_idx], lower_idx);
      ++upper_idx;
    }
  }

  while (upper_idx < upper_size) {
    // 下层文件用完了,表示现在所有余下的上层文件比所有下层文件都大
    // 于是让他们都指向 lower_size,即不存在(下层文件下标为 0~lower_size-1)。
    set_index(&index[upper_idx], lower_size);
    ++upper_idx;
  }

  // 如果是上层文件用完了,不做额外处理。因为函数作用是为上层文件设置索引,
  // 上层文件用完了,说明已经为所有上层文件设置了索引。
}

复制代码

四趟调用,分别为上层每一个文件索引项 IndexUnit 的四个字段赋值。

CalculateLB(
    upper_files, lower_files, &index_level,
    [this](const FileMetaData* a, const FileMetaData* b) -> int {
      return ucmp_->CompareWithoutTimestamp(a->smallest.user_key(),
                                            b->largest.user_key());
    },
    [](IndexUnit* index, int32_t f_idx) { index->smallest_lb = f_idx; });
CalculateLB(
    upper_files, lower_files, &index_level,
    [this](const FileMetaData* a, const FileMetaData* b) -> int {
      return ucmp_->CompareWithoutTimestamp(a->largest.user_key(),
                                            b->largest.user_key());
    },
    [](IndexUnit* index, int32_t f_idx) { index->largest_lb = f_idx; });
CalculateRB(
    upper_files, lower_files, &index_level,
    [this](const FileMetaData* a, const FileMetaData* b) -> int {
      return ucmp_->CompareWithoutTimestamp(a->smallest.user_key(),
                                            b->smallest.user_key());
    },
    [](IndexUnit* index, int32_t f_idx) { index->smallest_rb = f_idx; });
CalculateRB(
    upper_files, lower_files, &index_level,
    [this](const FileMetaData* a, const FileMetaData* b) -> int {
      return ucmp_->CompareWithoutTimestamp(a->largest.user_key(),
                                            b->smallest.user_key());
    },
    [](IndexUnit* index, int32_t f_idx) { index->largest_rb = f_idx; });

复制代码

小结

SST をインデックス化する方法は、より直感的で (B+ ツリーと比較できます)、理解しやすいです。ただし、コードの実装に応じて、対処する必要がある詳細と境界条件が多く、間違いを犯しやすいです。花を書くことができる二分探索と同じように、耳を鍛えるだけです。

参照する

  1. RocksDB 博客,ルックアップ パフォーマンスを向上させるための SST ファイルのインデックス作成****,**** rocksdb.org/blog/2014/0…

  2. ウィキペディア、分散型カスケーディング、フラクショナル カスケード、en.wikipedia.org /wiki/Fracti…

  3. RocksDB github FileIndexer:github.com/facebook/ro…

写真が好きな配信システムプログラマーのQingteng Wood Birdです. 私の公開アカウントWood Bird Miscellaneous Notesに注意を払うのが好きです. 私の記事が悪くないと思われる場合は、親指を立ててサポートしてください。ありがとうございます。

関連記事

LevelDBのデータ構造の話(3):LRUキャッシュ(LRUCache)

LevelDBのデータ構造の話(2):ブルームフィルター(Bloom Filter)

LevelDBのデータ構造の話 (1): スキップリスト

おすすめ

転載: juejin.im/post/7134225668027449352