メインコンテンツへスキップ
以下の理由から、ログとトレースについては、常に独自のスキーマを作成することを推奨します。
  • 主キーの選択 - デフォルトのスキーマでは、特定のアクセスパターン向けに最適化された ORDER BY が使用されています。通常、実際のアクセスパターンがこれに一致するとは限りません。
  • 構造の抽出 - 既存のカラム、たとえば Body カラムから新しいカラムを抽出したい場合があります。これは マテリアライズドカラム を使用して実現できます (より複雑なケースでは materialized view を使用します) 。これにはスキーマの変更が必要です。
  • Map の最適化 - デフォルトのスキーマでは、属性の保存に Map 型 が使用されています。これらのカラムでは任意のメタデータを保存できます。イベントに含まれるメタデータは事前に定義されていないことが多く、ClickHouse のような強く型付けされたデータベースでは、こうした機能がなければ保存できないため、これは重要な機能です。ただし、マップ のキーとその値へのアクセスは、通常のカラムへのアクセスほど効率的ではありません。これに対処するために、スキーマを変更し、最もよく参照される マップ キーをトップレベルのカラムとして持たせます。“SQL による構造の抽出” を参照してください。これにはスキーマの変更が必要です。
  • マップ キーアクセスの簡素化 - マップ 内のキーにアクセスするには、より冗長な構文が必要です。これは別名を使うことで緩和できます。クエリを簡潔にする方法については、“別名の使用” を参照してください。
  • セカンダリ索引 - デフォルトのスキーマでは、Map へのアクセスの高速化とテキストクエリの高速化のためにセカンダリ索引を使用しています。これらは通常は不要であり、追加のディスク容量を消費します。利用することはできますが、本当に必要かどうかを確認するため、事前にテストすべきです。“セカンダリ / データスキッピングインデックス” を参照してください。
  • Codec の使用 - 想定されるデータの特性を理解しており、それによって圧縮が改善される根拠がある場合は、カラムの codec をカスタマイズすることもできます。
上記の各ユースケースについては、以下で詳しく説明します。 重要: 最適な圧縮率とクエリ性能を実現するためにスキーマを拡張・変更することは推奨されますが、可能な限りコアカラムについては OTel のスキーマ命名に従ってください。ClickHouse Grafana プラグインは、クエリ構築を支援するために、Timestamp や SeverityText などの基本的な OTel カラムがいくつか存在することを前提としています。ログおよびトレースに必要なカラムについては、それぞれ [1][2]こちら に記載されています。これらのカラム名を変更し、プラグイン設定でデフォルト値を上書きすることもできます。

SQL による構造の抽出

構造化ログでも非構造化ログでも、ユーザーはしばしば次のことを行える必要があります。
  • 文字列ブロブからカラムを抽出する。これらに対するクエリは、クエリ時に文字列操作を使うよりも高速になります。
  • マップからキーを抽出する。デフォルトのスキーマでは、任意の属性は Map 型のカラムに格納されます。この型はスキーマレスという特性を備えており、ログやトレースを定義する際に属性用のカラムを事前に定義しなくてよいという利点があります。これは、Kubernetes からログを収集し、後で検索できるようポッドラベルを確実に保持したい場合には、事前定義が現実的でないことも多いためです。マップのキーやその値へのアクセスは、通常の ClickHouse カラムに対するクエリより低速です。そのため、マップからルートテーブルのカラムへキーを抽出したい場面はよくあります。
次のクエリを考えてみましょう。 構造化ログを使って、どの URL パスが最も多くの POST リクエストを受けているかを数えたいとします。JSON ブロブは 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 の使用 を参照してください。
これら 2 つのユースケースはいずれも、上記のクエリロジックを insert 時の処理に移すことで ClickHouse で実現できます。以下では、いくつかのアプローチを紹介し、それぞれが適している場面を説明します。
処理は OTel と ClickHouse のどちらで行うべきですか?こちら で説明しているように、OTel collector のプロセッサや operator を使って処理を行うこともできます。ほとんどの場合、ClickHouse は collector のプロセッサよりも大幅にリソース効率が高く、高速です。すべてのイベント処理を SQL で行う主な欠点は、ソリューションが ClickHouse に強く依存することです。たとえば、処理済みのログを OTel collector から S3 などの別の宛先へ送信したい場合もあるでしょう。

マテリアライズドカラム

マテリアライズドカラムは、他のカラムから構造を抽出するための最もシンプルな方法です。これらのカラムの値は常に insert 時に計算されるため、INSERT クエリで指定することはできません。
オーバーヘッドマテリアライズドカラムでは、値が insert 時にディスク上の新しいカラムとして抽出されるため、追加のストレージオーバーヘッドが発生します。
マテリアライズドカラムでは、任意の ClickHouse 式をサポートしており、文字列の処理 (正規表現や検索 を含む) や URL型変換JSON からの値の抽出数学演算 のためのさまざまな分析関数を活用できます。 基本的な処理には、マテリアライズドカラムを推奨します。特に、Map から値を抽出してルートカラムに昇格させたり、型変換を行ったりする場合に有効です。非常にシンプルなスキーマで使用する場合や、materialized view と組み合わせて使用する場合に特に役立つことがよくあります。以下はログ用のスキーマの例で、JSON は collector によって LogAttributes カラムに抽出されています。
JSON 関数を使用して String の Body から抽出する場合の同等のスキーマは、こちらにあります。 3 つのマテリアライズドカラムでは、リクエストされたページ、リクエストの種類、リファラーのドメインを抽出します。これらは map のキーにアクセスし、その値に関数を適用します。その結果、後続のクエリは大幅に高速になります:
マテリアライズドカラムは、デフォルトでは SELECT * の結果には含まれません。これは、SELECT * の結果を常に INSERT を使ってそのままテーブルに挿入し直せるという不変条件を保つためです。この動作は asterisk_include_materialized_columns=1 を設定すると無効にできます。また、Grafana ではデータソース設定の Additional Settings -> Custom Settings で有効にできます。

materialized view

materialized view を使うと、ログやトレースに対する SQL ベースのフィルタリングや変換を、より柔軟かつ強力に適用できます。 Materialized Views を使うと、計算コストを query time から insert time へ移せます。ClickHouse の materialized view は、データの block がテーブルに挿入されるたびに、その block に対してクエリを実行する単なるトリガーです。このクエリの結果は、2 つ目の「ターゲット」テーブルに挿入されます。
リアルタイム更新ClickHouse の materialized view は、基になるテーブルにデータが流れ込むたびにリアルタイムで更新され、継続的に更新される索引に近い動作をします。一方、他のデータベースの materialized view は通常、refresh が必要なクエリの静的な snapshot です (ClickHouse のリフレッシュ可能な materialized view に近いものです) 。
materialized view に関連付けられたクエリは、理論上は aggregation を含むほぼ任意のクエリにできますが、JOIN には制限があります。ログやトレースで必要になる変換およびフィルタリングの workload であれば、基本的にどの SELECT ステートメントでも使用できると考えてよいでしょう。 ここで覚えておくべきなのは、このクエリはテーブル (ソーステーブル) に挿入される行に対して実行される単なるトリガーであり、その結果が新しいテーブル (ターゲットテーブル) に送られるという点です。 データが 2 回永続化されることを避けるため (ソーステーブルとターゲットテーブルの両方に保存されるのを防ぐため) 、元のスキーマはそのままに、ソーステーブルのエンジンを Null table engine に変更できます。OTel collector は引き続きこのテーブルにデータを送信します。たとえばログでは、otel_logs テーブルは次のようになります。
Null table engine は強力な最適化機能です。/dev/null のようなものだと考えてください。このテーブルにはデータは保存されませんが、アタッチされた materialized view は、挿入された行が破棄される前にその行に対して引き続き実行されます。 次のクエリを見てみましょう。これは行を保持したいフォーマットに変換するもので、LogAttributes からすべてのカラムを抽出し (これは collector が json_parser operator を使って設定したものと仮定します) 、SeverityTextSeverityNumber を設定します (いくつかの単純な条件と、これらのカラム の定義に基づきます) 。この例では、実際に値が入ることがわかっているカラムだけを選択し、TraceIdSpanIdTraceFlags などのカラムは無視しています。
上記では Body カラムも抽出しています。これは、後からSQLで抽出されない追加のattributeが追加された場合に備えるためです。このカラムはClickHouseで効率よく圧縮され、アクセス頻度も低いため、クエリパフォーマンスへの影響はありません。最後に、キャストを使用してTimestampをDateTime型に変換し、容量を節約します (詳細は「型の最適化」を参照) 。
条件関数上では、SeverityTextSeverityNumber を抽出するために 条件関数 を使っている点に注目してください。これらは複雑な条件を組み立てたり、map 内で値が設定されているかどうかを確認したりする際に非常に便利です。ここでは LogAttributes にすべてのキーが存在すると素朴に仮定しています。ぜひ使い方に慣れておくことをおすすめします。NULL 値 を扱う関数とあわせて、ログのパースで大いに役立ちます。
これらの結果を受け取るテーブルが必要です。以下のターゲットテーブルは上記のクエリに対応しています:
ここで選択した型は、「型の最適化」で説明する最適化に基づいています。
スキーマが大きく変わっていることに注目してください。実際には、保持しておきたい Trace カラムに加えて、ResourceAttributes カラム (通常は Kubernetes のメタデータを含みます) もあるはずです。Grafana は Trace カラムを利用して、ログとトレースの間を相互にたどれるリンク機能を提供できます。詳しくは”Grafana の使用”を参照してください。
以下では、materialized view otel_logs_mv を作成します。これは、otel_logs テーブルに対して上記のSELECTを実行し、その結果を otel_logs_v2 に送信します。
上記を以下に示します。 ここで、“ClickHouse へのエクスポート” で使用した collector の設定を再起動すると、データが意図したフォーマットで otel_logs_v2 に取り込まれるようになります。型付き JSON 抽出関数を使用している点にも注目してください。
JSON 関数を使用して Body カラムから各カラムを抽出する、これと同等の materialized view を以下に示します。

型に注意

上記の materialized view は暗黙的なキャストに依存しています。特に LogAttributes map を使用する場合はその傾向が顕著です。ClickHouse は多くの場合、抽出した値をターゲットテーブルの型に透過的にキャストしてくれるため、必要な構文を減らせます。ただし、ビューの SELECT ステートメントを、同じスキーマを持つターゲットテーブルに対する INSERT INTO ステートメントと組み合わせて、必ずビューをテストすることを推奨します。これにより、型が正しく処理されていることを確認できます。特に次のケースに注意してください。
  • map にキーが存在しない場合は、空文字列が返されます。数値型の場合は、これを適切な値にマッピングする必要があります。これは 条件関数 を使って実現できます。たとえば if(LogAttributes['status'] = ", 200, LogAttributes['status']) や、デフォルト値を許容できる場合は キャスト関数 を使います。たとえば toUInt8OrDefault(LogAttributes['status'] )
  • 型によっては常にキャストされるとは限りません。たとえば、数値の文字列表現は enum 値にはキャストされません。
  • JSON 抽出関数は、値が見つからない場合、その型のデフォルト値を返します。これらの値が妥当かどうかを確認してください。
Nullable を避けるオブザーバビリティデータでは、ClickHouse で Nullable を使用しないでください。空文字列と NULL を区別する必要が logs や traces で生じることは、ほとんどありません。この機能は追加のストレージオーバーヘッドを伴い、クエリ性能にも悪影響を与えます。詳しくは こちら を参照してください。

プライマリ (順序付け) キーの選択

必要なカラムを抽出したら、順序付けキー / 主キーの最適化を始められます。 順序付けキーを選ぶ際には、いくつかのシンプルなルールがあります。以下の観点は互いに相反する場合もあるため、上から順に検討してください。このプロセスで複数のキー候補を洗い出せますが、通常は 4~5 個で十分です。
  1. 一般的なフィルター条件やアクセスパターンに合ったカラムを選びます。たとえば、オブザーバビリティ調査を特定のカラム (例: ポッド名) で絞り込むことから始めることが多い場合、そのカラムは WHERE 句で頻繁に使われます。使用頻度の低いカラムよりも、こうしたカラムを優先してキーに含めてください。
  2. フィルター時に全行の大部分を除外できるカラムを優先します。これにより、読み取る必要があるデータ量を減らせます。サービス名やステータスコードはしばしば有力な候補です。ただし後者は、大半の行を除外できる値でフィルターする場合に限ります。たとえば、多くのシステムでは 200 番台でのフィルタリングは大部分の行に一致しますが、500 エラーはそれに比べてごく一部にしか対応しません。
  3. テーブル内の他のカラムと高い相関がある可能性の高いカラムを優先します。これにより、それらの値も連続して格納されやすくなり、圧縮効率が向上します。
  4. 順序付けキーに含まれるカラムに対する GROUP BY および ORDER BY は、メモリ効率が向上する場合があります。

順序付けキーに含めるカラムのサブセットを特定したら、それらを特定の順序で宣言する必要があります。この順序は、クエリ内で副次的なキーカラムをフィルターする際の効率と、テーブルのデータファイルの圧縮率の両方に大きく影響します。一般に、キーはカーディナリティの低い順に並べるのが最適です。ただし、順序付けキーの後ろにあるカラムでのフィルタリングは、タプルの前のほうにあるカラムより効率が低くなる点とのバランスを取る必要があります。これらの特性とアクセスパターンの両方を考慮してください。最も重要なのは、実際に複数の候補をテストすることです。順序付けキーとその最適化方法についてさらに理解を深めるには、この記事を参照することをおすすめします。
まず構造を固める順序付けキーは、ログの構造を整えてから決めることをおすすめします。順序付けキーとして、属性マップ内のキーや JSON 抽出式は使用しないでください。順序付けキーは、テーブルのルートカラムとして持たせてください。

Map の使用

前述の例では、Map(String, String) カラム内の値にアクセスするために、map['key'] という map 構文を使用しました。ネストされたキーにアクセスするための map 記法に加えて、これらのカラムを絞り込んだり選択したりするための専用の ClickHouse map 関数 も利用できます。 たとえば、次のクエリでは、mapKeys 関数 と、それに続く groupArrayDistinctArray 関数 (コンビネータ) を使用して、LogAttributes カラムで使用可能な一意のキーをすべて特定します。
ドットは避けてくださいMap のカラム名にドットを使用することは推奨しておらず、将来的に非推奨となる可能性があります。_ を使用してください。

別名の使用

Map型に対するクエリは、通常のカラムに対するクエリよりも低速です。詳しくは”クエリの高速化”を参照してください。また、構文も複雑になりがちで、記述が煩雑になることがあります。後者の問題に対処するため、Aliasカラムの使用を推奨します。 ALIASカラムはクエリ時に計算され、テーブルには保存されません。そのため、この型のカラムに値をINSERTすることはできません。別名を使うことで、Mapのキーを参照して構文を簡潔にし、Mapのエントリを通常のカラムとして透過的に扱えるようになります。次の例を見てみましょう。
複数のマテリアライズドカラムに加えて、Map LogAttributes を参照する ALIAS カラム RemoteAddr もあります。これにより、LogAttributes['remote_addr'] の値をこのカラム経由でクエリできるようになり、クエリを簡潔にできます。つまり、次のようになります。
さらに、ALTER TABLE コマンドを使えば ALIAS は簡単に追加できます。これらのカラムは、たとえばすぐに利用できます。
エイリアスはデフォルトで除外されますデフォルトでは、SELECT * には ALIAS カラムは含まれません。この動作は、asterisk_include_alias_columns=1 を設定することで無効にできます。

型の最適化

型の最適化に関するClickHouse の一般的なベストプラクティスは、この ClickHouse のユースケースにも当てはまります。

コーデックの使用

型の最適化に加えて、ClickHouseオブザーバビリティのスキーマで圧縮の最適化を行う際には、コーデックに関する一般的なベストプラクティスに従うこともできます。 一般的に、ZSTD コーデックはログおよびトレースのデータセットに非常に適しています。圧縮レベルをデフォルト値の 1 から上げることで、圧縮率が向上する場合があります。ただし、値を上げるほど書き込み時の CPU オーバーヘッドも大きくなるため、実際にテストする必要があります。通常、この値を上げても大きな効果はあまり得られません。 さらに、タイムスタンプは圧縮の面ではデルタエンコーディングの恩恵を受けますが、このカラムをプライマリキーまたはソートキーに使用すると、クエリ性能が低下することが確認されています。圧縮率とクエリ性能のトレードオフを評価することを推奨します。

Dictionaries の使用

Dictionaries は ClickHouse の重要な機能の 1 つで、さまざまな内部および外部SOURCES から取得したデータをインメモリの キー・バリュー 形式で表現し、超低レイテンシのルックアップクエリ向けに最適化します。 これはさまざまな場面で役立ちます。たとえば、インジェスト処理を遅くすることなく取り込みデータをその場で enrich したり、クエリ全体のパフォーマンスを向上させたりできます。特に JOIN で効果を発揮します。 オブザーバビリティのユースケースでは JOIN が必要になることはまれですが、Dictionaries は enrichment の用途で引き続き便利です。insert 時にもクエリ時にも利用できます。以下では、その両方の例を紹介します。
JOIN の高速化Dictionaries を使った JOIN の高速化に関心がある場合は、こちらで詳細を確認できます。

挿入時とクエリ時

Dictionaries は、データセットをクエリ時または挿入時にエンリッチするために使用できます。これらのアプローチには、それぞれ長所と短所があります。要約すると、次のとおりです。
  • 挿入時 - これは通常、エンリッチする値が変化せず、Dictionary の投入元として使える外部ソースに存在する場合に適しています。この場合、行を挿入時にエンリッチしておけば、クエリ時に Dictionary をルックアップする必要がなくなります。その代わり、挿入性能の低下に加え、エンリッチした値をカラムとして保存するため追加のストレージオーバーヘッドが発生します。
  • クエリ時 - Dictionary 内の値が頻繁に変化する場合は、クエリ時のルックアップのほうが適していることがよくあります。これにより、対応する値が変わったときにカラムを更新したり、データを書き換えたりする必要がなくなります。この柔軟性の代償として、クエリ時のルックアップコストが発生します。このコストは、たとえばフィルタ句で Dictionary ルックアップを使って多数の行に対するルックアップが必要な場合には、通常無視できません。一方、結果のエンリッチ、つまり SELECT 内での利用では、このオーバーヘッドは通常それほど問題になりません。
まずは、Dictionaries の基本を理解しておくことをお勧めします。Dictionaries は、専用の関数を使って値を取得できるインメモリのルックアップテーブルを提供します。 シンプルなエンリッチの例については、Dictionaries のガイドをこちらで参照してください。以下では、オブザーバビリティでよくあるエンリッチのタスクに焦点を当てます。

IP辞書の使用

IPアドレスを使ってログやトレースに緯度・経度の値をGeoエンリッチすることは、オブザーバビリティでは一般的な要件です。これは、ip_trie 構造の Dictionary を使って実現できます。 ここでは、DB-IP.comCC BY 4.0 license の条件の下で提供している公開データセット DB-IP city-level dataset を使用します。 the readme を見ると、このデータは次のような構造になっていることがわかります。
この構造を踏まえ、まずは url() テーブル関数を使ってデータを少しのぞいてみましょう:
作業を簡単にするために、URL() テーブルエンジンを使って、フィールド名を持つ ClickHouse のテーブルオブジェクトを作成し、行の総数を確認しましょう。
ip_trie Dictionary では IP アドレス範囲を CIDR 表記で表す必要があるため、ip_range_startip_range_end を変換する必要があります。 各範囲の CIDR は、次のクエリで簡潔に求められます。
上記のクエリでは多くの処理を行っています。詳しく知りたい方は、このすばらしい解説をお読みください。そうでなければ、IP 範囲から CIDR を算出している、と理解しておけば十分です。
ここでは IP 範囲、国コード、座標だけが必要なので、新しいテーブルを作成し、Geo IP データを挿入しましょう:
ClickHouse で低レイテンシの IP ルックアップを実現するために、Geo IP データのキー -> 属性のマッピングをメモリ内に保持する Dictionary を利用します。ClickHouse には、ネットワークプレフィックス (CIDR ブロック) を座標と国コードにマッピングするための ip_trie Dictionary 構造 が用意されています。次のクエリでは、このレイアウトを使用し、上記のテーブルをソースとする Dictionary を定義しています。
Dictionaryから行を選択し、このデータがルックアップに使用できることを確認できます:
定期的な更新ClickHouse の Dictionaries は、基となる table のデータと、上で使用した lifetime clause に基づいて定期的に更新されます。DB-IP dataset の最新の変更を Geo IP Dictionary に反映するには、変換を適用したうえで、geoip_url remote table から geoip table へデータを再度 insert するだけです。
これで Geo IP データが ip_trie Dictionary (これも ip_trie という名前です) に読み込まれたので、IP のジオロケーションに利用できます。これは次のように dictGet() function を使って実現できます。
ここでの取得の速さに注目してください。これにより、ログをエンリッチできます。この場合は、クエリ時にエンリッチすることを選択します。 元のログデータセットに戻ると、上記を使ってログを国ごとに集計できます。以下では、先ほどの materialized view で得られたスキーマを使用し、抽出済みの RemoteAddress カラムが含まれていることを前提とします。
IP アドレスと地理的位置の対応は変わる可能性があるため、ユーザーが知りたいのは、同じアドレスの現在の地理的位置ではなく、そのリクエストが行われた時点でどこから発生したかであることが一般的です。そのため、この場合は索引時のエンリッチメントが適しています。これは、以下に示すように materialized columns を使用するか、materialized view の select で行えます。
定期的に更新ユーザーは、ipエンリッチメントDictionaryを新しいデータに基づいて定期的に更新したいことがよくあります。これは、Dictionary の LIFETIME 句を使用することで実現できます。これにより、基になるテーブルからDictionaryが定期的に再読み込みされます。基になるテーブルを更新する方法については、“リフレッシュ可能なマテリアライズドビュー”を参照してください。
上記の国情報と座標を使用すると、国ごとのグループ化やフィルタリングだけでなく、さらに幅広い可視化が可能になります。参考として、“Geoデータの可視化”を参照してください。

正規表現Dictionaryの使用 (ユーザーエージェントのパース)

ユーザーエージェント文字列のパースは、正規表現の典型的な用途であり、ログやトレースベースのデータセットでよく求められます。ClickHouse では、Regular Expression Tree Dictionaries を使用してユーザーエージェントを効率的にパースできます。 正規表現ツリーDictionaryは、正規表現ツリーを含む YAML ファイルへのパスを指定する YAMLRegExpTree 辞書ソース型を使って、ClickHouse オープンソース版で定義します。独自の正規表現辞書を用意したい場合は、必要な構造の詳細をこちらで確認できます。以下では、uap-core を使ったユーザーエージェントのパースに焦点を当て、サポートされている CSV フォーマット向けの辞書をロードします。この方法は OSS と ClickHouse Cloud の両方に対応しています。
以下の例では、2024 年 6 月時点の uap-core の最新のユーザーエージェント解析用正規表現のスナップショットを使用します。最新版のファイルは随時更新されており、こちらから入手できます。以下で使用する CSV ファイルにロードするには、こちらの手順に従ってください。
次の Memory テーブルを作成します。これらのテーブルには、デバイス、ブラウザ、オペレーティングシステムをパースするための正規表現を格納します。
これらのテーブルには、urlテーブル関数を使用して、以下の公開されているCSVファイルからデータを取り込むことができます。
メモリテーブルへのデータ投入が完了したら、正規表現Dictionaryを読み込めます。キーの値はカラムとして指定する必要がある点に注意してください。これらが、user agent から抽出できる属性になります。
これらのDictionaryを読み込めば、サンプルのユーザーエージェントを使って、新しい辞書抽出機能をテストできます:
ユーザーエージェント を取り巻くルールが変わることはまれで、Dictionary の更新も新しいブラウザー、オペレーティングシステム、デバイスが登場したときに必要になる程度です。そのため、この抽出は insert time に実行するのが適切です。 この処理は materialized column を使って行うことも、materialized view を使って行うこともできます。以下では、先ほど使用した materialized view を変更します。
このため、ターゲットテーブル otel_logs_v2 のスキーマを変更する必要があります。
前述の手順に従ってcollectorを再起動し、構造化ログを取り込んだ後、新たに抽出したDevice、Browser、Osの各カラムをクエリできます。
複雑な構造に対するTuplesこれらのユーザーエージェントのカラムでは、Tuplesが使われている点に注目してください。Tuplesは、階層構造があらかじめわかっている複雑な構造に適しています。サブカラムは、異なる型を扱える一方で、通常のカラムと同等のパフォーマンスを実現します (Mapのキーとは異なります) 。

さらに詳しく

Dictionary に関するさらに多くの例や詳細については、以下の記事を参照してください。

クエリの高速化

ClickHouse は、クエリ性能を高速化するためのさまざまな手法をサポートしています。以下の手法は、まず最も一般的なアクセスパターンに合わせて最適化し、圧縮を最大化できる適切なプライマリキー/ソートキー を選定したうえで検討してください。通常、これが最小限の労力で最も大きな性能向上をもたらします。

集計に materialized view (インクリメンタル) を使用する

前のセクションでは、データ変換やフィルタリングに materialized view を使用する方法を見てきました。materialized view はそれに加えて、insert 時に集計を事前計算し、その結果を保存するためにも使用できます。この結果は後続の insert の結果で更新できるため、実質的に集計を insert 時点で事前計算できます。 ここでの基本的な考え方は、得られる結果が元のデータをより小さく表現したものになることが多い、という点です (集計の場合は部分的なスケッチになります) 。これを、ターゲットテーブルから結果を読み出すシンプルなクエリと組み合わせることで、元のデータに対して同じ計算を行う場合よりもクエリ時間を短縮できます。 以下のクエリを考えてみましょう。ここでは、構造化されたログを使って 1 時間ごとの総トラフィックを計算します。
これは、ユーザーがGrafanaでよく描画する折れ線グラフの1つだと考えられます。このクエリはたしかに非常に高速です。データセットはわずか1000万行しかなく、しかもClickHouseは高速だからです。ただ、これが数十億行、数兆行までスケールしても、理想的にはこのクエリ性能を維持したいところです。
このクエリは、LogAttributes mapからsizeキーを抽出する先ほどのmaterialized viewの結果であるotel_logs_v2テーブルを使えば、10倍高速になります。ここでは説明のためにあえて生データを使っていますが、これが一般的なクエリであれば、先ほどのビューを使うことを推奨します。
Materialized viewを使ってこれをinsert timeに計算したい場合は、結果の格納先となるテーブルが必要です。このテーブルでは、1時間あたり1行だけを保持する必要があります。既存の時間帯に対する更新を受け取った場合は、他のカラムをその時間帯の既存の行にマージする必要があります。このような増分状態のマージを実現するには、他のカラムについて部分状態を保存しておく必要があります。 これには、ClickHouseの特別なエンジンタイプであるSummingMergeTreeが必要です。これは、同じソートキーを持つすべての行を、数値カラムの値を合計した1行に置き換えます。次のテーブルは、同じ日付を持つ行をすべてマージし、数値カラムを合計します。
materialized viewを説明するために、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行になっているはずです:
ここでは、クエリ結果を保存することで、行数を 10m (otel_logs 内) から 113 まで大幅に削減できました。重要なのは、otel_logs テーブルに新しいログが挿入されると、対応する各時間帯の新しい値が bytes_per_hour に送られ、バックグラウンドで非同期に自動マージされる点です。こうして 1 時間あたり 1 行だけを保持することで、bytes_per_hour は常にコンパクトかつ最新の状態に保たれます。 行のマージは非同期で行われるため、ユーザーがクエリする時点では、1 時間あたり複数の行が存在している可能性があります。クエリ時に未マージの行も確実にマージするには、次の 2 つの方法があります。
  • テーブル名に FINAL モディファイア を使用する (上の count クエリで使用した方法) 。
  • 最終テーブルで使われているソートキー、つまり Timestamp で集計し、メトリクスを合計する。
通常は、2 つ目の方法のほうが効率的で柔軟です (テーブルを他の用途にも使えるため) 。ただし、クエリによっては 1 つ目のほうがシンプルです。以下では両方を示します。
これにより、クエリは0.6秒から0.008秒へと、75倍以上高速化しました!
より大規模なデータセットで、さらに複雑なクエリを扱う場合は、この効果は一層大きくなる可能性があります。例についてはこちらを参照してください。

より複雑な例

上記の例では、SummingMergeTree を使用して、1時間ごとの単純な件数を集計しています。単純な合計より先の統計を扱うには、別のターゲットテーブルエンジンである AggregatingMergeTree が必要です。 1日ごとのユニークな IP アドレス数 (またはユニークユーザー数) を計算したいとします。そのためのクエリは次のとおりです。
カーディナリティのカウントを増分更新できるように永続化するには、AggregatingMergeTree が必要です。
集約状態が保存されることを ClickHouse に認識させるため、UniqueUsers カラムは型 AggregateFunction として定義し、部分状態の生成元となる関数 (uniq) と、元のカラムの型 (IPv4) を指定します。SummingMergeTree と同様に、同じ ORDER BY のキー値を持つ行はマージされます (上の例では Hour) 。 対応する materialized view では、先ほどのクエリを使用します:
集約関数の末尾に State という接尾辞を付けている点に注目してください。これにより、最終結果ではなく、関数の集約状態が返されます。これには、この中間状態をほかの状態とマージできるようにするための追加情報が含まれます。 collector を再起動してデータが再読み込みされると、unique_visitors_per_hour テーブルに 113 行あることを確認できます。
最後のクエリでは、関数に Merge 接尾辞を付ける必要があります (カラムには部分集計状態が格納されているためです) :
ここでは、FINALではなくGROUP BYを使用している点に注意してください。

高速なルックアップのための materialized view (incremental) の利用

filter 句や aggregation 句で頻繁に使われるカラムを基に ClickHouse の ソートキー を選ぶ際は、アクセスパターンを考慮する必要があります。これは、ユーザーのアクセスパターンがより多様で、単一のカラムセットでは表しきれないオブザーバビリティのユースケースでは制約になることがあります。この点は、デフォルトの OTel スキーマに組み込まれている例を見るとよくわかります。トレースのデフォルトスキーマを見てみましょう。
このスキーマは、ServiceNameSpanNameTimestamp でのフィルタリング向けに最適化されています。tracing では、ユーザーは特定の TraceId によるルックアップを実行し、そのトレースに関連するスパンを取得できる必要もあります。これは ソートキー にも含まれていますが、末尾に配置されているため、フィルタリング効率は高くありません。その結果、単一のトレースを取得する場合でも、かなりの量のデータをスキャンする必要が生じる可能性があります。 OTel collector は、この課題に対処するための materialized view と関連テーブルもインストールします。テーブルとビューを以下に示します。
このビューにより、テーブル otel_traces_trace_id_ts には各トレースの最小および最大のタイムスタンプが実質的に確実に保持されます。TraceId で順序付けされたこのテーブルでは、これらのタイムスタンプを効率的に取得できます。さらに、これらのタイムスタンプ範囲は、メインの otel_traces テーブルをクエリする際に利用できます。より具体的には、id でトレースを取得する際、Grafana は次のクエリを使用します。
ここでの CTE は、trace ID ae9226c78d1d360601e6383928e4d22d の最小および最大のタイムスタンプを特定し、それを使って関連する spans を対象にメインの otel_traces を filter します。 同じアプローチは、類似の アクセスパターン にも適用できます。類似の例については、Data Modeling のこちらで説明しています。

プロジェクションの使用

ClickHouse のプロジェクションを使用すると、1 つのテーブルに対して複数の 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 を順序付けキーの末尾に追加すれば上記クエリのパフォーマンスを改善できますが、プロジェクションを追加することもできます。
まず、先にプロジェクションを作成し、その後にそれをマテリアライズする必要がある点に注意してください。後者のコマンドを実行すると、データは2つの異なる順序でディスク上に二重に保存されます。以下に示すように、プロジェクションはデータの作成時に定義することもでき、その場合はデータが挿入されるたびに自動的に維持されます。
重要な点として、プロジェクションが ALTER によって作成される場合、MATERIALIZE PROJECTION コマンドを実行しても、その作成は非同期で行われます。この操作の進行状況は、次のクエリで is_done=1 になるまで待つことで確認できます。
上記のクエリを再度実行すると、追加のストレージを消費する代わりに、パフォーマンスが大幅に向上していることがわかります (測定方法については”テーブルサイズと圧縮の測定”を参照してください) 。
上記の例では、プロジェクションに、先ほどのクエリで使用したカラムを指定しています。つまり、プロジェクションの一部として指定したこれらのカラムだけが、Status で並べ替えられた状態でディスクに保存されます。代わりにここで SELECT * を使用すると、すべてのカラムが保存されます。これにより、より多くのクエリ (任意のカラムの組み合わせを使用するもの) がプロジェクションの恩恵を受けられるようになりますが、追加のストレージが必要になります。ディスク容量と圧縮の測定については、“テーブルサイズと圧縮の測定”を参照してください。

セカンダリ索引 / データスキッピングインデックス

ClickHouse でプライマリキーをどれほど適切に調整しても、クエリによってはフルテーブルスキャンが避けられません。これは materialized view (および一部のクエリでは projections) を使うことである程度緩和できますが、追加のメンテナンスが必要になるうえ、それらを活用するには、ユーザーがその存在を把握している必要があります。一方、従来のリレーショナルデータベースではこれをセカンダリ索引で解決しますが、ClickHouse のようなカラム指向データベースでは効果的ではありません。そこで ClickHouse では “Skip” 索引を使用しており、一致する値を含まない大きな chunk を読み飛ばせるため、クエリ性能を大幅に向上できる場合があります。 デフォルトの OTel スキーマでは、map アクセスの高速化を目的としてセカンダリ索引が使用されています。ただし、これらは概して効果が薄く、カスタムスキーマにそのまま取り入れることは推奨しません。それでも、スキッピング索引が有用なケースはあります。 適用を試みる前に、セカンダリ索引のガイドを読んで理解しておくべきです。 一般に、プライマリキーと対象の非プライマリなカラム/式の間に強い相関があり、かつユーザーがまれな値、つまり多くの granule には現れない値を検索する場合に有効です。 ClickHouse は、全文検索向けに特化したテキスト索引を提供しています。 この索引は、トークン化されたテキストデータに対して転置索引を構築し、トークンベースの検索クエリを高速に実行できるようにします。 テキスト索引は、ClickHouse バージョン 26.2 以降で利用できます。 この索引は、MergeTree テーブルの以下のカラム型に定義できます: String, FixedString, Array(String), Array(FixedString), および Map (mapKeysmapValues の map 関数経由) 。 テキスト索引の定義には、tokenizer 引数が必要です。必要に応じて、トークン化の前に入力文字列を変換するプリプロセッサ関数を指定することもできます。 索引の検索に推奨される関数は、hasAnyTokenshasAllTokens です。 また、従来の文字列検索関数の一部も、テキスト索引が存在する場合は自動的に最適化されます。 詳細およびサポートされている関数については、こちらこちらのドキュメントを参照してください。 以下の例では、構造化ログのデータセットを使用します。
hasAnyTokens はテキスト索引がなくても使用できますが、その場合、クエリは Body カラム全体を低速にフルスキャンします:

テキスト索引の追加

Bodyカラムに対するテキスト索引は、テーブル作成時に追加できます。
または、後から ALTER TABLE を使用して追加できます。
同じSELECTクエリをもう一度実行すると、テキスト索引ルックアップが行われます。 アクセスされるデータ量はギガバイト単位からメガバイト単位に減少し、パフォーマンスは約45倍向上します。

プリプロセッサの使用

このデータセットでは、Body カラムに複数のキー・バリュー・ペア (例: msgidctxattr など) を含む JSON 形式の文字列が格納されています。 ここでは、msg フィールドだけを検索対象にしたいとします。 JSON 文字列全体に索引を作成する代わりに、トークン化の前に msg の値だけを抽出するプリプロセッサを定義できます。 たとえば:
この例では、プリプロセッサには次の効果があります。
  • トークン化および索引付けの対象となるテキスト量を減らす
  • 索引サイズを小さくする
  • 誤検出の発生確率を下げる
  • クエリのパフォーマンスを向上させる
プリプロセッサを使用しない索引と比べると、性能は約2倍向上します。 プリプロセッサを使用すると、索引サイズもギガバイト単位から数百キロバイトまで小さくなり、元のサイズのおよそ0.01%になります
**テキスト検索向けのその他の索引 二次スキップ索引の詳細については、こちらをご覧ください。

Map型からの抽出

Map型は、OTelのスキーマで広く使われています。この型では、値とキーの型が同じである必要があり、Kubernetes のラベルのようなメタデータには十分です。ただし、Map型のサブキーをクエリする際は、親カラム全体が読み込まれる点に注意してください。Map に多数のキーがある場合、そのキーが独立したカラムとして存在する場合に比べて、ディスクからより多くのデータを読み込む必要があるため、クエリ性能に大きな影響が出ることがあります。 特定のキーを頻繁にクエリする場合は、それをルートレベルの専用カラムとして切り出すことを検討してください。これは通常、一般的なアクセスパターンに応じてデプロイ後に行う作業であり、本番稼働前に予測するのは難しいことがあります。デプロイ後にスキーマを変更する方法については、「スキーマ変更の管理」 を参照してください。

テーブルサイズと圧縮の測定

ClickHouse がオブザーバビリティ用途で使われる主な理由の 1 つは、高い圧縮率にあります。 圧縮によってストレージコストを大幅に削減できるだけでなく、ディスク上のデータ量が減ることで I/O も少なくなり、クエリや insert も高速になります。I/O 削減による効果は、CPU に対する圧縮アルゴリズムのオーバーヘッドを上回ります。したがって、ClickHouse のクエリを高速化する際は、まずデータの圧縮率向上に注力すべきです。 圧縮の測定方法の詳細は、こちらを参照してください。
最終更新日 2026年6月19日