ClickHouse データスキッピングインデックスの理解
はじめに
多くの要因が ClickHouse のクエリ性能に影響を与えます。ほとんどのシナリオで重要な要素は、ClickHouse がクエリの WHERE 句の条件を評価する際に主キーを使用できるかどうかです。それに応じて、最も一般的なクエリパターンに適用される主キーを選択することは、効果的なテーブル設計のために不可欠です。
しかし、どんなに慎重に調整された主キーであっても、効率的に使用できないクエリのユースケースが必然的に存在します。ユーザーは一般的に時間系列データを扱いますが、顧客 ID、ウェブサイトの URL、製品番号など、他のビジネス次元に基づいて同じデータを分析したいと考えることがよくあります。その場合、WHERE 句の条件を適用するために、各カラムの値をフルスキャンする必要があるため、クエリのパフォーマンスが大幅に悪化する可能性があります。ClickHouse はそれでも比較的高速ですが、数百万または数十億の個々の値を評価することは、「インデックス未使用」のクエリが主キーに基づくクエリよりもはるかに遅く実行される原因となります。
従来の関係データベースでは、この問題へのアプローチの一つは、テーブルに一つ以上の「セカンダリ」インデックスを追加することです。これは 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 はインデックスファイルデータを使用して、各関連データブロックを処理する必要があるか、バイパスできるかを判断します(ブロックが主キーの適用によってすでに除外されていないと仮定)。非常に単純化された例を考えてみましょう。予測可能なデータでロードされた以下のテーブルです。
主キーを使用しないシンプルなクエリを実行すると、my_value
カラムの 1 億件のエントリ全てがスキャンされます:
非常に基本的なスキップインデックスを追加します:
通常、スキップインデックスは新しく挿入されたデータのみに適用されるため、インデックスを追加しただけでは上記のクエリには影響しません。
既存のデータにインデックスを付与するには、このステートメントを使用します:
新しく作成されたインデックスを使用してクエリを再実行します:
ClickHouse は 800 メガバイトの 1 億行を処理する代わりに、32768 行の 360 キロバイトのみを読み取って分析しました -- 8192 行ずつの 4 つのグラニュールです。
よりビジュアルな形で、my_value
が 125 の 4096 行がどのように読み取られ選択されたか、そして次の行がディスクから読み込むことなくスキップされたかの様子を示しています:

ユーザーは、クエリを実行する際にトレースを有効にすることによって、スキップインデックス使用に関する詳細情報にアクセスできます。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) ブルームフィルターのハッシュ関数用のシード。これらのパラメータがブルームフィルターの機能にどのように影響するかについては、こちらの計算機を参照してください。このインデックスは、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。このインデックスは token インデックスと同じように機能します。ブルームフィルター設定の前に 1 つの追加パラメーター、インデックス化される ngram のサイズを取ります。ngram は、任意の文字の長さ
n
の文字列です。したがって、A short string
の ngram サイズ 4 では、次のようにインデックス化されます:
このインデックスは、単語の区切りがない言語、たとえば中国語などのテキスト検索に特に役立つ場合があります。
スキップインデックス関数
データスキッピングインデックスの核心的な目的は、一般的なクエリによって分析されるデータの量を制限することです。ClickHouse データの分析的性質を考慮すると、これらのクエリのパターンのほとんどは関数式を含みます。したがって、スキップインデックスは共通の関数と正しく相互作用しなければ効率的ではありません。これは次のいずれかで発生します:
- データが挿入され、インデックスが関数式として定義される(式の結果がインデックスファイルに格納される)、または
- クエリが処理され、式が格納されたインデックス値に適用されてブロックを除外するかどうかを決定します。
各タイプのスキップインデックスは、こちらにリストされているインデックス実装に適した ClickHouse の利用可能な関数のサブセットで機能します。一般に、セットインデックスとブルームフィルターに基づくインデックス(別のタイプのセットインデックス)はともに無順序であり、したがって範囲では機能しません。それに対して、minmax インデックスは範囲に非常に適しており、範囲が交差するかどうかを決定する速度が非常に速いです。部分一致関数 LIKE、startsWith、endsWith、hasToken の有効性は、使用されるインデックスタイプ、インデックス式、およびデータの特定の形状に依存します。
スキップインデックス設定
スキップインデックスに適用できる 2 つの設定があります。
- use_skip_indexes (0 または 1、デフォルトは 1)。すべてのクエリがスキップインデックスを効率的に使用できるわけではありません。特定のフィルタリング条件がほとんどのグラニュールを含む可能性がある場合、データスキッピングインデックスを適用することは不要な、時には重要なコストを伴います。スキップインデックスから利益を得る可能性が低いクエリには、0 に設定してください。
- force_data_skipping_indices (カンマ区切りのインデックス名リスト)。この設定は、非効率的なクエリの一部を防ぐために使用できます。スキップインデックスなしではテーブルをクエリに必要以上のコストがかかる場合、この設定を 1 つ以上のインデックス名で使用すると、リストされたインデックスを使用しないいかなるクエリにも例外が返されます。これにより、悪意のあるクエリがサーバーリソースを消費するのを防ぐことができます。
スキップベストプラクティス
スキップインデックスは直感的ではなく、特に RDMS 領域からのセカンダリ行ベースインデックスやドキュメントストアからの逆インデックスに慣れたユーザーにとってはそうです。利益を得るには、ClickHouse のデータスキッピングインデックスは、インデックス計算のコストを相殺するだけの十分なグラニュール読み取りを回避する必要があります。特に、ある値がインデックス化されたブロック内に1度でも出現する場合、そのブロック全体がメモリに読み込まれ評価される必要があるため、インデックスコストが不必要に発生します。
以下のデータ分布を考えてみましょう:

主キー/順序付けキーが timestamp
であり、visitor_id
にインデックスがあると仮定します。次のクエリを考えてみましょう:
この種のデータ分布では、従来のセカンダリインデックスが非常に有利です。要求された visitor_id を持つ 5 行を見つけるためにすべての 32768 行を読む代わりに、セカンダリインデックスは単に 5 つの行の位置を含むだけで、その 5 行のみがディスクから読み取られます。ClickHouse のデータスキッピングインデックスではその正反対のことが当てはまります。いかなるスキップインデックスのタイプに関わらず、visitor_id
カラム内のすべての 32768 値がテストされます。
したがって、主キーのカラムに単にインデックスを追加することで ClickHouse のクエリを高速化しようとする自然な衝動は、しばしば誤りです。この高度な機能は、主キーの変更(主キーの選び方を参照)、プロジェクションの使用、またはマテリアライズドビューの使用など、他の代替案を調査した後にのみ使用すべきです。データスキッピングインデックスが適切である場合でも、インデックスとテーブルの両方を慎重に調整する必要があることがよくあります。
ほとんどのケースでは、有用なスキップインデックスは主キーとターゲットにした非主キーのカラム/式の間に強い相関関係が必要です。相関がない場合(上の図のように)、数千の値のブロック内の少なくとも 1 つの行によってフィルタリング条件が満たされる可能性が高く、少なくとも多くのブロックがスキップされることはありません。対照的に、主キーの値の範囲(例えば、1日の時間)が潜在的なインデックスカラムの値(例えば、テレビ視聴者の年齢)と強く関連付けられている場合、minmax タイプのインデックスは有益である可能性が高いです。データを挿入する際に、主キーの順序付けキーに追加のカラムを含めるか、主キーに関連する値が挿入時にグループ化されるようにバッチ挿入することで、この相関を高めることが可能かもしれません。たとえば、特定の site_id のすべてのイベントをインジェストプロセスによって一緒にグループ化して挿入することができます。これは、多くの granules を生成し、特定の site_id 値を検索する際に多くのブロックをスキップできる結果となります。
スキップインデックスのもう一つの良い候補は、任意の値がデータ内で比較的スパースである高いカーディナリティの式です。たとえば、ある API リクエストにおけるエラーコードを追跡する可視化プラットフォームなどの例が考えられます。特定のエラーコードはデータ内で稀であっても、検索には特に重要かもしれません。エラーコードカラムのセットスキップインデックスを使用することで、エラーを含まない大部分のブロックをスキップし、エラー関連のクエリの性能を大幅に向上させることが可能です。
最後に、重要なベストプラクティスは、テストを繰り返すことです。再度、b-tree セカンダリインデックスやドキュメントを検索するための逆インデックスとは異なり、データスキッピングインデックスの動作は予測が容易ではありません。テーブルに追加すると、データの取り込みとインデックスから恩恵を受けないクエリの両方において意味のあるコストが発生します。実世界のデータ型で常にテストし、テストには型、グラニュラリティサイズやその他のパラメータのバリエーションを含むべきです。テストはしばしば、考察だけでは明らかでないパターンや落とし穴を明らかにします。