新着:HeliusがLight Protocolを買収
安価な書き込みの代償:RocksDB はどのように読み取るのか?
ブログ/エンジニアリング

安価な書き込みの代償:RocksDB はどのように読み取るのか?

Developer Experience EngineerXの0xIchigoLinkedInの0xIchigoGitHubの0xIchigo
読了時間:3分

キーバリューストアからの読み取りは単純に思えます。キーを渡し、値を受け取るだけです。しかし、このシリーズの前回の記事では、なぜこの考え方が誤っているのかを説明しました。LSM ツリーは、データを整理する作業を先送りすることで、書き込みを安価にするというトレードオフを選びます。

その代償を支払うのが読み取りです。

キーの現在値は、memtable、レベル 0 のファイル、またはその下にある任意のソート済みレベルに存在する可能性があります。読み取りパスの役割は、すべてを確認せずに、この値の最新バージョンを見つけることです。

この記事では、その検索を高速に保つ仕組みと、コストを決定する計算方法について説明します。

RocksDB はどのようにキーを読み取るのか?

RocksDB の読み取りは、最新のデータから最も古いデータへ進み、キーの最初のバージョンを見つけた時点で停止します。基本はそれだけであり、ほかのすべてはこの処理を高速化するためにあります。

検索順序は固定されており、前回の記事で説明した書き込みパスを逆向きにたどります。アクティブな memtable、イミュータブルな memtable、レベル 0 の SST ファイル、そしてレベル 1 以下のソート済み SST ファイルの順です。

RocksDB は最初にアクティブな memtable を確認します。これは最新の書き込みを保持し、メモリ上に存在し、スキップリストを使用しているためです。その結果、ルックアップはソート済みのインメモリ検索となり、I/O コストはほとんどかかりません。キーが見つかると、検索は直ちに停止します。

アクティブな memtable の次に、RocksDB は容量に達しているものの、まだディスクへフラッシュされていないイミュータブルな memtable を確認します。新しいものから古いものの順に調べます。

その後、ディスクを確認します。レベル 0 のファイルはキー範囲が重複しているため、それぞれを新しいものから古いものの順に個別に確認する必要があります。各ファイルは、ある memtable が取り込んだ内容のスナップショットであり、同じキーが同時に 2 つのレベル 0 ファイルに存在し得るためです。

レベル 0 の各ファイルは、キーが隠れている可能性のある別々の場所です。だからこそ、レベル 0 のファイル数は読み取りに負担をかけ、RocksDB はその数を制限するために書き込みを停止させます。

レベル 1 以下では、各レベル内の SST ファイルはソート済みで互いに重複しないため、キーを含む可能性があるファイルは各レベルで最大 1 つです。そのファイルはファイルメタデータの二分探索で特定され、その後、検索はファイル内部へ進みます。インデックスブロックが適切なデータブロックを特定し、そのデータブロックにキーがあるか、このレベルには存在しないことが示されます。

シーケンス番号は、最初に見つかったバージョンで検索を停止させるため、RocksDB の読み取りパスで重要です。これにより、そのバージョンが最新であることが保証されます。たとえば読み取りでトゥームストーンが見つかった場合、検索は成功し、キーが削除済みであることを返します。そのため、検索はトゥームストーンで終了し、古い値はディスク上に残ります。

RocksDB が提供する読み取り操作とは?

RocksDB は 4 種類の読み取り方法を公開しており、それぞれが読み取りパスを異なる方法で使用します。Get、MultiGet、Iterators、Snapshots です。

Get

Get は、前のセクションで追跡したポイントルックアップです。つまり、最新から最古へ検索して 1 つのキーを見つけ、最初のバージョンを採用します。

MultiGet

MultiGet は、ポイントルックアップをバッチ処理します。読み取り時に RocksDB へキーの集合を渡すと、それらがソートされ、アクセスする可能性のあるファイルごとにグループ化されます。

これにより、RocksDB は 1 つの SST を開き、そのインデックスブロックとフィルターブロックを一度だけ参照し、I/O をバッチ処理できます。読み取りパスは Get と同じですが、コストが固定され、バッチ全体で償却されます。

イテレーター

Iterators は範囲をスキャンします。つまり、あるキーへシークし、ソート順に前方または後方へ進みます。

実質的に、イテレーターは k-way マージです。つまり、k 個の異なるソート済みリストを、完全にソートされた 1 つのシーケンスへ結合します。memtable、各イミュータブルな memtable、すべての L0 ファイル、下位の各レベルから 1 ファイルという、すべての有効なデータソースが独自のソート済みカーソルを提供し、ヒープが次に小さいキーを保持するカーソルを繰り返し返します。

最新バージョンを優先するこのルールは各ステップで適用されるため、複数のソースが同じキーを提示した場合は最新バージョンが採用され、残りは破棄されます。

イテレーターが進むと、隠された値とトゥームストーンはスキップされます。つまり、削除済みキーで埋め尽くされた範囲は、何も返さずにイテレーターが内部で走査しなければならない範囲です。大量に削除されたキースペースでは、ポイントルックアップでは気付かない形でスキャンが遅くなります。

スナップショット

Snapshot は、ある時点で取得されたシーケンス番号です。これを伴う読み取りは、その番号より新しいバージョンを無視し、その番号以下で最新のバージョンを返します。これにより、整数比較だけで、一貫性のある特定時点の読み取りを実現できます。

スナップショットの取得自体は実質的に無料です。コストは後から発生するためです。有効なスナップショットから見えるバージョンは、どれほど古くても回収できません。

Get と MultiGet は、目的のキーを正確に把握しています。そのため、「このキーはどこにあるか?」よりも安価な問い、すなわち「どのファイルに確実に存在しないか?」を検索できます。Iterators はこの近道を利用できません。この安価な問いに答えることが、ブルームフィルターの役割です。

ブルームフィルターとは?

ブルームフィルターは、集合について 1 つの問いに答えます。この要素は確実に集合に含まれていないか? 答えは「確実に含まれていない」か「含まれている可能性がある」のどちらかであり、確実に含まれているとは答えません。キーが確実に存在しないファイルを検索で開く必要がなくなるため、I/O を節約できます。

SST の構築時には、各キーを複数のハッシュ関数に通し、それぞれのハッシュがコンパクトなビット配列(フィルターブロック)内のビットを設定します。

読み取り時には、検索対象のキーを同じ方法でハッシュし、それらのビットを確認します。いずれかのビットが未設定なら、そのキーは挿入されていないため、ここには確実に存在しません。

すべてのビットが設定されている場合、キーは存在する可能性があります。「可能性」とするのは、ほかのキーのハッシュが偶然同じビットを設定している場合があるためです。

つまり、偽陽性が発生する可能性があります。検索がファイルを開いて確認しても何も見つからず、フィルターが節約するはずだった I/O を消費します。

RocksDB で一般的な、キーあたり 10 ビットという設定では、偽陽性率は約 1% です。10 ビットを使うことで、そのキーを含まないファイルを 99% の確率でスキップできます。100 万キーを持つ SST のフィルターは 1 MB 強で、インデックスとともにメモリ上に常駐し、保護対象のディスクに触れずに参照されます。システムエンジニアリングにおいて、これほど少ないコストで大きな効果を得られる構造はほとんどありません。

これは、このシリーズの前回の記事でフィルターブロックのサイズがゼロだった理由でもあります。ブルームフィルターはオプトインであり、filter_policy を通じてアプリケーションが選択するキーあたりのビット数で設定します。デフォルトでは設定されません。

ワークロードを事前に把握できないライブラリとしては妥当なデフォルトです。しかし本番環境では妥当ではありません。フィルターの有無によって、存在しないキーのコストがファイルごとに 1 回のメモリ確認で済むか、ファイルごとにインデックスブロックを読み取る必要があるかが決まるためです。

制約の 1 つは、ブルームフィルターが役立つのは検索対象の正確なキーが分かっている場合だけという点です。ブルームフィルターは key:0301 がこのファイルに存在するかどうかには答えられますが、ハッシュによって順序が失われるため、このファイルに key:0090 と key:0110 の間の何かが含まれるかどうかには答えられません。

次のキーを事前に把握できない Iterators は、この構造の恩恵を受けられません。ブルームフィルターはポイントルックアップ向けの技術です。範囲アクセスが多いワークロードでは、別の対策が必要です。

RocksDB のプレフィックスシークはどうか?

スキャン問題に対する RocksDB の部分的な解決策は、ハッシュ対象を変えることです。プレフィックス抽出器は、キーに意味のある構造があることを RocksDB に伝えます。たとえば、先頭 8 バイトや、区切り文字より前のすべてです。プレフィックスが何かを RocksDB が認識すると、キー全体の代わりに、またはキー全体と併せて、プレフィックスに対するブルームフィルターを構築できます。特定のプレフィックスで始まるキーがファイルに含まれるかを判定するフィルターは、イテレーターにも役立ちます。

ただし、この近道は 1 つのプレフィックス内でのみ機能します。

指定したプレフィックスのスキャンは、ポイントルックアップと同じ方法でファイルやブロックをスキップできます。あるプレフィックスから次のプレフィックスへまたがるスキャンでは、同じ恩恵を受けられません。プレフィックスをまたいで反復順序の保証を維持する方法については、それを可能にする細かな設定を別途詳しく掘り下げる必要があります。

プレフィックスシークによって、あらゆる範囲スキャンが安価になるわけではありません。

特定のプレフィックスに対する、非常に一般的な 1 種類の範囲スキャンだけを安価にします。

RocksDB のブロックキャッシュとは?

ブロックキャッシュは、非圧縮ブロックのインメモリキャッシュです。SST からデータブロックを読み取ると、そのブロックは展開されて保持されるため、次に同じブロックを必要とする読み取りはディスクに触れずに取得できます。つまり、ホットキーはホットブロック内に集まり、ホットブロックでは I/O コストが発生しなくなります。

つまり、ブルームフィルターは検索がファイルへ入るのを防ぎ、ブロックキャッシュは入る必要があるファイルへの再アクセスを安価にします。

注意点は、キャッシュが 2 つあることです。

ブロックキャッシュは RocksDB 自身のキャッシュであり、明示的に管理する非圧縮ブロックを保持します。その下には、RocksDB が最近読み取った未加工の圧縮済みファイルページを保持する、OS のページキャッシュがあります。同じデータが両方に同時に存在する場合があります。つまり、ブロックキャッシュでは非圧縮、ページキャッシュでは圧縮された状態です。このため、RocksDB の実際のメモリ使用量は常に過小評価されがちです。また、ページキャッシュを無視したキャッシュサイズの議論では、システムの半分しか考慮していません。

インデックスブロックとフィルターブロックは特別に扱われます。これらは小さく、対象ファイルに到達するほぼすべての読み取りで参照され、キャッシュミスのコストが壊滅的に高いため、常駐させることはほぼ必須です。

ブロックキャッシュの予算内に配置するか、テーブルリーダー独自のメモリとして予算外に配置するかは、構成(つまり cache_index_and_filter_blocks)で選択します。

トゥームストーンとマージチェーンが読み取りに与えるコストとは?

トゥームストーンとマージチェーンは、読み取りパスへ影響する書き込みパスのレコード種別ですが、解消できるのはコンパクションだけです。トゥームストーンは、コンパクションによって自身と、その背後に隠された不要なデータが削除されるまで蓄積します。マージチェーンは、コンパクションによって確定値へ畳み込まれるまで伸び続けます。

トゥームストーンを見つけたポイントルックアップのコストは低くなります。「削除済み」は有効な結果なので、検索を停止して回答を返せるためです。しかし、イテレーターはトゥームストーンで停止できません。トゥームストーンを解決し、それによって隠されたバージョンをスキップし、次の有効なキーが見つかるまで進み続ける必要があります。大量に削除されたキースペースは、何も返さないまま走査して破棄しなければならないエントリが散在する地雷原です。そのスキャンでは、わずかな出力のために予測困難な大量の作業が発生する可能性があります。ポイントルックアップはこれに気付きませんが、範囲スキャンには防ぐ手段がありません。

マージオペランドも同様のコストを生みますが、方向が逆です。マージ済みキーの読み取りは、最新レコードで停止できません。オペランドは値ではなく、変更の記述だからです。そのため検索は、完全な値、トゥームストーン、またはツリーの最下層に到達するまでオペランドを収集し続け、その後、マージ演算子でチェーンを畳み込む必要があります。

前回の記事のウォークスルーでは長さ 3 のチェーンを畳み込みましたが、設計上、チェーンを 3 に制限するものはありません。コンパクションの間に 1 万回マージされたカウンターを読み取るには、1 万個のオペランドを収集して畳み込む必要があります。書き込みパスには、それを防ぐ仕組みがありません。

増幅のトライアングル

増幅のトライアングルは、すべての LSM ツリーを支配する 3 方向のトレードオフです。書き込み増幅、読み取り増幅、空間増幅からなります。いずれか 2 つを改善すると、残る 1 つが悪化します。

Athanassoulis らは、これらのトレードオフを RUM 予想(2016 年)として形式化しました。これは、アクセス方式が最小化できるのは、読み取りオーバーヘッド、更新オーバーヘッド、メモリまたは空間のオーバーヘッドのうち最大 2 つであり、残る 1 つがコストを引き受けるというものです。B ツリーは、読み取りが安価で更新が高価な位置にあります。一方、LSM ツリーは更新が安価で読み取りが高価な別の位置にあり、トライアングルはその間の領域を示します。

この記事で扱ったすべての仕組みは、トライアングルの一角が自らを守るためのものです。

書き込み増幅とは、アプリケーションが論理的に書き込んだバイト数に対する、ディスクへ物理的に書き込まれたバイト数の比率です。1 つの Put は、WAL へ 1 回、L0 へのフラッシュ時に 1 回、さらにコンパクションで下位レベルへ移動するたびに書き込まれます。つまり、十数レベルを持つレベル型 LSM では、1 回の論理書き込みが、その存続期間中に 10~30 回の物理書き込みになることも珍しくありません。前回の記事では、これらの書き込みがすべてシーケンシャルであることを評価しましたが、この記事ではその代償に注目します。

読み取り増幅とは、クエリが回答を見つけるために確認しなければならない場所の数です。ポイントルックアップの最悪ケースでは、すべての memtable、L0 ファイル、下位の各レベルから 1 ファイルを確認します。つまり、この記事の冒頭で追跡した検索です。実際のコストは、ブルームフィルターとブロックキャッシュを通過したものによって決まります。範囲スキャンでは、すべてのソースを同時に開いたままにする k-way マージが該当します。この記事の大部分は、読み取り増幅を扱っています。フィルター、キャッシュ、プレフィックス抽出器は、そのコストを抑えるための仕組みです。

空間増幅とは、保存された論理データ量に対する、消費されたディスク容量の比率です。コンパクションを待つ古いバージョン、トゥームストーンとその背後に隠された不要なデータ、畳み込まれていないマージチェーン、有効なスナップショットが固定するすべてのバージョンは、アプリケーションが読み取れない、または不要になったデータのためにディスク容量を消費します。適切に調整されたレベル型コンパクションでは、これを約 1.1 倍に抑えられますが、ほかの構成ではさらに多くの空間と引き換えになります。

トライアングルでは、いずれか 2 つの頂点を改善すると、3 つ目が悪化します。つまり、

  • 積極的にコンパクションする:読み取りは高速になり、使用空間は縮小しますが、各キーが絶えず再書き込みされ、書き込み増幅が上昇します
  • コンパクションを遅らせる:書き込みはスムーズになりますが、読み取りで確認するファイルが積み上がり、不要なデータがディスクに残ります
  • フィルターにメモリを使う:キャッシュと読み取りは改善しますが、メモリはトライアングルの各頂点が競り合う、目立たない第 4 のリソースです

「高速」と書かれた設定はありません。あるのは、特定のワークロードに合う、または合わない、トライアングル上の位置だけです。

読み取りパスのウォークスルー

以下では、前回の記事のウォークスルーで使用したプロジェクトを、統計を有効にして再利用します。

src/main.rs の内容を次のように置き換えてください。

コード
use rocksdb::{BlockBasedOptions, Options, DB};

fn main() {
	let with_bloom = std::env::args().nth(1).as_deref() == Some("bloom");
	let path = if with_bloom { "/tmp/read-bloom" } else { "/tmp/read-nobloom" };

	let mut opts = Options::default();
	opts.create_if_missing(true);
	opts.enable_statistics();

	if with_bloom {
		let mut bbt = BlockBasedOptions::default();

		bbt.set_bloom_filter(10.0, false);
		opts.set_block_based_table_factory(&bbt);
	}

	let db = DB::open(&opts, path).unwrap();

	// Three L0 SSTs with fully overlapping ranges: file b holds keys where
	// key % 4 == b, all spanning 0..600. Keys where key % 4 == 3 exist nowhere
	for batch in 0..3u64 {
		let mut k = batch;

		while k < 600 {
			let key = format!("key:{:04}", k);
			db.put(key.as_bytes(), b"value").unwrap();
			k += 4;
		}

		db.flush().unwrap();
	}

	// key:0404 lives only in the oldest file, meaning the search must get past
	// the two newer files to reach it
	let hit = db.get(b"key:0404").unwrap();
	println!("get(key:0404) = {:?}", hit.map(|b| String::from_utf8(b).unwrap()));

	// key:0303 is inside every file's range and inside no file
	let miss = db.get(b"key:0303").unwrap();
	println!("get(key:0303) = {:?}", miss);

	if let Some(stats) = db.property_value("rocksdb.options-statistics").unwrap() {
		for line in stats.lines() {
			if line.contains("bloom.filter.useful")
				|| line.contains("bloom.filter.full.positive")
				|| line.contains("block.cache.data") {
				println!("{}", line);
			}
		}
	}
}

ここでは、キー範囲が完全に重複する、フラッシュ済みの L0 ファイルが 3 つあります。両方のキーがどのファイルにも隠れている可能性があるため、範囲メタデータだけではどのファイルも除外できません。

ヒット(つまり key:0404)は最も古いファイルにだけ存在します。そのため、最新優先の検索では、そのキーを持たない 2 つのファイルを処理してから、キーを持つファイルへ到達します。

ミス(つまり key:0303)は、すべてのファイルの範囲内にあるものの、どのファイルにも存在しません。この読み取りを省けるのはブルームフィルターだけです。

まずそのまま実行し、次に引数 bloom を指定して実行してください。

フィルターなしの出力は次のとおりです。

コード
get(key:0404) = Some("value")
get(key:0303) = None
rocksdb.block.cache.data.miss COUNT : 3
rocksdb.block.cache.data.hit COUNT : 3
rocksdb.bloom.filter.useful COUNT : 0

2 回のルックアップに答えるため、データブロックへ 6 回アクセスしています。ヒットでは 3 つの各ファイルからデータブロックを読み取りましたが、そのうち 2 つは役に立たず、3 回のディスク読み取りが発生しました。次にミスでも同じ 3 ファイルを確認しました。その 3 回のデータブロックアクセスがキャッシュヒットになったのは、最初のルックアップが直前にキャッシュをウォームアップしたからです。ブロックキャッシュだけが役割を果たしています。

フィルターありの出力は次のとおりです。

コード
get(key:0404) = Some("value")
get(key:0303) = None
rocksdb.block.cache.data.miss COUNT : 1
rocksdb.block.cache.data.hit COUNT : 0
rocksdb.bloom.filter.useful COUNT : 5
rocksdb.bloom.filter.full.positive COUNT : 1

データブロックへのアクセスは合計 1 回です。useful が 5 になる計算を手作業で確認できます。

  • ヒットでは、最新ファイルのフィルターと 2 番目のファイルのフィルターを参照し、どちらも「なし」を返しました。最も古いファイルのフィルターは、唯一の full.positive を返し、真陽性であることが確認されました。これにより、2 回スキップできました
  • ミスでは 3 つすべてのフィルターを参照し、3 回「なし」が返されました。つまり、3 回スキップし、開いたファイルはゼロです

ヒットとミスを合計すると、ファイル確認は 5 回です。フィルターがメモリから回答した 5 回のファイル確認は、フィルターなしの実行で必要だった 5 回のデータブロック読み取りに相当します。つまり、キーあたり 10 ビットによって、6 回のブロックアクセスが 1 回になりました。

これは、実質的に小規模な増幅のトライアングルです。フィルターなしの実行では読み取り増幅のコストを全額支払い、フィルターありの実行ではメモリ、つまりファイルあたり数百バイトのフィルターでそのコストを支払いました。どちらの場合も書き込みパスは何も支払っていません。RocksDB のすべてのチューニング判断は、これを大規模にしたトレードオフです。

Solana は読み取りパスにどのような負荷をかけるのか?

前回の記事では、バリデータを「永遠に回線速度で流入し続ける shred」という書き込みパスのストレステストとして捉えました。読み取り側は、この取引のもう半分です。誰も読み戻せない台帳は、データセンターを温めるための高価な手段にすぎません。

この記事で説明した検索順序、フィルター、キー設計に関するすべての主張は、Solana のデータが実際にクエリされる方法に現れます。

バリデータ内部では、Blockstore の読み取りは 2 種類に分かれます。修復リクエストへの応答と、ブロックのリプレイまたはストリーミングです。

修復リクエストへの応答では、ほかのバリデータが受信できなかった shred を要求します。これは (slot, index) によるポイントルックアップです。ブロック 1 つ分の台帳のリプレイまたはストリーミングは、指定されたスロットに対する範囲スキャンです。

shred のキーはスロット番号で始まるため、1 つのスロットに属するすべての shred がディスク上で連続して配置されます。そのため、「スロット n を取得する」という操作は、ポイント読み取りが分散するのではなく、狭い範囲をシーケンシャルに進むイテレーター処理になります。

書き込みパスのキー設計が、読み取りパスの最適化になります。つまり、フラッシュ時にほとんど重複しない L0 ファイルを生成できたスロット優先の同じレイアウトにより、ブロック読み取りも連続した 1 回の走査になります。

RPC レイヤーでは、さらに難しい問いを処理します。getTransaction は、署名を使ってトランザクションステータスデータを検索するポイントルックアップです。これはブルームフィルターが想定するユースケースです。指定された SST に存在しない署名は、インデックス読み取りではなく、1 回のメモリ確認で除外できるべきだからです。

getSignaturesForAddress は、この記事の前半で説明したプレフィックス型のスキャンです。アドレスから署名へのインデックスはアドレスで始まるため、1 つのアカウントの全履歴が連続した 1 つのキー範囲を占めます。そのため、アドレス全体のアクティビティは、この構造が得意とする範囲スキャンの形になります。

しかし、アプリケーションが実際に尋ねる問いは、その両方の上に成り立ちます。「このアドレスのすべてのトランザクションを取得する」というプリミティブはありません。この種の問いは、まず署名のプレフィックススキャンを行い、次に各署名からトランザクションをポイントルックアップする処理になります。

実質的に、1 つの範囲クエリが数百または数千の Get に分岐します。これはまさに、MultiGet のバッチ処理が想定するワークロードです。つまり、署名をソートし、アクセスするファイルごとにグループ化し、各ファイルのインデックスとフィルターを一度だけ参照します。

外部からは 1 回の API 呼び出しに見えるクエリでも、読み取りパスでは、この記事で説明したすべての仕組みが順番に動作します。イテレーター、プレフィックス順序、ブルームフィルター、バッチ償却、ブロックキャッシュです。

私たちは、その API 呼び出しを構築しました。

従来、開発者はクライアント側で getSignaturesForAddress と n 回の getTransaction 呼び出しをループさせ、n+1 パターンで必ず必要になる再試行ロジックも実装する必要がありました。

getTransactionsForAddress は、そのループを 1 つのメソッドに統合し、この記事で説明した読み取りパスの仕組みに合うさまざまな機能を提供します。

昇順と降順は、イテレーターが進む 2 つの方向です。カーソルベースのページネーションでは、キー位置を表す slot:position 形式のトークンを返します。そのため、ページ分割されたクエリの再開とは、前回停止した位置へスキャンを再シークすることです。スロットと時間のフィルターは、処理後に結果を破棄するのではなく、処理範囲自体を制限します。

このメソッドを提供するアーカイブシステムは、数百テラバイトに及ぶ Solana の全トランザクション履歴を RocksDB に保持しています。私たちはこれを ClickHouse から RocksDB へ移行しました。これが、このシリーズが存在する理由です。これらの記事で説明するストレージエンジンは、アカウントの全期間を連続したスキャンにし、署名ルックアップを 1 回のメモリ確認にするため、私たちがカラムファミリーごとに調整したものです。だからこそ、私たちは Solana 全体で最高性能のアーカイブシステムを提供できています。

まとめ

読み取りパスは LSM ツリーの返済計画であり、この記事ではその明細を示しました。ルックアップは、memtable、L0、すべてのソート済みレベルを最新から最古へたどり、最初のバージョンを見つけた時点で停止します。ほかのすべての機能とツールは、この検索で必要なアクセス回数を最小限に抑えるために存在します。ブルームフィルターは、あるキーがファイルに確実に存在しないかをメモリ上で判定し、そのファイルを開かずに済ませます。ブロックキャッシュは、開く必要があるファイルへの再アクセスを安価にします。プレフィックス設計は、範囲スキャンで唯一安価な形を、アプリケーションが実際に必要とする形に変えます。

増幅のトライアングルは、これらすべてを記録する台帳です。読み取りコストはメモリで支払われ、書き込みコストはシーケンシャルな分割払いとして先に支払われます。空間コストは、検索がまたいでいく各トゥームストーンや隠されたバージョンに、静かに積み上がります。

この記事で扱った各構造は、蓄積したファイル、不要なバージョン、畳み込まれていないマージチェーンのコストを単に管理するだけです。この記事で説明したものは、実際には何も削除しません。それらを削除する処理はバックグラウンドで継続的に動作します。その実行をどの程度積極的にするかは、RocksDB で最も重要なチューニング判断です。このシリーズの次回の記事では、コンパクションを取り上げます。コンパクションの役割、RocksDB が提供する 3 つの方式、そして Solana の Blockstore が方式を切り替えた後、元に戻したことで知られる理由を解説します。

データベースの読み取りパスについてのウォークスルーを午後いっぱいかけて読むことに魅力を感じるなら、私たちと一緒に開発しませんか。ここで説明した読み取りパスは、Solana の全履歴に対して私たちが運用しているものです。ブルームフィルターを参照するか、ファイルを開くかの違いは、実際のハードウェアと実際のコストに直結します。現在、エンジニアリングチームの各職種で採用を行っています。募集中のすべての職種は helius.dev/careers でご覧いただけます。

Heliusを購読

Solana開発の最新情報や新しい記事の公開通知を受け取れます

拡大画像