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

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

はじめに

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

しかしながら、いくら主キーを慎重に調整しても、効率的に利用できないクエリのユースケースは必然的に存在します。ユーザーは通常、タイムシリーズ型データにClickHouseを利用していますが、しばしば顧客ID、ウェブサイトURL、製品番号などの他のビジネスディメンションに基づいて同じデータを分析したいと考えます。その場合、WHERE句条件を適用するために各カラム値のフルスキャンが必要になる可能性があるため、クエリパフォーマンスがかなり低下することがあります。そのような状況でもClickHouseは比較的速いですが、数百万または数十億の個々の値を評価することは、「インデックスされていない」クエリが主キーに基づくクエリよりもかなり遅く実行される原因となります。

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

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

基本操作

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

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

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

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

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

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

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

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

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

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

ClickHouseは100百万行の800メガバイトを処理するのではなく、32768行の360キロバイトだけを読み取り、分析しています -- それぞれ8192行の4つの粒子です。

より視覚的な形では、my_valueが125の4096行がどのように読み取られ、選択されたか、以下の行がどのようにスキップされたかが示されています:

my_key columnmy_valuecolumnGranule 61 8192 rows Granule 62 8192 rows Granule 63 8192 rows Granule 64 8192 rows 507904 ...516095524288 ...532479516096...524287516096...524288125124126128130127129131Skip IndexBlock of Two GranulesSkip IndexBlock of Two Granulesindex calc is set of (124, 125,126, 127) so blocknot skippedset of (128, 129,130, 131) so blockskipped

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

これにより、クエリSQLやテーブルインデックスを調整する際に有用なデバッグ情報が提供されます。上記の例から、デバッグログはスキップインデックスが6102/6104のグラニュールをドロップしたことを示しています:

スキップインデックスの種類

minmax

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

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

set

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

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

ブルームフィルタタイプ

ブルームフィルタは、セットメンバーシップの空間効率的なテストを可能にするデータ構造で、わずかな確率で偽陽性を伴います。スキップインデックスの場合、偽陽性はあまり重要ではありません。なぜなら、唯一の欠点は不要なブロックをいくつか読み取ることだからです。ただし、偽陽性の可能性があるため、インデックスされた式が真であることを期待する必要があります。そうしないと、有効なデータがスキップされる可能性があります。

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

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

  • 基本のbloom_filterは、0から1の間の許容される「偽陽性」率の単一のオプショナルパラメータを取ります(指定されていない場合、.025が使用されます)。

  • 専用のtokenbf_v1。これは3つのパラメータを必要とし、すべては使用されるブルームフィルタを調整することに関連しています:(1)フィルタのバイト単位のサイズ(大きなフィルタは、ストレージコストがかかるため、偽陽性が少なくなります)、(2)適用されるハッシュ関数の数(再度、多くのハッシュフィルタは偽陽性を減らします)、及び(3)ブルームフィルタのハッシュ関数のシード。このパラメータに関しては、詳細を確認するには、こちらの計算機を参照してください here。このインデックスは、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。このインデックスは、トークンインデックスと同様に機能します。ブルームフィルタの設定の前に、インデックスするngramのサイズという1つの追加パラメータを取ります。ngramは、任意の文字の長さnの文字列のことです。たとえば、A short stringをngramサイズ4でインデックスすると、次のようになります:

このインデックスは特に、単語の区切りがない言語(たとえば中国語)のテキスト検索に役立つことがあります。

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

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

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

各タイプのスキップインデックスは、インデックス実装に適したClickHouseの関数のサブセットで機能します。これに関しては、hereに記載されています。一般的に、setインデックスとブルームフィルタベースのインデックス(別のタイプのsetインデックス)はどちらも無秩序であるため、範囲では機能しません。対照的に、minmaxインデックスは、範囲の交差を判断するのが非常に速いので、特に範囲と相性が良いです。部分一致関数LIKE、startsWith、endsWith、およびhasTokenの効果は、使用されるインデックスタイプ、インデックス式、およびデータの特定の形状に依存します。

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

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

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

スキップのベストプラクティス

スキップインデックスは直感的ではなく、特にRDMSの領域からのセカンダリ行ベースのインデックスやドキュメントストアの逆インデックスに慣れたユーザーには理解しづらいものです。不利益を得るためには、ClickHouseのデータスキッピングインデックスを適切に適用して、インデックスを計算するコストを相殺するために十分なグラニュールの読み取りを避ける必要があります。重要なことに、インデックス付きブロック内で値が1回でも発生すると、そのブロック全体をメモリに読み込んで評価する必要があり、インデックスコストが不必要に発生します。

以下のデータ分布を考えます:

timestamp columnvisitor_idcolumnurlcolumnGranule 4072 8192 rows Granule 4073 8192 rows Granule 4074 8192 rows Granule 4075 8192 rows 2022-02-07 15:00:002022-02-07 16:00:002022-02-07 17:00:002022-02-07 18:00:0010011001100110011001

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

この種のデータ分布では、従来のセカンダリ・インデックスが非常に有利です。必要なvisitor_idを持つ5行を見つけるために32768行をすべて読み込む代わりに、セカンダリインデックスはわずか5行の場所を含み、その5行だけがディスクから読み込まれます。ClickHouseのデータスキッピングインデックスでは、全く逆のことが言えます。visitor_idカラムの32768の値は、スキップインデックスのタイプに関係なくすべてテストされます。

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

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

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

最後に、重要なベストプラクティスは、テスト、テスト、テストです。再度言いますが、b-treeセカンダリインデックスやドキュメントを検索するための逆インデックスとは異なり、データスキッピングインデックスの動作は簡単には予測できません。それらをテーブルに追加することは、データの取り込みと、さまざまな理由でインデックスから利益を得ないクエリに対して意義あるコストを伴います。実世界のデータタイプで常にテストされるべきであり、テストはタイプ、粒度サイズ、その他のパラメータのバリエーションを含むべきです。テストによって、思考実験だけでは明らかでないパターンや落とし穴が明らかになることがよくあります。