Google LevelDB は LSM-Tree 実装の例です。ただし、オープンソースがリリースされた後、軽量で簡潔なスタイルを維持するために、バグの修正は別として、大幅な更新と反復は行われていません。産業環境での多様な負荷に対応できるようにするために、Facebook (Meta) は、Fork LevelDB の後にさまざまな最適化を行いました。ハードウェアに関しては、フラッシュ メモリや高速ディスク、マルチコア CPU など、最新のハードウェアをより効果的に使用できます。 SST インデックス、インデックス シャーディング、プレフィックス ブルーム フィルター、列ファミリー 待機。
この一連の記事は、RocksDB シリーズのブログに基づいており、ソース コードといくつかの使用経験を組み合わせて、いくつかの興味深い最適化ポイントを共有し、すべての人に刺激を与えることを望んでいました。レベルには制限があり、不適切である場合は、議論のためにメッセージを残してください。
この記事は、RocksDB 最適化シリーズの最初の記事であり、ディープ クエリのパフォーマンスを最適化するために、さまざまなレベルの SST が特定の方法でインデックス化されています。
バックグラウンド
まず、前回の記事で描いたLevelDBのアーキテクチャ図。
その中で、LevelDB の aGet()
のようになります。
-
変更可能な memtable
-
不変の memtable の配列
-
階層的に編成された一連の 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+ ツリーなど) に似たレベル間インデックス構造を追加して、基礎となる二分範囲を縮小できます。このアイデアは、フラクショナル カスケードと呼ばれます。
举个例子,如上图,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, +∞)
。仅考虑该文件搜索结束就要去下层搜索的情况,则有:
-
如果 x < smallest,则需要在下层中比 smallest 小的最右边那个文件(
smallest_rb
)找。 -
如果 x == smallest,即 smallest ≤ x ≤ smallest,则需要在下层比 smallest 大的最左边的文件(
smallest_lb
)开始找,到比 smallest 小的最右边的文件(smallest_rb
)停止。 -
如果 smallest < x < largest,则需要在下层比 smallest 大的最左边的文件(
smallest_lb
)开始找,到比 largest 小的最右边的文件(largest_rb
)停止。 -
如果 x == largest,即 largest ≤ x ≤ largest,则需要在下层比 largest 大的最左边的文件(
largest_lb
)开始找,到比 largest 小的最右边的文件(largest_rb
)停止。 -
如果 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
的应用场景更泛化一些,比如你可以使用其往下层寻找所有等于给定值的结果。
下面,仍以之前例子,来使用图解释下:
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 将建立过程拆为了四趟,分别构建上述四种指针,逻辑封装在了两个函数中:CalculateLB
和 CalculateRB
。
下面以 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+ ツリーと比較できます)、理解しやすいです。ただし、コードの実装に応じて、対処する必要がある詳細と境界条件が多く、間違いを犯しやすいです。花を書くことができる二分探索と同じように、耳を鍛えるだけです。
参照する
-
RocksDB 博客,ルックアップ パフォーマンスを向上させるための SST ファイルのインデックス作成****,**** rocksdb.org/blog/2014/0…
-
ウィキペディア、分散型カスケーディング、フラクショナル カスケード、en.wikipedia.org /wiki/Fracti…
-
RocksDB github FileIndexer:github.com/facebook/ro…
写真が好きな配信システムプログラマーのQingteng Wood Birdです. 私の公開アカウントWood Bird Miscellaneous Notesに注意を払うのが好きです. 私の記事が悪くないと思われる場合は、親指を立ててサポートしてください。ありがとうございます。
関連記事
LevelDBのデータ構造の話(3):LRUキャッシュ(LRUCache)