- 主キーの選択 - デフォルトのスキーマでは、特定のアクセスパターン向けに最適化された
ORDER BYが使用されています。通常、実際のアクセスパターンがこれに一致するとは限りません。 - 構造の抽出 - 既存のカラム、たとえば
Bodyカラムから新しいカラムを抽出したい場合があります。これは マテリアライズドカラム を使用して実現できます (より複雑なケースでは materialized view を使用します) 。これにはスキーマの変更が必要です。 - Map の最適化 - デフォルトのスキーマでは、属性の保存に Map 型 が使用されています。これらのカラムでは任意のメタデータを保存できます。イベントに含まれるメタデータは事前に定義されていないことが多く、ClickHouse のような強く型付けされたデータベースでは、こうした機能がなければ保存できないため、これは重要な機能です。ただし、マップ のキーとその値へのアクセスは、通常のカラムへのアクセスほど効率的ではありません。これに対処するために、スキーマを変更し、最もよく参照される マップ キーをトップレベルのカラムとして持たせます。“SQL による構造の抽出” を参照してください。これにはスキーマの変更が必要です。
- マップ キーアクセスの簡素化 - マップ 内のキーにアクセスするには、より冗長な構文が必要です。これは別名を使うことで緩和できます。クエリを簡潔にする方法については、“別名の使用” を参照してください。
- セカンダリ索引 - デフォルトのスキーマでは、Map へのアクセスの高速化とテキストクエリの高速化のためにセカンダリ索引を使用しています。これらは通常は不要であり、追加のディスク容量を消費します。利用することはできますが、本当に必要かどうかを確認するため、事前にテストすべきです。“セカンダリ / データスキッピングインデックス” を参照してください。
- Codec の使用 - 想定されるデータの特性を理解しており、それによって圧縮が改善される根拠がある場合は、カラムの codec をカスタマイズすることもできます。
SQL による構造の抽出
- 文字列ブロブからカラムを抽出する。これらに対するクエリは、クエリ時に文字列操作を使うよりも高速になります。
- マップからキーを抽出する。デフォルトのスキーマでは、任意の属性は
Map型のカラムに格納されます。この型はスキーマレスという特性を備えており、ログやトレースを定義する際に属性用のカラムを事前に定義しなくてよいという利点があります。これは、Kubernetes からログを収集し、後で検索できるようポッドラベルを確実に保持したい場合には、事前定義が現実的でないことも多いためです。マップのキーやその値へのアクセスは、通常の ClickHouse カラムに対するクエリより低速です。そのため、マップからルートテーブルのカラムへキーを抽出したい場面はよくあります。
Body カラム内に String として保存されています。さらに、ユーザーが collector で json_parser を有効にしている場合は、LogAttributes カラム内にも Map(String, String) として保存されていることがあります。
LogAttributes が利用可能であることを前提とすると、サイト内のどの URL パスが最も多くの POST リクエストを受けているかを集計するクエリは次のとおりです。
LogAttributes['request_path'] のような map 構文と、URL からクエリパラメータを取り除くための path 関数 を使っている点に注目してください。
ユーザーが collector で JSON パースを有効にしていない場合、LogAttributes は空になるため、String の Body からカラムを抽出するには JSON 関数 を使う必要があります。
パースには ClickHouse を優先してください一般に、構造化ログの JSON パースは ClickHouse で行うことを推奨しています。ClickHouse は JSON パースの実装として最速だと確信しています。ただし、ログを他の送信先にも送りたい場合や、このロジックを SQL に置きたくない場合があることも理解しています。
extractAllGroupsVertical 関数を使って正規表現を利用する必要があります。
辞書の利用を検討してください上記のクエリは、正規表現辞書を活用するように最適化できます。詳しくは Dictionaries の使用 を参照してください。
処理は OTel と ClickHouse のどちらで行うべきですか?こちら で説明しているように、OTel collector のプロセッサや operator を使って処理を行うこともできます。ほとんどの場合、ClickHouse は collector のプロセッサよりも大幅にリソース効率が高く、高速です。すべてのイベント処理を SQL で行う主な欠点は、ソリューションが ClickHouse に強く依存することです。たとえば、処理済みのログを OTel collector から S3 などの別の宛先へ送信したい場合もあるでしょう。
マテリアライズドカラム
オーバーヘッドマテリアライズドカラムでは、値が insert 時にディスク上の新しいカラムとして抽出されるため、追加のストレージオーバーヘッドが発生します。
LogAttributes カラムに抽出されています。
Body から抽出する場合の同等のスキーマは、こちらにあります。
3 つのマテリアライズドカラムでは、リクエストされたページ、リクエストの種類、リファラーのドメインを抽出します。これらは map のキーにアクセスし、その値に関数を適用します。その結果、後続のクエリは大幅に高速になります:
マテリアライズドカラムは、デフォルトでは
SELECT * の結果には含まれません。これは、SELECT * の結果を常に INSERT を使ってそのままテーブルに挿入し直せるという不変条件を保つためです。この動作は asterisk_include_materialized_columns=1 を設定すると無効にできます。また、Grafana ではデータソース設定の Additional Settings -> Custom Settings で有効にできます。materialized view
リアルタイム更新ClickHouse の materialized view は、基になるテーブルにデータが流れ込むたびにリアルタイムで更新され、継続的に更新される索引に近い動作をします。一方、他のデータベースの materialized view は通常、refresh が必要なクエリの静的な snapshot です (ClickHouse のリフレッシュ可能な materialized view に近いものです) 。
SELECT ステートメントでも使用できると考えてよいでしょう。
ここで覚えておくべきなのは、このクエリはテーブル (ソーステーブル) に挿入される行に対して実行される単なるトリガーであり、その結果が新しいテーブル (ターゲットテーブル) に送られるという点です。
データが 2 回永続化されることを避けるため (ソーステーブルとターゲットテーブルの両方に保存されるのを防ぐため) 、元のスキーマはそのままに、ソーステーブルのエンジンを Null table engine に変更できます。OTel collector は引き続きこのテーブルにデータを送信します。たとえばログでは、otel_logs テーブルは次のようになります。
/dev/null のようなものだと考えてください。このテーブルにはデータは保存されませんが、アタッチされた materialized view は、挿入された行が破棄される前にその行に対して引き続き実行されます。
次のクエリを見てみましょう。これは行を保持したいフォーマットに変換するもので、LogAttributes からすべてのカラムを抽出し (これは collector が json_parser operator を使って設定したものと仮定します) 、SeverityText と SeverityNumber を設定します (いくつかの単純な条件と、これらのカラム の定義に基づきます) 。この例では、実際に値が入ることがわかっているカラムだけを選択し、TraceId、SpanId、TraceFlags などのカラムは無視しています。
Body カラムも抽出しています。これは、後からSQLで抽出されない追加のattributeが追加された場合に備えるためです。このカラムはClickHouseで効率よく圧縮され、アクセス頻度も低いため、クエリパフォーマンスへの影響はありません。最後に、キャストを使用してTimestampをDateTime型に変換し、容量を節約します (詳細は「型の最適化」を参照) 。
これらの結果を受け取るテーブルが必要です。以下のターゲットテーブルは上記のクエリに対応しています:
スキーマが大きく変わっていることに注目してください。実際には、保持しておきたい Trace カラムに加えて、
ResourceAttributes カラム (通常は Kubernetes のメタデータを含みます) もあるはずです。Grafana は Trace カラムを利用して、ログとトレースの間を相互にたどれるリンク機能を提供できます。詳しくは”Grafana の使用”を参照してください。otel_logs_mv を作成します。これは、otel_logs テーブルに対して上記のSELECTを実行し、その結果を otel_logs_v2 に送信します。
otel_logs_v2 に取り込まれるようになります。型付き JSON 抽出関数を使用している点にも注目してください。
Body カラムから各カラムを抽出する、これと同等の materialized view を以下に示します。
型に注意
LogAttributes map を使用する場合はその傾向が顕著です。ClickHouse は多くの場合、抽出した値をターゲットテーブルの型に透過的にキャストしてくれるため、必要な構文を減らせます。ただし、ビューの SELECT ステートメントを、同じスキーマを持つターゲットテーブルに対する INSERT INTO ステートメントと組み合わせて、必ずビューをテストすることを推奨します。これにより、型が正しく処理されていることを確認できます。特に次のケースに注意してください。
- map にキーが存在しない場合は、空文字列が返されます。数値型の場合は、これを適切な値にマッピングする必要があります。これは 条件関数 を使って実現できます。たとえば
if(LogAttributes['status'] = ", 200, LogAttributes['status'])や、デフォルト値を許容できる場合は キャスト関数 を使います。たとえばtoUInt8OrDefault(LogAttributes['status'] ) - 型によっては常にキャストされるとは限りません。たとえば、数値の文字列表現は enum 値にはキャストされません。
- JSON 抽出関数は、値が見つからない場合、その型のデフォルト値を返します。これらの値が妥当かどうかを確認してください。
プライマリ (順序付け) キーの選択
- 一般的なフィルター条件やアクセスパターンに合ったカラムを選びます。たとえば、オブザーバビリティ調査を特定のカラム (例: ポッド名) で絞り込むことから始めることが多い場合、そのカラムは
WHERE句で頻繁に使われます。使用頻度の低いカラムよりも、こうしたカラムを優先してキーに含めてください。 - フィルター時に全行の大部分を除外できるカラムを優先します。これにより、読み取る必要があるデータ量を減らせます。サービス名やステータスコードはしばしば有力な候補です。ただし後者は、大半の行を除外できる値でフィルターする場合に限ります。たとえば、多くのシステムでは 200 番台でのフィルタリングは大部分の行に一致しますが、500 エラーはそれに比べてごく一部にしか対応しません。
- テーブル内の他のカラムと高い相関がある可能性の高いカラムを優先します。これにより、それらの値も連続して格納されやすくなり、圧縮効率が向上します。
- 順序付けキーに含まれるカラムに対する
GROUP BYおよびORDER BYは、メモリ効率が向上する場合があります。
順序付けキーに含めるカラムのサブセットを特定したら、それらを特定の順序で宣言する必要があります。この順序は、クエリ内で副次的なキーカラムをフィルターする際の効率と、テーブルのデータファイルの圧縮率の両方に大きく影響します。一般に、キーはカーディナリティの低い順に並べるのが最適です。ただし、順序付けキーの後ろにあるカラムでのフィルタリングは、タプルの前のほうにあるカラムより効率が低くなる点とのバランスを取る必要があります。これらの特性とアクセスパターンの両方を考慮してください。最も重要なのは、実際に複数の候補をテストすることです。順序付けキーとその最適化方法についてさらに理解を深めるには、この記事を参照することをおすすめします。
まず構造を固める順序付けキーは、ログの構造を整えてから決めることをおすすめします。順序付けキーとして、属性マップ内のキーや JSON 抽出式は使用しないでください。順序付けキーは、テーブルのルートカラムとして持たせてください。
Map の使用
Map(String, String) カラム内の値にアクセスするために、map['key'] という map 構文を使用しました。ネストされたキーにアクセスするための map 記法に加えて、これらのカラムを絞り込んだり選択したりするための専用の ClickHouse map 関数 も利用できます。
たとえば、次のクエリでは、mapKeys 関数 と、それに続く groupArrayDistinctArray 関数 (コンビネータ) を使用して、LogAttributes カラムで使用可能な一意のキーをすべて特定します。
ドットは避けてくださいMap のカラム名にドットを使用することは推奨しておらず、将来的に非推奨となる可能性があります。
_ を使用してください。別名の使用
LogAttributes を参照する ALIAS カラム RemoteAddr もあります。これにより、LogAttributes['remote_addr'] の値をこのカラム経由でクエリできるようになり、クエリを簡潔にできます。つまり、次のようになります。
ALTER TABLE コマンドを使えば ALIAS は簡単に追加できます。これらのカラムは、たとえばすぐに利用できます。
エイリアスはデフォルトで除外されますデフォルトでは、
SELECT * には ALIAS カラムは含まれません。この動作は、asterisk_include_alias_columns=1 を設定することで無効にできます。型の最適化
コーデックの使用
ZSTD コーデックはログおよびトレースのデータセットに非常に適しています。圧縮レベルをデフォルト値の 1 から上げることで、圧縮率が向上する場合があります。ただし、値を上げるほど書き込み時の CPU オーバーヘッドも大きくなるため、実際にテストする必要があります。通常、この値を上げても大きな効果はあまり得られません。
さらに、タイムスタンプは圧縮の面ではデルタエンコーディングの恩恵を受けますが、このカラムをプライマリキーまたはソートキーに使用すると、クエリ性能が低下することが確認されています。圧縮率とクエリ性能のトレードオフを評価することを推奨します。
Dictionaries の使用
JOIN の高速化Dictionaries を使った JOIN の高速化に関心がある場合は、こちらで詳細を確認できます。
挿入時とクエリ時
- 挿入時 - これは通常、エンリッチする値が変化せず、Dictionary の投入元として使える外部ソースに存在する場合に適しています。この場合、行を挿入時にエンリッチしておけば、クエリ時に Dictionary をルックアップする必要がなくなります。その代わり、挿入性能の低下に加え、エンリッチした値をカラムとして保存するため追加のストレージオーバーヘッドが発生します。
- クエリ時 - Dictionary 内の値が頻繁に変化する場合は、クエリ時のルックアップのほうが適していることがよくあります。これにより、対応する値が変わったときにカラムを更新したり、データを書き換えたりする必要がなくなります。この柔軟性の代償として、クエリ時のルックアップコストが発生します。このコストは、たとえばフィルタ句で Dictionary ルックアップを使って多数の行に対するルックアップが必要な場合には、通常無視できません。一方、結果のエンリッチ、つまり
SELECT内での利用では、このオーバーヘッドは通常それほど問題になりません。
IP辞書の使用
ip_trie 構造の Dictionary を使って実現できます。
ここでは、DB-IP.com が CC BY 4.0 license の条件の下で提供している公開データセット DB-IP city-level dataset を使用します。
the readme を見ると、このデータは次のような構造になっていることがわかります。
URL() テーブルエンジンを使って、フィールド名を持つ ClickHouse のテーブルオブジェクトを作成し、行の総数を確認しましょう。
ip_trie Dictionary では IP アドレス範囲を CIDR 表記で表す必要があるため、ip_range_start と ip_range_end を変換する必要があります。
各範囲の CIDR は、次のクエリで簡潔に求められます。
上記のクエリでは多くの処理を行っています。詳しく知りたい方は、このすばらしい解説をお読みください。そうでなければ、IP 範囲から CIDR を算出している、と理解しておけば十分です。
ip_trie Dictionary 構造 が用意されています。次のクエリでは、このレイアウトを使用し、上記のテーブルをソースとする Dictionary を定義しています。
定期的な更新ClickHouse の Dictionaries は、基となる table のデータと、上で使用した lifetime clause に基づいて定期的に更新されます。DB-IP dataset の最新の変更を Geo IP Dictionary に反映するには、変換を適用したうえで、
geoip_url remote table から geoip table へデータを再度 insert するだけです。ip_trie Dictionary (これも ip_trie という名前です) に読み込まれたので、IP のジオロケーションに利用できます。これは次のように dictGet() function を使って実現できます。
RemoteAddress カラムが含まれていることを前提とします。
定期的に更新ユーザーは、ipエンリッチメントDictionaryを新しいデータに基づいて定期的に更新したいことがよくあります。これは、Dictionary の
LIFETIME 句を使用することで実現できます。これにより、基になるテーブルからDictionaryが定期的に再読み込みされます。基になるテーブルを更新する方法については、“リフレッシュ可能なマテリアライズドビュー”を参照してください。正規表現Dictionaryの使用 (ユーザーエージェントのパース)
次の Memory テーブルを作成します。これらのテーブルには、デバイス、ブラウザ、オペレーティングシステムをパースするための正規表現を格納します。
otel_logs_v2 のスキーマを変更する必要があります。
複雑な構造に対するTuplesこれらのユーザーエージェントのカラムでは、Tuplesが使われている点に注目してください。Tuplesは、階層構造があらかじめわかっている複雑な構造に適しています。サブカラムは、異なる型を扱える一方で、通常のカラムと同等のパフォーマンスを実現します (Mapのキーとは異なります) 。
さらに詳しく
クエリの高速化
集計に materialized view (インクリメンタル) を使用する
このクエリは、
LogAttributes mapからsizeキーを抽出する先ほどのmaterialized viewの結果であるotel_logs_v2テーブルを使えば、10倍高速になります。ここでは説明のためにあえて生データを使っていますが、これが一般的なクエリであれば、先ほどのビューを使うことを推奨します。bytes_per_hour テーブルは空で、まだデータが入っていないものとします。materialized viewは、otel_logs に挿入されたデータに対して上記の SELECT を実行し (これは設定されたサイズのブロック単位で行われます) 、その結果を bytes_per_hour に送ります。構文を以下に示します。
TO 句で、結果の送信先、つまり bytes_per_hour を示します。
OTel collector を再起動してログを再送すると、bytes_per_hour テーブルには上記のクエリ結果が段階的に格納されていきます。完了したら、bytes_per_hour のサイズを確認できます。1時間あたり1行になっているはずです:
otel_logs 内) から 113 まで大幅に削減できました。重要なのは、otel_logs テーブルに新しいログが挿入されると、対応する各時間帯の新しい値が bytes_per_hour に送られ、バックグラウンドで非同期に自動マージされる点です。こうして 1 時間あたり 1 行だけを保持することで、bytes_per_hour は常にコンパクトかつ最新の状態に保たれます。
行のマージは非同期で行われるため、ユーザーがクエリする時点では、1 時間あたり複数の行が存在している可能性があります。クエリ時に未マージの行も確実にマージするには、次の 2 つの方法があります。
- テーブル名に
FINALモディファイア を使用する (上の count クエリで使用した方法) 。 - 最終テーブルで使われているソートキー、つまり Timestamp で集計し、メトリクスを合計する。
より大規模なデータセットで、さらに複雑なクエリを扱う場合は、この効果は一層大きくなる可能性があります。例についてはこちらを参照してください。
より複雑な例
UniqueUsers カラムは型 AggregateFunction として定義し、部分状態の生成元となる関数 (uniq) と、元のカラムの型 (IPv4) を指定します。SummingMergeTree と同様に、同じ ORDER BY のキー値を持つ行はマージされます (上の例では Hour) 。
対応する materialized view では、先ほどのクエリを使用します:
State という接尾辞を付けている点に注目してください。これにより、最終結果ではなく、関数の集約状態が返されます。これには、この中間状態をほかの状態とマージできるようにするための追加情報が含まれます。
collector を再起動してデータが再読み込みされると、unique_visitors_per_hour テーブルに 113 行あることを確認できます。
FINALではなくGROUP BYを使用している点に注意してください。
高速なルックアップのための materialized view (incremental) の利用
filter 句や aggregation 句で頻繁に使われるカラムを基に ClickHouse の ソートキー を選ぶ際は、アクセスパターンを考慮する必要があります。これは、ユーザーのアクセスパターンがより多様で、単一のカラムセットでは表しきれないオブザーバビリティのユースケースでは制約になることがあります。この点は、デフォルトの OTel スキーマに組み込まれている例を見るとよくわかります。トレースのデフォルトスキーマを見てみましょう。
ServiceName、SpanName、Timestamp でのフィルタリング向けに最適化されています。tracing では、ユーザーは特定の TraceId によるルックアップを実行し、そのトレースに関連するスパンを取得できる必要もあります。これは ソートキー にも含まれていますが、末尾に配置されているため、フィルタリング効率は高くありません。その結果、単一のトレースを取得する場合でも、かなりの量のデータをスキャンする必要が生じる可能性があります。
OTel collector は、この課題に対処するための materialized view と関連テーブルもインストールします。テーブルとビューを以下に示します。
otel_traces_trace_id_ts には各トレースの最小および最大のタイムスタンプが実質的に確実に保持されます。TraceId で順序付けされたこのテーブルでは、これらのタイムスタンプを効率的に取得できます。さらに、これらのタイムスタンプ範囲は、メインの otel_traces テーブルをクエリする際に利用できます。より具体的には、id でトレースを取得する際、Grafana は次のクエリを使用します。
ae9226c78d1d360601e6383928e4d22d の最小および最大のタイムスタンプを特定し、それを使って関連する spans を対象にメインの otel_traces を filter します。
同じアプローチは、類似の アクセスパターン にも適用できます。類似の例については、Data Modeling のこちらで説明しています。
プロジェクションの使用
ORDER BY 句を指定できます。
前のセクションでは、ClickHouse で materialized view を使用して集計を事前計算し、行を変換し、さまざまなアクセスパターンに合わせてオブザーバビリティのクエリを最適化する方法を見てきました。
例として、trace ID によるルックアップを最適化するために、materialized view が、insert を受け取る元のテーブルとは異なる並び順キーを持つターゲットテーブルへ行を送るケースを示しました。
プロジェクションも同じ問題への対処に使用でき、主キーに含まれないカラムに対するクエリ向けの最適化を可能にします。
理論上、この機能を使えば 1 つのテーブルに複数の並び順キーを持たせることができますが、明確な欠点が 1 つあります。それはデータの重複です。具体的には、データはメインの主キーの順序で書き込む必要があるだけでなく、各プロジェクションで指定した順序でも書き込む必要があります。これにより insert は遅くなり、より多くのディスク容量を消費します。
プロジェクションと Materialized Views の比較プロジェクションは materialized view と同様の機能を数多く提供しますが、使用は限定的にとどめるべきであり、多くの場合は後者の方が適しています。欠点と、どのような場合に適しているのかを理解しておく必要があります。たとえば、プロジェクションは集計の事前計算に使用できますが、この用途には Materialized views を使用することを推奨します。
otel_logs_v2 テーブルを 500 エラーコードでフィルタリングするものです。これはログにおける一般的なアクセスパターンであり、ユーザーはエラーコードでフィルタリングしたいことが多いと考えられます。
Null を使ってパフォーマンスを測定するここでは
FORMAT Null を使い、結果は表示していません。これにより、すべての結果は読み取られますが返されないため、LIMIT によるクエリの早期終了を防げます。これは、1,000 万行すべてをスキャンするのにかかる時間を示すためだけのものです。(ServiceName, Timestamp) では線形スキャンが必要になります。Status を順序付けキーの末尾に追加すれば上記クエリのパフォーマンスを改善できますが、プロジェクションを追加することもできます。
ALTER によって作成される場合、MATERIALIZE PROJECTION コマンドを実行しても、その作成は非同期で行われます。この操作の進行状況は、次のクエリで is_done=1 になるまで待つことで確認できます。
SELECT * を使用すると、すべてのカラムが保存されます。これにより、より多くのクエリ (任意のカラムの組み合わせを使用するもの) がプロジェクションの恩恵を受けられるようになりますが、追加のストレージが必要になります。ディスク容量と圧縮の測定については、“テーブルサイズと圧縮の測定”を参照してください。
セカンダリ索引 / データスキッピングインデックス
全文検索用のテキスト索引
tokenizer 引数が必要です。必要に応じて、トークン化の前に入力文字列を変換するプリプロセッサ関数を指定することもできます。
索引の検索に推奨される関数は、hasAnyTokens と hasAllTokens です。
また、従来の文字列検索関数の一部も、テキスト索引が存在する場合は自動的に最適化されます。
詳細およびサポートされている関数については、こちらとこちらのドキュメントを参照してください。
以下の例では、構造化ログのデータセットを使用します。
hasAnyTokens はテキスト索引がなくても使用できますが、その場合、クエリは Body カラム全体を低速にフルスキャンします:
テキスト索引の追加
ALTER TABLE を使用して追加できます。
プリプロセッサの使用
msg、id、ctx、attr など) を含む JSON 形式の文字列が格納されています。
ここでは、msg フィールドだけを検索対象にしたいとします。
JSON 文字列全体に索引を作成する代わりに、トークン化の前に msg の値だけを抽出するプリプロセッサを定義できます。
たとえば:
- トークン化および索引付けの対象となるテキスト量を減らす
- 索引サイズを小さくする
- 誤検出の発生確率を下げる
- クエリのパフォーマンスを向上させる