はじめに
ClickHouse の概念
ClickHouse では、テーブルはデータベースに属します。デフォルトでは
default データベースが使用されますが、これは OpenTelemetry collector で変更できます。
最低限、次の ClickHouse の基本事項を理解しておく必要があります。
これらの概念は、ClickHouse のパフォーマンスの中核を成します。データがどのように書き込まれるか、ディスク上でどのように構成されるか、そしてクエリ時に ClickHouse がどれだけ効率的に不要なデータの読み取りをスキップできるかは、これらによって決まります。このガイドで扱うあらゆる最適化、たとえば マテリアライズドカラム、スキップ索引、主キー、projections、materialized view は、こうした基本的な仕組みの上に成り立っています。
チューニングを始める前に、以下の ClickHouse ドキュメントを確認しておくことをお勧めします。
- ClickHouse でのテーブル作成 - テーブルの簡単な入門です。
- Parts
- Partitions
- Merges
- 主キー/索引
- ClickHouse がデータを保存する方法: parts と グラニュール - ClickHouse でデータがどのように構成され、どのようにクエリされるかを詳しく説明した、より高度なガイドです。グラニュール と主キーも詳しく扱っています。
- MergeTree- コマンドや内部仕様の理解に役立つ高度な MergeTree リファレンスガイドです。
最適化 1. 頻繁にクエリされる属性をマテリアライズする
LogAttributes、ScopeAttributes、ResourceAttributes 内で頻繁にクエリされる属性を特定し、マテリアライズドカラムを使ってそれらをトップレベルのカラムに昇格させることです。
この最適化だけでも、ClickStack のデプロイメントを 1 日あたり数十テラバイト規模までスケールさせるのに十分な場合が多く、より高度なチューニング手法を検討する前に適用すべきです。
属性をマテリアライズする理由
Map(String, String) カラムに格納します。これは柔軟性が高い一方で、マップのサブキーをクエリする際にはパフォーマンス上の重要な影響があります。
Map カラムから単一のキーをクエリする場合、ClickHouse はディスクからマップカラム全体を読み込む必要があります。マップに多数のキーが含まれていると、専用のカラムを読み込む場合に比べて不要な IO が発生し、クエリも遅くなります。
頻繁にアクセスされる属性をマテリアライズすると、挿入時に値を抽出して独立したカラムとして保存できるため、このオーバーヘッドを回避できます。
マテリアライズドカラムの特長:
- 挿入時に自動的に計算される
- INSERT ステートメントで明示的に設定できない
- 任意の ClickHouse 式をサポートする
- String から、より効率的な数値型や日付型へ型変換できる
- スキップ索引と主キーを利用できる
- マップ全体へのアクセスを避けることでディスク読み取りを削減できる
ClickStack は、マップから抽出されたマテリアライズドカラムを自動的に検出し、ユーザーが元の属性パスを引き続きクエリしている場合でも、クエリ実行時に透過的にそれらを使用します。
例
ResourceAttributes に格納される、トレース向けのデフォルトの ClickStack スキーマを見てみましょう。
ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c" のように指定します。
その結果、次のようなSQL述語になります。
ResourceAttributes カラム全体を読み取る必要があります。Map に多くのキーが含まれている場合、このカラムは非常に大きくなる可能性があります。
この属性を頻繁にクエリする場合は、トップレベルのカラムとしてマテリアライズする必要があります。
挿入時にポッド名を抽出するには、マテリアライズドカラムを追加します:
PodName として保存されます。
これにより、ユーザーは Lucene 構文を使ってポッド名を効率よくクエリできるようになります。たとえば PodName:"checkout-675775c4cc-f2p9c" のように指定できます。
新たに挿入されるデータでは、これにより map へのアクセスが不要になり、I/O も大幅に削減されます。
ただし、ユーザーが元の属性パス、たとえば ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c" に対して引き続きクエリを実行する場合でも、ClickStack は内部的にクエリを自動的に書き換え、マテリアライズされた PodName カラムを使用します。つまり、次の条件を使用します。
デフォルトでは、マテリアライズドカラムは
SELECT * クエリの対象から除外されます。これにより、クエリ結果を常にテーブルへ再挿入できるという不変条件が保たれます。過去データのマテリアライズ
system.mutations テーブルで確認できます。
is_done = 1 になるまで待機します。
最適化 2. スキップ索引の追加
- TraceId、セッション識別子、attribute キー、値など、カーディナリティの高い文字列のフィルタリング
- スパンの所要時間など、数値範囲のフィルタリング
ブルームフィルタ
PodName:"checkout-675775c4cc-f2p9c" のようなクエリで読み取るデータ量を削減できる可能性があります。
ブルームフィルタが最も効果を発揮するのは、特定の値が比較的少数のパーツにしか現れないような値の分布になっている場合です。これは、ポッド名、トレースID、セッション識別子のようなメタデータが時間と相関し、その結果、テーブルの順序キーに沿ってクラスター化されるオブザーバビリティのワークロードで自然によく見られます。
すべてのスキップ索引と同様に、ブルームフィルタも選択的に追加し、実際のクエリパターンに対して検証して、測定可能な効果が得られることを確認する必要があります。詳細は”スキップ索引の有効性を評価する”を参照してください。
Min-max 索引
SpanAttributes 内の Kafka オフセットが頻繁にクエリされるとします。
スキップ索引をマテリアライズする
スキップ索引のマテリアライズスキップ索引のマテリアライズは、通常は軽量で安全に実行できます。特に minmax 索引ではその傾向が顕著です。大規模なデータセットに対する ブルームフィルタ 索引では、リソース使用量をより適切に制御するため、パーティション単位でマテリアライズするほうがよい場合があります。例:
is_done = 1 になるまで待ちます。
完了したら、索引データが作成されていることを確認します。
0.01 から 0.05 に引き上げると、より小さく、より高速に評価できる索引になりますが、その代わりプルーニングの効果は弱まります。スキップできるグラニュールは少なくなる可能性がありますが、索引評価が速くなることで、クエリ全体のレイテンシが改善する場合があります。
したがって、ブルームフィルタ パラメータの調整はワークロード依存の最適化であり、実際のクエリパターンと本番環境に近いデータ量で検証する必要があります。
スキップ索引の詳細については、ガイド “ClickHouse のデータスキッピングインデックスを理解する” を参照してください。
スキップ索引の有効性を評価する
EXPLAIN indexes = 1 を使用することです。これにより、クエリプランの各段階で、どれだけの パーツ と グラニュール が除外されるかを確認できます。多くの場合、主キーによって検索範囲がすでに絞り込まれたあとに、Skip ステージで グラニュール が大幅に減っているのが理想です。スキップ索引はパーティションプルーニングと主キープルーニングのあとに評価されるため、その効果は、残ったパーツと グラニュール に対してどれだけ削減できたかで見るのが最も適切です。
EXPLAIN を使えばプルーニングが実際に行われているかは確認できますが、それだけで全体として高速化されるとは限りません。スキップ索引の評価にはコストがかかり、特に索引が大きい場合はその影響が大きくなります。実際に性能が向上していることを確認するため、索引を追加してマテリアライズする前後で、必ずクエリをベンチマークしてください。
たとえば、デフォルトの Traces スキーマに含まれる、TraceId 用のデフォルトのブルームフィルタースキップ索引を見てみましょう。
EXPLAIN indexes = 1 を使うと確認できます:
FORMAT Null を使用し、実行結果の再現性を保つためにクエリ条件 cache を無効にします:
use_query_condition_cache を無効にすると、キャッシュされたフィルタリング判定の影響を結果が受けないようにでき、use_skip_indexes = 0 を設定すると、比較のためのクリーンなベースラインを確保できます。絞り込みが効果的で、かつ索引の評価コストが低い場合は、上の例のように、索引付きクエリのほうが大幅に高速になるはずです。
スキップ索引を追加するタイミング
最適化 3. 主キーの変更
用語に関する補足このドキュメント全体では、「ソートキー」という用語を「主キー」と同じ意味で使っています。厳密には ClickHouse では両者は異なりますが、ClickStack では通常、テーブルの
ORDER BY 句で指定される同じカラムを指します。詳細は、ソートキーと異なる主キーの選び方についての ClickHouse ドキュメント を参照してください。- ログ (
otel_logs) -(ServiceName, TimestampTime, Timestamp) - トレース (‘otel_traces) -
(ServiceName, SpanName, toDateTime(Timestamp))
主キーの選択
デフォルトの主キーの変更デフォルトの主キーは、ほとんどのケースで十分です。変更は慎重に行い、クエリパターンを明確に理解したうえでのみ実施してください。主キーを変更すると、他のワークフローのパフォーマンスが低下する可能性があるため、テストは不可欠です。
- 一般的なフィルタ条件やアクセスパターンに合うカラムを選択します。たとえば、オブザーバビリティの調査を通常は特定のカラム (例: ポッド名) で絞り込むことから始める場合、そのカラムは
WHERE句で頻繁に使用されます。使用頻度の低いカラムよりも、こうしたカラムを優先してキーに含めてください。 - フィルタ時に全行の大部分を除外できるカラムを優先します。これにより、読み込む必要のあるデータ量を減らせます。サービス名やステータスコードは有力な候補になることがよくあります。後者については、大半の行を除外できる値でフィルタする場合に限ります。たとえば、多くのシステムでは 200 コードでフィルタすると大半の行に一致しますが、500 エラーであれば一致するのは小さな部分集合です。
- テーブル内の他のカラムと高い相関がある可能性の高いカラムを優先します。これにより、それらの値も連続して格納されやすくなり、圧縮の改善につながります。
- ソートキーに含まれるカラムに対する
GROUP BY(チャート用の集計) およびORDER BY(ソート) の操作は、メモリ効率が向上する場合があります。
主キーの変更
ServiceName の前に SeverityText カラムを含む新しい主キーを持つログテーブルを簡単に作成する方法を示しています。
1
新しいテーブルを作成する
ORDER BY と主キー上記の例では、
PRIMARY KEY と ORDER BY の両方を指定する必要があります。
ClickStack では、これらはほとんどの場合同じです。
ORDER BY は物理的なデータレイアウトを制御し、PRIMARY KEY はスパースインデックスを定義します。
まれに、非常に大規模なワークロードでは両者が異なることもありますが、ほとんどのユーザーは一致させておくべきです。既存データを新しいテーブルに backfill することは、大規模環境ではめったに見合いません。通常はコンピュートと IO のコストが高く、性能向上のメリットに見合わないためです。代わりに、古いデータは有効期限 (TTL)で期限切れになるままにし、新しいデータが改善されたキーの恩恵を受けるようにしてください。
SeverityText を導入する同じ例を使用します。この場合は、新しいデータ用のテーブルを作成し、履歴分析のために古いテーブルは保持します。
1
2
Merge テーブルを作成する
Merge エンジン (MergeTree と混同しないでください) は、それ自体ではデータを保存しませんが、複数の別テーブルを同時に読み取れるようにします。currentDatabase() は、このコマンドが正しいデータベースで実行されることを前提としています。そうでない場合は、データベース名を明示的に指定してください。otel_logs からデータが返ることを確認できます。3
HyperDX が Merge テーブルを参照するように更新する
ログのログソースで使用するテーブルとしてotel_logs_merge を使うよう HyperDX を設定します。この時点では、書き込みは元の主キーのまま otel_logs に継続され、一方で読み取りは Merge テーブルを使用します。ユーザーに見える変更はなく、インジェストへの影響もありません。4
テーブルを入れ替える
次に、EXCHANGE ステートメントを使って otel_logs テーブルと otel_logs_23_01_2025 テーブルの名前をアトミックに入れ替えます。otel_logs テーブルに送られます。既存データは otel_logs_23_01_2025 に残り、Merge テーブル経由で引き続きアクセスできます。この接尾辞は変更が適用された日付を示しており、そのテーブルに含まれる最新の timestamp を表します。この手順により、インジェストを中断せず、ユーザーに見える影響もなく主キーを変更できます。SeverityText ではなく SeverityNumber を主キーに含めるべきだと判断した場合です。以下の手順は、主キーの変更が必要になるたびに何度でも繰り返し応用できます。
1
2
テーブルを入れ替える
ここでは、EXCHANGE ステートメントを使用して、otel_logs テーブルと otel_logs_30_01_2025 テーブルの名前をアトミックに入れ替えます。otel_logs テーブルに対して行われます。古いデータは otel_logs_30_01_2025 に残り、merge テーブル経由で引き続きアクセスできます。不要になったテーブル有効期限 (TTL) ポリシーを設定している場合 (推奨) 、書き込みを受けなくなった古い主キーのテーブルは、データの有効期限切れに伴って徐々に空になります。これらのテーブルは監視し、データがなくなった段階で定期的にクリーンアップする必要があります。現時点では、このクリーンアップは手動で行います。
最適化 4. materialized view の活用
最適化 5. PROJECTION の活用
ORDER BY キーとは異なる独自のプライマリインデックスを持ち、元の並び順に合わないアクセスパターンに対しても、ClickHouse がより効率よくデータを絞り込めるようになります。
materialized view でも、異なる並び替えキーを持つ別のターゲットテーブルに行を明示的に書き込むことで、同様の効果を得られます。重要な違いは、PROJECTION は ClickHouse によって自動かつ透過的に維持される のに対し、materialized view は ClickStack が意図的に登録し、選択して使う必要のある明示的なテーブルだという点です。
クエリが基となるテーブルを対象にすると、ClickHouse は基底レイアウトと利用可能な PROJECTION を評価し、それぞれのプライマリインデックスを確認したうえで、正しい結果を返しつつ読み取る グラニュール 数が最も少ないレイアウトを選択します。この判断はクエリアナライザによって自動的に行われます。
したがって ClickStack では、PROJECTION は 純粋なデータの並べ替え に最も適しており、具体的には次のような場合です。
- アクセスパターンがデフォルトの主キーと本質的に異なる
- 単一のソートキーですべてのワークフローをカバーするのが現実的でない
- 最適な物理レイアウトを ClickHouse に透過的に選択させたい
PROJECTION の例
ワイルドカードを使用上記の PROJECTION の例では、ワイルドカード (
SELECT *) を使用しています。選択するカラムを一部に絞ると書き込み時のオーバーヘッドは減らせますが、その一方で PROJECTION を利用できる場面も限られます。というのも、それらのカラムだけで完全に処理できるクエリしか対象にならないためです。ClickStack では、その結果 PROJECTION の用途がごく限られたケースに狭まりがちです。このため、一般には適用範囲を最大化するためにワイルドカードを使うことが推奨されます。プロジェクションのマテリアライズには長時間を要し、多くのリソースを消費する可能性があります。オブザーバビリティデータは通常、有効期限 (TTL) によって期限切れになるため、これは本当に必要な場合にのみ実行してください。ほとんどの場合、プロジェクションは新しく取り込まれたデータにのみ適用されるようにしておけば十分で、直近 24 時間など、最も頻繁にクエリされる時間範囲の最適化に役立ちます。
SELECT *) を単純に並べ替えたものを表しており、クエリのフィルタ条件がプロジェクションの ORDER BY と強く一致している場合に、最も確実に機能します。
TraceId でフィルタリングし (特に等価条件) 、かつ時間範囲を含むクエリでは、上記のプロジェクションの効果が期待できます。たとえば次のとおりです。
TraceId に条件をかけないクエリや、プロジェクションの並び順キーの先頭にない他の次元で主に絞り込むクエリでは、通常は効果がなく、代わりにベースレイアウト経由で読み取られることがあります。
プロジェクションには、集計結果を格納することもできます (materialized view と同様です) 。ただし ClickStack では、プロジェクションベースの集計は一般に推奨されません。どの PROJECTION が選ばれるかは ClickHouse アナライザに依存するため、利用を制御しにくく、挙動も把握しづらいからです。代わりに、ClickStack がアプリケーション層で明示的に登録し、意図して選択できる materialized view を優先してください。
コストと指針
- 挿入時のオーバーヘッド: 異なる順序キーを持つ
SELECT *PROJECTION では、実質的にデータを2回書き込むことになるため、書き込み I/O が増加し、インジェストを維持するには追加の CPU とディスクスループットが必要になる場合があります。 - 必要な場合に限って使用: PROJECTION は、アクセスパターンが明確に異なり、2つ目の物理的な順序付けによって多くのクエリで有意なプルーニング効果が得られる場合に使うのが最適です。たとえば、2つのチームが同じデータセットに対して根本的に異なる方法でクエリするケースです。
- ベンチマークで検証する: ほかのチューニングと同様に、PROJECTION を追加してマテリアライズする前後で、実際のクエリレイテンシとリソース使用量を比較してください。
_part_offset を使った軽量 PROJECTION
ClickStack における軽量 PROJECTION はベータです
_part_offset-based 軽量 PROJECTION は、ClickStack のワークロードには推奨されません。ストレージ使用量と書き込み I/O は削減できますが、その一方でクエリ時のランダムアクセスが増える可能性があり、オブザーバビリティ規模の本番環境での挙動は現在も評価中です。この推奨事項は、機能の成熟と運用データの蓄積に伴って変更される可能性があります。_part_offset ポインタのみを格納する、より軽量な PROJECTION もサポートされています。これによりストレージのオーバーヘッドを大幅に削減でき、最近の改善によって グラニュール 単位のプルーニングも可能になったため、真のセカンダリ索引により近い動作をするようになりました。詳細は次を参照してください。
代替手段
- OpenTelemetry collector を設定し、異なる
ORDER BYキーを持つ 2 つのテーブルに書き込むようにして、各テーブルに対して個別の ClickStack ログソースを作成する。 - materialized view をコピーパイプラインとして作成する。つまり、メインテーブルに materialized view をアタッチし、生の行を別のソートキーを持つ secondary table にそのまま SELECT する (非正規化またはルーティングのパターン) 。このターゲットテーブル用のログソースを作成します。例は こちら を参照してください。