メインコンテンツまでスキップ
メインコンテンツまでスキップ

ClickHouseのデータスキッピングインデックスの理解

はじめに

ClickHouseのクエリ性能には多くの要因が影響します。ほとんどのシナリオにおいて重要な要素は、ClickHouseがクエリのWHERE句条件を評価する際に主キーを利用できるかどうかです。それに応じて、最も一般的なクエリパターンに適用される主キーを選択することは、効果的なテーブル設計にとって不可欠です。

しかし、主キーがどれほど慎重に調整されていても、それを効率的に利用できないクエリの使用例は必然的に存在します。ユーザーは一般的に時間系列データにClickHouseを利用しますが、同じデータを顧客ID、ウェブサイトのURL、または製品番号などの他のビジネスディメンションに従って分析したいと望むことが多いです。その場合、WHERE句条件を適用するために各カラム値のフルスキャンが必要になる可能性があるため、クエリ性能はかなり悪化する可能性があります。ClickHouseはそのような状況でも比較的速いですが、数百万または数十億の個別の値を評価することは、「インデックス未使用」のクエリが、主キーに基づくものよりもはるかに遅く実行される原因となります。

従来のリレーショナルデータベースでは、この問題へのアプローチの一つは、テーブルに1つ以上の「セカンダリ」インデックスを添付することです。これはb-tree構造で、データベースがディスク上で一致するすべての行をO(log(n))時間で見つけることを可能にします(ここでnは行数です)。しかし、このタイプのセカンダリインデックスは、ClickHouse(または他の列指向データベース)には機能しません。なぜなら、インデックスに追加するための個別の行がディスク上に存在しないからです。

その代わりに、ClickHouseは特定の状況でクエリ速度を大幅に改善する異なるタイプのインデックスを提供します。これらの構造は「スキップ」インデックスと呼ばれ、ClickHouseが一致する値を持たないことが保証された大きなデータのチャンクを読み取るのをスキップできるようにします。

基本操作

ユーザーはMergeTreeファミリーのテーブルでのみデータスキッピングインデックスを使用できます。各データスキッピングインデックスは次の4つの主要な引数を持ちます:

  • インデックス名。インデックス名は、各パーティションにインデックスファイルを作成するために使用されます。また、インデックスを削除またはマテリアライズする際のパラメータとしても必要です。
  • インデックス式。インデックス式は、インデックスに保存された値のセットを計算するために使用されます。これにはカラムの組み合わせ、単純な演算子、およびインデックスタイプによって決定される関数のサブセットが含まれる場合があります。
  • TYPE。インデックスのタイプは、各インデックスブロックを読み取るのをスキップできるかどうかを決定する計算を制御します。
  • GRANULARITY。各インデックスブロックはGRANULARITYグラニュールから構成されます。例えば、主テーブルインデックスのグラニュラリティが8192行で、インデックスのグラニュラリティが4の場合、各インデックス「ブロック」は32768行になります。

ユーザーがデータスキッピングインデックスを作成すると、テーブルの各データパートディレクトリに2つの追加ファイルが生成されます。

  • skp_idx_{index_name}.idx、これは順序付けされた式の値を含みます。
  • skp_idx_{index_name}.mrk2、これは対応するオフセットを関連データカラムファイルに含むものです。

クエリを実行する際にWHERE句のフィルタリング条件の一部がスキップインデックス式と一致し、関連カラムファイルを読み取ると、ClickHouseはインデックスファイルのデータを使用して、各関連データブロックを処理する必要があるか、またはバイパスできるかを決定します(このブロックがすでに主キーを適用して除外されていない場合)。非常に単純化した例として、次の表を考えてみましょう。

CREATE TABLE skip_table
(
  my_key UInt64,
  my_value UInt64
)
ENGINE MergeTree primary key my_key
SETTINGS index_granularity=8192;

INSERT INTO skip_table SELECT number, intDiv(number,4096) FROM numbers(100000000);

主キーを使用しないシンプルなクエリを実行すると、my_valueカラムの1億エントリがすべてスキャンされます:

SELECT * FROM skip_table WHERE my_value IN (125, 700)

┌─my_key─┬─my_value─┐
│ 512000 │      125 │
│ 512001 │      125 │
│    ... |      ... |
└────────┴──────────┘

8192 rows in set. Elapsed: 0.079 sec. Processed 100.00 million rows, 800.10 MB (1.26 billion rows/s., 10.10 GB/s.

非常に基本的なスキップインデックスを追加してみましょう:

ALTER TABLE skip_table ADD INDEX vix my_value TYPE set(100) GRANULARITY 2;

通常、スキップインデックスは新しく挿入されたデータにのみ適用されるため、インデックスを追加するだけでは上記のクエリに影響を与えません。

既に存在するデータをインデックス化するには、このステートメントを使用します:

ALTER TABLE skip_table MATERIALIZE INDEX vix;

新しく作成したインデックスでクエリを再実行します:

SELECT * FROM skip_table WHERE my_value IN (125, 700)

┌─my_key─┬─my_value─┐
│ 512000 │      125 │
│ 512001 │      125 │
│    ... |      ... |
└────────┴──────────┘

8192 rows in set. Elapsed: 0.051 sec. Processed 32.77 thousand rows, 360.45 KB (643.75 thousand rows/s., 7.08 MB/s.)

80メガバイトの100百万行を処理する代わりに、ClickHouseは32768行の360キロバイトのみを読み取り、分析しました -- それは8192行あたり4つのグラニュールです。

より視覚的な形式で、my_valueが125の4096行がどのように読み取られ、選択されたか、そしてディスクから読み取られることなく次の行がどのようにスキップされたかは以下の通りです:

Simple Skip

ユーザーは、クエリを実行する際にトレースを有効にすることにより、スキップインデックスの使用に関する詳細情報にアクセスできます。clickhouse-clientから、send_logs_levelを設定します:

SET send_logs_level='trace';

これは、クエリSQLやテーブルインデックスの調整を試みる際に役立つデバッグ情報を提供します。上記の例から、デバッグログはスキップインデックスが2つのグラニュールを除くすべてのものをドロップしたことを示しています:

<Debug> default.skip_table (933d4b2c-8cea-4bf9-8c93-c56e900eefd1) (SelectExecutor): Index `vix` has dropped 6102/6104 granules.

スキップインデックスタイプ

minmax

この軽量インデックスタイプは、パラメータを必要としません。各ブロックのインデックス式の最小および最大値を保存します(式がタプルであれば、タプルの各メンバーの値を別々に保存します)。このタイプは、値によって緩やかにソートされる傾向のあるカラムに理想的です。このインデクスタイプは、クエリ処理中に適用するコストが最も低いことが一般的です。

このタイプのインデックスは、スカラーまたはタプル式で正しく機能します -- 配列やマップデータ型を返す式には決して適用されません。

set

この軽量インデックスタイプは、ブロックごとの値セットのmax_sizeの単一パラメータを受け入れます(0は制限のない離散値の数を許可します)。このセットにはブロック内のすべての値が含まれています(または、値の数がmax_sizeを超える場合は空です)。このインデクスタイプは、各グラニュール内での低いカーディナリティを持つカラム(本質的に「塊になっている」)に対してうまく機能しますが、全体的には高いカーディナリティを持っています。

このインデックスのコスト、パフォーマンス、および効果は、ブロック内でのカーディナリティに依存します。各ブロックに多数のユニークな値が含まれている場合、クエリ条件を大きなインデックスセットに対して評価するのは非常に高価になるか、インデックスが空になるため適用されません。

ブルームフィルタータイプ

ブルームフィルターは、わずかな偽陽性の可能性を代償にして、集合メンバーシップのテストを空間的に効率よく行うことができるデータ構造です。偽陽性はスキップインデックスの場合には大きな懸念事項ではありません。なぜなら唯一の欠点は、いくつかの不要なブロックを読み込むことだからです。しかし、偽陽性の可能性があるため、インデックス式は真であることが期待されるべきです。そうでなければ、有効なデータがスキップされる可能性があります。

ブルームフィルターは、大量の離散値のテストをより効率的に処理できるため、テストする値がより多く生成される条件式に適している場合があります。特に、ブルームフィルターインデックスは配列に適用でき、配列のすべての値がテストされ、マップに対しては、mapKeysやmapValues関数を使用してキーまたは値を配列に変換します。

ブルームフィルターに基づくデータスキッピングインデックスタイプは3つあります:

  • 基本的なbloom_filter。これは、許可された「偽陽性」率の単一のオプションパラメータを受け入れます(指定がない場合は0.025が使用されます)。

  • 専門的なtokenbf_v1。これは、全てのパラメータがブルームフィルターの調整に関連する3つのパラメータを受け入れます:(1)バイト単位のフィルターのサイズ(大きなフィルターは偽陽性が少なく、ストレージのコストがかかる)、(2)適用されるハッシュ関数の数(さらに、より多くのハッシュフィルターは偽陽性を減少させます)、(3)ブルームフィルターハッシュ関数のシード。これらのパラメータがブルームフィルター機能にどのように影響するかの詳細は、計算機を参照してください こちら。 このインデックスは、String、FixedString、およびMapデータ型にのみ機能します。入力式は、非英数字の文字で区切られた文字列のシーケンスに分割されます。例えば、This is a candidate for a "full text" searchのカラム値は、トークンThis is a candidate for full text searchを含みます。このインデックスは、LIKE、EQUALS、IN、hasToken()など、長い文字列内の単語や他の値の検索に使用されることを意図しています。例えば、小数のクラス名や行番号をフリーフォームアプリケーションログラインのカラムで検索する場合に利用されるかもしれません。

  • 専門的なngrambf_v1。このインデックスは、トークンインデックスと同様に機能します。ブルームフィルター設定の前に1つの追加パラメータ、インデックスするngramのサイズを受け取ります。ngramは任意の文字の長さnの文字列です。例えば、A short stringのngramサイズが4の場合は、以下のようにインデックスされます:

'A sh', ' sho', 'shor', 'hort', 'ort ', 'rt s', 't st', ' str', 'stri', 'trin', 'ring'

このインデックスは、特に単語の区切りがない言語(例えば中国語)に対して、テキスト検索に役立つかもしれません。

スキップインデックス関数

データスキッピングインデックスの主な目的は、人気のあるクエリによって分析されるデータ量を制限することです。ClickHouseのデータの分析的性質を考慮すると、これらのクエリのパターンのほとんどは、関数式を含みます。したがって、スキップインデックスは、効率的であるために一般的な関数と正しく相互作用する必要があります。これは次のいずれかの状況で発生する可能性があります:

  • データが挿入され、インデックスが関数式として定義されている場合(式の結果がインデックスファイルに保存されます)、または
  • クエリが処理され、式が保存されたインデックス値に適用されてブロックを除外するかどうかを決定します。

各タイプのスキップインデックスは、次のインデックス実装に適したClickHouse関数のサブセットで機能します こちら にリストされています。一般的に、setインデックスとブルームフィルターベースのインデックス(別のタイプのsetインデックス)はどちらも順序付けされていないため、範囲で機能しません。それに対し、minmaxインデックスは範囲で具体的にうまく機能します。なぜなら範囲が交差するかどうかを決定するのが非常に速いからです。部分一致関数LIKE、startsWith、endsWith、hasTokenの有効性は、使用されるインデックスタイプ、インデックス式、およびデータの特定の形状に依存します。

スキップインデックス設定

スキップインデックスに適用される2つの設定があります。

  • use_skip_indexes (0または1、デフォルトは1)。すべてのクエリがスキップインデックスを効率的に使用できるわけではありません。特定のフィルタリング条件がほとんどのグラニュールを含む可能性がある場合、データスキッピングインデックスを適用することは不要であり、時には重大なコストが発生します。スキップインデックスからの利益が低いと思われるクエリには、値を0に設定します。
  • force_data_skipping_indices (カンマ区切りのインデックス名のリスト)。この設定は、一部の非効率的なクエリを防ぐために使用できます。スキップインデックスを使用することなしにテーブルをクエリすることが高コストな状況では、この設定を1つ以上のインデックス名と共に使用すると、リストに含まれないインデックスを使用しないすべてのクエリに例外が返されます。これにより、悪く書かれたクエリがサーバーリソースを消費するのを防ぐことができます。

スキップインデックスのベストプラクティス

スキップインデックスは直感的ではなく、特にRDBMS領域からのセカンダリ行ベースインデックスやドキュメントストアからの逆インデックスに慣れているユーザーにとってはそうです。何らかの利益を得るためには、ClickHouseデータスキッピングインデックスを適用することが、インデックスの計算コストを相殺するだけの十分なグラニュールの読み取りを回避する必要があります。重要なのは、インデックス化されたブロックにおいて値がたとえ一度でも出現する場合、ブロック全体をメモリに読み込み、評価しなければならないため、インデックスコストが不必要に発生することです。

次のデータ分布を考えてみましょう:

Bad Skip

主キー/順序によるキーがtimestampであり、visitor_idにインデックスがあると仮定します。次のクエリを考えます:

SELECT timestamp, url FROM table WHERE visitor_id = 1001`

このようなデータ分布の場合、従来のセカンダリインデックスは非常に有利です。要求されたvisitor_idの5行を見つけるために32768行すべてを読み込むのではなく、セカンダリインデックスはわずか5つの行位置を含み、その5つの行のみがディスクから読み取られます。ClickHouseデータスキッピングインデックスに対しては、全32768のvisitor_idカラムの値が条件にかかわらず試されるため、逆になります。

したがって、キーカラムに単にインデックスを追加することでClickHouseクエリを高速化しようとする自然な衝動は、しばしば誤りです。この高度な機能は、主キーの変更を検討した後(主キーの選び方を参照)、プロジェクションを使用するか、マテリアライズドビューを使用するなどの他の代替手段を調査した後にのみ使用されるべきです。データスキッピングインデックスが適切である場合でも、インデックスとテーブルの両方の微調整が必要になることが多いです。

ほとんどの場合、役立つスキップインデックスは、主キーとターゲットとなる非主カラム/式の間に強い相関関係が必要です。相関関係がない場合(上の図のように)、グラニュールのブロック内の数千の値のいずれかの行によってフィルタリング条件が満たされる可能性が高く、スキップされるブロックが少なくなります。対照的に、主キーの値の範囲(例えば時間帯)が、潜在的なインデックスカラムの値(例えばテレビ視聴者の年齢)と強く関連付けられている場合、minmaxタイプのインデックスが有益である可能性が高くなります。データを挿入する際に、この相関関係を高める方法として、ソート/ORDER BYキーに追加のカラムを含めるか、主キーに関連する値を挿入時にグループ化することがあります。例えば、特定のsite_idのすべてのイベントが、メインキーが多数のサイトのイベントを含むタイムスタンプであっても、インジェストプロセスによって一緒にグループ化して挿入される可能性があります。これにより、特定のsite_id値で検索する際に多くのブロックがスキップされるため、数少ないsite_idを含む多くのグラニュールが生成されることになります。

スキップインデックスのもう一つの良い候補は、高いカーディナリティの式で、データ内で任意の1つの値が比較的スパースである場合です。例えば、APIリクエストのエラーコードを追跡する可観測性プラットフォームの例かもしれません。特定のエラーコードは珍しいですが、検索の際には特に重要かもしれません。エラーコードカラムにセットスキップインデックスを設定することで、エラーを含まない大部分のブロックをバイパスし、したがってエラーに焦点を当てたクエリの性能を大幅に改善します。

最後に、キーとなるベストプラクティスは、「テスト、テスト、テスト」です。再度、b-treeセカンダリインデックスやドキュメント検索の逆インデックスとは異なり、データスキッピングインデックスの動作は容易に予測できません。テーブルに追加することは、データの取り込みやインデックスから利益を得ないさまざまな理由によるクエリに対して意味のあるコストを発生させます。実際のデータ型で必ずテストし、テストには型、グラニュラリティサイズ、および他のパラメータのバリエーションを含める必要があります。テストは、思考実験だけでは明らかでないパターンと落とし穴を明らかにすることが多いです。