Skip to main content

はじめに

ClickHouse には、リアルタイムなシナリオで大量のデータに対する分析クエリを高速化するための、さまざまな仕組みがあります。そうしたクエリ高速化の仕組みの 1 つが、プロジェクション の利用です。プロジェクションは、着目する属性に基づいてデータを並べ替えたものを作成することで、クエリの最適化に役立ちます。具体的には、次のいずれかの形を取ります。
  1. 完全な並べ替え
  2. 元のテーブルの一部を異なる順序で並べ替えたもの
  3. 事前計算された集約 (materialized view に似ています) で、集約に合わせた順序付けが されているもの

プロジェクションはどのように機能しますか?

実際のところ、PROJECTION は元のテーブルに追加される 非表示テーブルのようなものと考えられます。PROJECTION は元のテーブルとは 異なる行順序、したがって異なるプライマリインデックスを持つことができ、 さらに集計値を自動的かつ段階的に事前計算できます。その結果、プロジェクション を使うことで、 クエリ実行を高速化するための 2 つの「調整手段」が得られます。
  • プライマリインデックスを適切に活用する
  • 集計を事前計算する
プロジェクション は、ある意味で materialized view に似ています。これも複数の行順序を持てるほか、insert 時に集計を 事前計算できます。 ただし プロジェクション は自動的に更新され、 元のテーブルとの同期が保たれます。一方、materialized view は 明示的に更新する必要があります。クエリが元のテーブルを対象とすると、 ClickHouse は自動的に主キーをサンプリングし、 同じ正しい結果を生成でき、かつ読み取る必要があるデータ量が最も少ないテーブルを 以下の図に示すように選択します。

_part_offset によるより効率的なストレージ

バージョン 25.5 以降、ClickHouse はプロジェクション内の仮想カラム _part_offset をサポートしており、 これによりプロジェクションを定義する新しい方法が利用できるようになりました。 現在、プロジェクションを定義する方法は 2 つあります。
  • 完全なカラムを保存する (従来の動作) : プロジェクションに完全な データを保持し、それを直接読み取れるため、フィルタが プロジェクションのソート順に一致する場合は、より高速に処理できます。
  • ソートキー + _part_offset のみを保存する: プロジェクションは索引のように機能します。 ClickHouse はプロジェクションのプライマリインデックスを使って一致する行を特定し、実際のデータは基となるテーブルから 読み取ります。これにより、クエリ時の I/O が わずかに増える代わりに、ストレージのオーバーヘッドを削減できます。
上記のアプローチは組み合わせることもでき、一部のカラムをプロジェクションに保存し、 ほかのカラムは _part_offset を介して間接的に扱えます。

Projections を使用すべきケース

Projections は、データの挿入に応じて自動的に維持されるため、新規ユーザーにとって魅力的な機能です。さらに、クエリは単一のテーブルに送るだけでよく、可能な場合は projections が活用されて応答時間の短縮につながります。 これは materialized view とは対照的です。materialized view では、ユーザーはフィルターに応じて適切に最適化されたターゲットテーブルを選択するか、クエリを書き換える必要があります。そのため、ユーザーアプリケーション側の負担が大きくなり、クライアント側の複雑さも増します。 このような利点がある一方で、projections にはいくつか本質的な制約もあるため、それを理解したうえで限定的に使用すべきです。
  • Projections では、ソーステーブルと (隠し) ターゲットテーブルに異なる有効期限 (TTL) を設定できませんが、 materialized view では異なる有効期限 (TTL) を設定できます。
  • Projections を持つテーブルでは、論理更新と削除はサポートされません。
  • materialized view は連鎖できます。つまり、ある materialized view のターゲットテーブルを別の materialized view のソーステーブルにする、といった構成が可能です。これは Projections ではできません。
  • Projection の定義では JOIN はサポートされませんが、materialized view ではサポートされます。ただし、Projections を持つテーブルに対するクエリでは JOIN を自由に使用できます。
  • Projection の定義ではフィルター (WHERE clause) はサポートされませんが、materialized view ではサポートされます。ただし、Projections を持つテーブルに対するクエリでは自由にフィルタリングできます。
以下のような場合には、Projections の使用を推奨します。
  • データを完全に再並べ替える必要がある場合です。理論上、projection 内の式で GROUP BY, を使用することもできますが、集計の維持には materialized view のほうが 効果的です。また、クエリオプティマイザーも、単純な再並べ替えを行う projection、 すなわち SELECT * ORDER BY x を使用する projection を活用しやすい傾向があります。 この式では、ストレージ使用量を抑えるためにカラムの一部だけを選択できます。
  • ストレージ使用量が増える可能性と、 データを二重に書き込むオーバーヘッドを許容できる場合です。挿入速度への影響を検証し、 ストレージのオーバーヘッドを評価してください。

例

主キーに含まれていないカラムでのフィルタリング

この例では、テーブルにプロジェクションを追加する方法を紹介します。 また、プロジェクションを使って、テーブルの主キーに含まれていないカラムで フィルタするクエリを高速化する方法も見ていきます。 この例では、sql.clickhouse.com で利用できる New York Taxi Data データセットを使用します。このデータセットは pickup_datetime で並べ替えられています。 まず、乗客がドライバーに $200 を超えるチップを支払ったすべての trip ID を見つける シンプルなクエリを書いてみましょう。 ORDER BY に含まれていない tip_amount でフィルタしているため、ClickHouse は テーブル全体をスキャンする必要があります。このクエリを高速化してみましょう。 元のテーブルと結果を保持するため、新しいテーブルを作成し、INSERT INTO SELECT を使ってデータをコピーします。
プロジェクションを追加するには、ALTER TABLEステートメントとADD PROJECTION ステートメントを使用します。
プロジェクションを追加した後は、その中のデータを物理的に並べ替えて書き換え、上記で指定したクエリに従わせるために、MATERIALIZE PROJECTION ステートメントを使用する必要があります。
projection を追加したので、もう一度クエリを実行してみましょう。 クエリ時間が大幅に短縮され、スキャンする行数も 少なくなっていることがわかります。 また、作成した projection が実際に上記のクエリで使用されたことは、 system.query_log テーブルにクエリを実行することで確認できます。

PROJECTIONを使用して UK price paid クエリを高速化する

PROJECTIONを使ってクエリパフォーマンスをどのように向上できるかを示すために、実際のデータセットを使った例を見てみましょう。この例では、3,003 万行を含む UK Property Price Paid チュートリアルのテーブルを使用します。このデータセットは、sql.clickhouse.com 環境でも利用できます。 テーブルがどのように作成され、データが挿入されたかを確認したい場合は、“The UK property prices dataset” ページを参照してください。 このデータセットに対して、2 つのシンプルなクエリを実行できます。1 つ目は、ロンドンで最も高額で取引された county を一覧表示し、2 つ目は county ごとの平均価格を計算します。 どちらのクエリも非常に高速ですが、テーブル作成時の ORDER BY ステートメントに town も price も含まれていなかったため、いずれのクエリでも 3,003 万行すべてに対するフルテーブルスキャンが発生している点に注目してください。
PROJECTIONを使って、このクエリを高速化できるか見てみましょう。 元のテーブルと結果を保持するため、新しいテーブルを作成し、INSERT INTO SELECT を使ってデータをコピーします:
prj_oby_town_price というPROJECTIONを作成してデータを投入します。これにより、 町名と価格で並べ替えたプライマリインデックスを持つ追加の (非表示の) テーブルが生成され、 特定の町における最高価格の取引について郡を一覧表示するクエリを最適化できます。
mutations_sync 設定は、 同期実行を強制するために使用されます。 PROJECTION prj_gby_county を作成してデータを投入します。これは追加の (非表示の) テーブルであり、 英国にある既存の130のすべてのカウンティについて、avg(price) の集計値を段階的に事前計算します。
上記の prj_gby_county PROJECTIONのように、PROJECTIONで GROUP BY 句が使われている場合、 (隠し) テーブルの基盤となるストレージエンジンは AggregatingMergeTree となり、すべての集約関数は AggregateFunction に変換されます。これにより、データの増分集約が適切に行われます。
以下の図は、メインテーブル uk_price_paid_with_projections と、その 2 つのPROJECTIONを可視化したものです。 ここで、ロンドンの county のうち価格が最も高い 3 件を 一覧表示するクエリを再度実行すると、クエリパフォーマンスが改善していることがわかります。 同様に、平均支払価格が最も高いイギリスの county を 3 件 一覧表示するクエリでも改善が見られます。 両方のクエリはいずれも元のテーブルを対象としており、2 つのプロジェクションを作成する前は、どちらのクエリでもフルテーブルスキャンが発生していたことに注意してください (3,003 万行すべてがディスクからストリーミングされました) 。 また、支払価格が最も高い 3 件について London の counties を一覧表示するクエリでは、217 万行がストリーミングされている点にも注意してください。このクエリ用に最適化した 2 つ目のテーブルを直接使用した場合は、ディスクからストリーミングされたのは 8.192 万行だけでした。 この違いが生じる理由は、前述の optimize_read_in_order 最適化が、現時点ではプロジェクションではサポートされていないためです。 system.query_log テーブルを調べると、ClickHouse が上記 2 つのクエリに対して 2 つのプロジェクションを自動的に使用していたことがわかります (下の projections カラムを参照) :

さらに例を見ていきます

以下の例では、同じ英国の価格データセットを使用し、projections を使用する場合としない場合のクエリを比較します。 元のテーブル (およびパフォーマンス) を維持するため、ここでも CREATE AS と INSERT INTO SELECT を使用してテーブルのコピーを作成します。

PROJECTIONを作成する

year、district、town の各次元で集計PROJECTIONを作成しましょう。 このPROJECTIONは各販売の年ごとに売上をグループ化するため、その式をカラムとしてマテリアライズし、フィルタから参照できるようにします。
既存のデータについてPROJECTIONを作成します。 (これをマテリアライズしない場合、PROJECTIONが作成されるのは新たに挿入されたデータのみです) :
以下のクエリでは、PROJECTIONありの場合となしの場合で、パフォーマンスを比較します。PROJECTIONの使用を無効にするには、デフォルトで有効になっている設定 optimize_use_projections を使用します。

クエリ 1. 年ごとの平均価格

結果は同じですが、後者の例のほうがパフォーマンスは向上します。

クエリ 2. ロンドンの年別平均価格

クエリ 3. 最も高額な地域

2020 年以降の行に対するフィルタは、PROJECTION に保存されている 次元 year を直接参照します。 今回も結果は同じですが、2 番目のクエリではクエリパフォーマンスが向上していることがわかります。
フィルタは、保存されている 次元 year を直接参照する必要があります。 WHERE toYear(date) >= 2020 と書いた場合、PROJECTION は使用されません。optimizer が この条件を date に対する range に書き換える一方で、PROJECTION には date カラムが保存されていないため、ClickHouse は基となるテーブルの scan にフォールバックします。

1つのクエリで複数のプロジェクションを組み合わせる

バージョン 25.6 以降、前バージョンで導入された _part_offset サポートを基盤として、ClickHouse では複数のフィルターを含む1つのクエリの高速化に複数のプロジェクションを利用できるようになりました。 重要なのは、ClickHouse は引き続き 1 つのプロジェクション (または基となるテーブル) からしかデータを読み取りませんが、読み取り前に不要なパーツを絞り込むために、ほかのプロジェクションのプライマリインデックスを利用できるという点です。 これは特に、複数のカラムでフィルタリングするクエリで有効です。各カラムがそれぞれ異なるプロジェクションに対応している可能性があるためです。
現在、この仕組みで絞り込めるのはパーツ全体のみです。グラニュール単位の絞り込みは、まだサポートされていません。
これを示すため、 (_part_offset カラムを使うプロジェクションを含む) テーブルを定義し、上の図に対応する 5 つの行の例を挿入します。
次に、テーブルにデータを挿入します。
注: このテーブルでは説明のため、1 行ごとの granule や無効化した part merge などのカスタム設定を使用していますが、これらは本番環境では推奨されません。
この構成では、次のようになります。
  • 5 つの個別のパーツ (挿入した各行につき 1 つ)
  • 各行に対して 1 つのプライマリインデックスのエントリ (基となるテーブルと各プロジェクション)
  • 各パーツにはちょうど 1 行だけが含まれる
この構成で、region と user_id の両方で絞り込むクエリを実行します。 基となるテーブルのプライマリインデックスは event_date と id から構築されているため、 この場合は役に立ちません。そのため ClickHouse は次を使用します。
  • region_proj を使って region でパーツを絞り込む
  • user_id_proj を使って user_id でさらに絞り込む
この動作は EXPLAIN projections = 1 を使うと確認でき、 ClickHouse がどのようにプロジェクションを選択して適用するかが示されます。
EXPLAIN の出力 (上記参照) には、論理クエリプランが上から下の順に示されています。 最終的に、基となるテーブルから読み取られるのは 5 つのパーツのうち 1 つだけです。 複数のプロジェクションの索引解析を組み合わせることで、ClickHouse はスキャン対象のデータ量を大幅に削減し、 ストレージのオーバーヘッドを低く抑えながらパフォーマンスを向上させます。
最終更新日 2026年9月28日