Skip to content

ClickHouseのオブザーバビリティ向けにClickStackを5倍高速化した方法

t02em6f031p u08g43srtv4 1f935c7b0cca 512
2026年7月2日 · 30分で読む

まとめ

  • Managed ClickStack の GA に向けて、ログとトレースのスキーマをベンチマーク駆動で再設計し、代表的なオブザーバビリティクエリのレイテンシを5倍以上短縮しました。
  • 主キーの再設計、テキストインデックス、エイリアスカラム、マテリアライズドビューを組み合わせ、ClickHouseの新機能を最大限活用しています。
  • OpenTelemetry Collector から継承したデフォルトスキーマは厳密なベンチマークを経ていなかったため、ペタバイト規模では遅いクエリやタイムアウトが顕在化していました。
  • 実際にユーザーがよく使うクエリパターン(トレースID検索、属性検索、ログ本文検索、ヒストグラム集計)を洗い出し、優先的に最適化しました。
  • すべての変更を現実的な本番ワークロードで検証し、取り込み・ストレージ・運用効率のバランスを保っています。

TLDR;

私たちは、主キーの再設計、テキストインデックス、クエリ書き換え、マテリアライズドビュー、そして ClickHouse の新機能を組み合わせたベンチマーク駆動プロセスで、ClickStack のオブザーバビリティスキーマを再構築しました。ユーザーが最も頼りにするクエリパターンに最適化し、あらゆる変更を現実的な本番ワークロードで検証することで、代表的なクエリのレイテンシを 5 倍以上短縮しつつ、取り込み・ストレージ・運用効率のバランスの取れたプロファイルを維持しました。

はじめに

Managed ClickStack は、昨年のプライベートプレビュー、2 月のベータ版リリースを経て、先日 GA に到達しました。GA までの道のりでは、UX の改善から運用面の成熟度に至るまで、スタック全体で大きな製品開発が行われました。しかし、他のすべてを超える 1 つの課題がありました。それは、ユーザーが期待する高速でインタラクティブな体験を維持しながら、増大するオブザーバビリティワークロードの要求に応えられるよう ClickStack をスケールさせることでした。

ClickStack のパフォーマンスの中核にあるのは、ログとトレースのスキーマです。これらのスキーマはデータの保存・圧縮・インデックスの仕組みを決定し、取り込み効率とクエリ速度の両方に直接影響します。同様に重要なのは、ClickStack の UI がそうしたスキーマ最適化を活用するよう設計されており、基盤となるストレージレイアウトやインデックスを効果的に利用するクエリを生成する点です。したがってパフォーマンスは、スキーマ設計とクエリ設計の両方の産物であり、両者は共に進化していきます。

本記事では、GA を前に ClickStack のパフォーマンスを一変させたログスキーマとクエリの最適化について解説します。テレメトリデータの保存・インデックス・クエリの方法を根本から見直し、UI がこれらの改善を最大限活用できるようにすることで、代表的なオブザーバビリティワークフローにわたってクエリのレイテンシを 5 倍以上短縮しました。

デフォルトスキーマの継承

ClickStack のデフォルトスキーマは、もともと ClickStack が推奨する取り込みパイプラインである OpenTelemetry Collector 用 ClickHouse エクスポーターから継承したものです。しっかりとした基盤と OpenTelemetry データとの幅広い互換性を提供していたものの、コミュニティが提供したものであり、ClickHouse の新しい機能を完全に活用するようには進化していませんでした。

また、ペタバイト規模で運用する誰もが直面するであろう根本的な課題もありました。プライベートプレビューやベータ期間中に大規模顧客と協働する中で、遅いクエリ、短い時間範囲での Top N クエリ、タイムアウトする検索や集計クエリが次第に一般的になっていきました。

こうした課題に特定のサービス単位で対応するには、多くの場当たり的あるいはリアクティブな最適化が必要でした。中にはクエリの構造をわずかに変更する程度の製品更新もありました。しかし、潜在的なスキーマ最適化のリストは膨らみ続けました。おそらくより重要なのは、このスキーマが大半のワークロードで依然として最適かどうか、また ClickHouse のバージョン間でどのように振る舞うかを判断するために必要な、厳密なベンチマークとワークロード分析を受けたことが一度もなかったという点です。

改善余地の特定

こうした課題を受けて、今年初めに私たちはクエリパフォーマンスの問題に対処し、すべてのユーザーにとって out-of-the-box のパフォーマンスを向上させるための一大取り組みに着手しました。

このグループの任務は、製品でよく使われるクエリパターンを特定し、活用できそうな ClickHouse の最適化を洗い出し、ClickHouse を最適化しようとするコアエンジニアのマインドセットで取り組むことでした。

調査により、ユーザー体験に最も大きな影響を与える一般的なクエリパターンがいくつか明らかになり、それらが最適化の優先対象となりました:

クエリ名アクセスパターン使い方
12_trace_id, 04_rare_body_text_term正確な属性による干し草の中の針的な Top N クエリ検索(例: ユーザー ID による検索)
02_map_high_freq_search, 03_map_high_freq_search属性カラムに対する高頻度な語での Top N クエリ検索(例: Kubernetes namespace での検索)
01_high_freq_servicename_body_search, 04_rare_body_text_termBody 式に対する Top N クエリログ本文の検索
06_map_low_freq_histogram, 08_rare_body_text_histogram正確な属性による干し草の中の針的クエリに対する集計検索ページのヒストグラム(例: 既知のユーザー ID を SeverityText でグルーピングしたカウント)
07_map_high_freq_histogram高頻度語クエリに対する集計検索ページのヒストグラム(例: Kubernetes namespace を SeverityText でグルーピングしたカウント)
05_high_freq_servicename_body_histogram, 08_rare_body_text_histogramBody フレーズクエリに対する集計検索ページのヒストグラム(例: Body 中のフレーズを SeverityText でグルーピングしたカウント)
09_specific_log_lookup特定ログのルックアップ個別ログの確認
10_distinct_keys非構造化キーの distinct なイントロスペクション補完キーの表示
11_distinct_values, 13_distinct_column_values非構造化値の distinct なイントロスペクション補完/フィルタ値の表示

Kubernetes namespace による検索とチャート

これらのカテゴリの一部はかなり似ているように見えます。3 つのカテゴリにおける検索と集計は、6 つの別々の問題として個別に列挙されています。しかしそれらは実質的に異なる問題であり、一部のスキーマ更新は 1 つのクエリパターンのためだけに行われます。

ベンチマークの必要性

理論に基づいて場当たり的に変更を適用することで改善に至ることはあるかもしれませんが、何らかの検証層が必要でした。反復的な性質を持つ科学的方法は、私たちの製品に対して実測可能な真の改善を推進するモデルとして魅力的でした。

並行して、ClickCannon という新しいツールを用いたベンチマークの取り組みも進行中で、ワークロードを支えるのに必要なリソースを特定すること、つまり「ClickStack を運用するにはどれくらいのリソースが必要か?」という問いに答えるための取り組みでした。

OpenTelemetry のログとトレースを使って ClickHouse インスタンスに対する調整可能な取り込みとベンチマークを可能にするプログラムとして、ClickCannon はテストパラメータを定義して反復的なテストを連続実行することで、実際のワークロードをシミュレートすることを可能にしました。ClickCannon の詳細については、こちらをご覧ください。

スキーマ最適化とキャパシティプランニングは相補的な取り組みです。 ベンチマークは特定のスケールで ClickStack を運用するために必要なリソースを理解するのに役立ちますが、スキーマ最適化はリリースする製品のパフォーマンスと効率の改善に焦点を当てます。両者は継続的に相互に影響し合います。スキーマ変更はリソース要件を変えることがあり、ベンチマークはボトルネックとさらなるスキーマ改善の機会を明らかにすることがよくあります。その結果、大きなスキーマ改訂には必ず新たなベンチマークが伴い、パフォーマンス向上とインフラへの影響の両方を検証するようにしています。

ClickCannon に加え、正式なテストフレームワークとインスタンス管理システムが必要でした。これには少し手間がかかりましたが、Kubernetes 環境を駆動してテストを実行し、エフェメラルな ClickHouse Cloud インスタンスを立ち上げ、ライフサイクルを管理するプログラムを作成しました。

これらの基盤が整うことで、スキーマ変更を迅速に評価し、イテレーションごとに結果を比較し、制御可能で再現性のある条件下でその影響を測定できるようになりました。

テストアプローチ

ユーザーを代表するテストワークロードを最初に構築しようとしたとき、調整できるパラメータは実質無限にあるように思えました。取り込みスループット、インスタンスサイズ、レプリカ数、データサイズ、想定テスト期間、行あたりの平均データサイズ、Map 内の要素数などはそのごく一部です。

最終的に、スキーマ、クエリワークロード、ClickHouse のバージョンだけを変数とし、それ以外は固定した設定に落ち着きました。スループットは 640 MiB/s(月あたり約 1.5 PB)に固定し、デプロイは各 24 CPU コアの 3 レプリカ構成、すべてのテストで実際の取り込みパターンを模倣するために timestamp 順に並んだ代表的なデータセットを使用しました。

これは現実的な本番環境を表しており、スキーマ変更を評価するための強力なベースラインを提供しました。個々のデプロイは特定の要件に基づく追加のチューニングで恩恵を受ける可能性があるものの、ここから得られる最適化は、私たちがユーザーベースで見てきた幅広いオブザーバビリティワークロードにおいて効果を発揮します。

まずはログスキーマを対象に、ClickCannon ベンチマークの取り組みで使用したものと同じ OpenTelemetry コーパスから派生したデータセットを使用することにしました。このデータセットは、ResourceAttributes 内のキー数などの特性が、本番で見られる平均的なテレメトリプロファイルにより近くなるように調整されています。私たちは今後もこのデータセットを進化させ続け、匿名化されたプラットフォームテレメトリからの知見を活用して、本番ワークロードと顧客が最も頼りにするクエリパターンを代表し続けるようにしていきます。

改善の評価

スキーマ変更の成功を測定することは、単にクエリレイテンシを見るよりも微妙です。あらゆる最適化にはトレードオフがあり、ある領域での改善が別の領域でのリグレッションを生む可能性があります。私たちの目標は、単一のクエリに対する最速のベンチマーク結果を出すことではなく、ClickStack 全体の効率、つまりバランスの取れた運用プロファイルを保ちながらクエリパフォーマンスを向上させることでした。これを達成するため、あらゆるスキーマ改訂は、採用が検討される前に一貫した指標と適格基準に照らして評価されました。

すべてのベンチマークは ClickHouse Cloud ウェアハウスを用いて実施しました。書き込みは親サービスに向けられ、クエリは専用の子サービスに対して実行されるため、取り込みとクエリのワークロードを分離し、それぞれ独立して評価できます。

私たちは常に、スループット、書き込みサービスの CPU 使用率、書き込みサービスのメモリ消費量、そして ClickHouse がインサートとマージのバランスをどれだけ効果的に取っているかを示す MaxPartCountForPartition を測定して、取り込みパフォーマンスの評価から始めます。最適化の性質によっては、他の場所で意味のある向上をもたらすのであれば、これらの指標の一部の劣化は許容できることがあります。

次に、CPU とメモリ要件の変化を考慮し、コンピュートコンピュート分離型のリードサービスへの影響を評価します。ここでも、これらが目立って影響を受ける場合には、クエリパフォーマンスで得られたゲインと照らし合わせて評価する必要があります。

追加コストが許容範囲であると判断できたら、個別のクエリのパフォーマンスを評価します。ほとんどの最適化は特定のクエリパターンを対象としていますが、すべてのベンチマーク実行にはより広範なワークロードも含めることで、改善が製品の他の部分でのリグレッションと引き換えに達成されていないことを確認します。

スキーマ最適化

私たちは幅広い最適化案を検討しましたが、最も効果の大きかった変更は 5 つのカテゴリーに集約されました。主キーの変更、インデックスの更新、新規インデックスの追加、クエリの書き換え、テーブル設定です。

念のため、これが元のスキーマです:

CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_logs (
  `Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
  `TimestampTime` DateTime DEFAULT toDateTime(Timestamp),
  `TraceId` String CODEC(ZSTD(1)),
  `SpanId` String CODEC(ZSTD(1)),
  `TraceFlags` UInt8,
  `SeverityText` LowCardinality(String) CODEC(ZSTD(1)),
  `SeverityNumber` UInt8,
  `ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
  `Body` String CODEC(ZSTD(1)),
  `ResourceSchemaUrl` LowCardinality(String) CODEC(ZSTD(1)),
  `ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
  `ScopeSchemaUrl` LowCardinality(String) CODEC(ZSTD(1)),
  `ScopeName` String CODEC(ZSTD(1)),
  `ScopeVersion` LowCardinality(String) CODEC(ZSTD(1)),
  `ScopeAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
  `LogAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.cluster.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.cluster.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.container.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.container.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.deployment.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.deployment.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.namespace.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.namespace.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.node.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.node.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.pod.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.pod.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.pod.uid` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.pod.uid'] CODEC(ZSTD(1)),
  `__hdx_materialized_deployment.environment.name` LowCardinality(String) MATERIALIZED ResourceAttributes['deployment.environment.name'] CODEC(ZSTD(1)),
  INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
  INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
  INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
  INDEX idx_scope_attr_key mapKeys(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
  INDEX idx_scope_attr_value mapValues(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
  INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
  INDEX idx_log_attr_value mapValues(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
  INDEX idx_lower_body lower(Body) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8
)
ENGINE = MergeTree
PARTITION BY toDate(TimestampTime)
PRIMARY KEY (ServiceName, TimestampTime)
ORDER BY (ServiceName, TimestampTime, Timestamp)
TTL TimestampTime + ${TABLES_TTL}
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;

このスキーマにはいくつかの注目すべき設計判断が含まれていました。OpenTelemetry の動的な属性(例: ResourceAttributes)は ClickHouse の Map 型を使って保存されており、スキーマ変更なしに任意のリソース、スコープ、ログ属性を取り込むことができます。よくクエリされる属性でのフィルタリングを高速化するため、Kubernetes やデプロイ関連のフィールドの小さなセットが専用カラムにマテリアライズされています。これにより、クエリ時に Map から値を繰り返し抽出することを避け、一般的な検索や集計のパフォーマンスを向上させます。UI はこれらのカラムが抽出されていることを自動的に認識し、可能な場所でそれらを使用します。

このスキーマはデータスキッピングインデックスも広範に活用しています。Map のキーと値の両方に Bloom フィルタが適用されており、ClickHouse は要求された属性や値を含むはずのないグラニュールを迅速に除外できます。同様に、ログ本文は tokenbf_v1 インデックスでインデックス化され、非構造化ログ内容に対するトークンベースの検索をより効率的に行えるようになっています。

順序付けの戦略も同様に重要です。データは (ServiceName, TimestampTime, Timestamp) でソートされ、主キーには (ServiceName, TimestampTime) のみが含まれています。ServiceName を最初に配置することで、同じサービスからのテレメトリがディスク上で近接配置され、サービス固有のフィルタリングが非常に効率的になります。TimestampTime は、ナノ秒精度の Timestamp カラムを分単位で表現したもので、局所性を向上させ、時間ベースのクエリにより連続した範囲を作るために含まれていました。特筆すべきは、疎な主キーインデックス(PRIMARY KEY 句によって制御される)には完全な Timestamp カラム自体は含まれていない点です。ほとんどのフィルタリングの利点は TimestampTime で既に得られているため、より高カーディナリティな timestamp を含めても、追加の枝刈り効果はほとんどなく、主キーインデックスのメモリ消費を増やすだけになるからです。

主キーの時間バケット化

ClickHouse で主キーを定義することは、データがどのようにディスク上でソートされるか、そして疎インデックスの内容を定義することでもあります。以前の主キーは (ServiceName, TimestampTime) でした。ServiceName はテレメトリを発するサービスの名前、TimestampTimeTimestamp を秒単位に丸めた値です。

適切に構成されれば、主キーは read_in_order 最適化に活用でき、Top N クエリで LIMIT に達した時点で短絡できます。これは ClickStack UI でログを検索する際に使用されます。ただしそのためには、主キーがクエリの時間ソート(ClickStack では通常 Timestamp)と揃っている必要があります。この最適化が、主キーを変更する主要な動機でした。

問題は、時間バケット化の適切な粒度を選ぶことでした。生の timestamp のような高カーディナリティ値を使用すると、データが多数の小さな範囲に断片化され、圧縮効率が下がり、スキャンの効率も低下します。これが、元のスキーマが ServiceName を先頭にしていた主な理由の 1 つで、類似データをディスク上でまとめてグループ化し、局所性を向上させます。

最初の実験では toStartOfMinute(Timestamp) を使用しました。これは read_in_order 最適化を機能させることに成功しましたが、ベンチマークでは、より細かい粒度がディスクから読み取るグラニュール範囲を多くしすぎることで、検索と集計クエリの両方が悪化することが分かりました。検索対象の時間範囲が大きくなるほど、この効果は顕著になりました。

-- Before
PRIMARY KEY (ServiceName, TimestampTime)
ORDER BY (ServiceName, TimestampTime, Timestamp)

-- After
PRIMARY KEY (toStartOfFiveMinutes(TimestampTime), ServiceName, TimestampTime)

広範なテストの結果、toStartOfFiveMinutes(Timestamp) に落ち着きました。5 分バケットは read_in_order の利点を維持しながら、ディスク上でより大きく連続したデータ範囲を保持しました。timestamp 駆動のクエリで計測可能な劣化はなく、圧縮特性が向上し、1 分バケット化で見られたリグレッションを回避しました。また、ServiceName などの他の述語に依存するクエリにとっても有益であることが証明されました。これらは 1 分アプローチの高カーディナリティにより悪影響を受けていたためです。

ここでは緑のバーが以前のスキーマ、黄色が提案された変更を表しています。クエリ 1 と 2 はレイテンシが増加し、3 と 4 は低下します。次の最適化でリグレッションは元に戻ります。

主キーから TimestampTime を削除

TimestampTime カラムは以前、TimestampDateTime64 カラムを元にした DateTime カラムとして存在していました。つまり 64 バイトではなく 32 バイトで、秒単位の精度を持ちます。これにより疎な主キーインデックスのサイズと枝刈り中に読み取られるデータ量が削減される一方で、より低カーディナリティな timestamp で効果的な時間ベースの枝刈りを提供していました。

-- Before
`TimestampTime` DateTime DEFAULT toDateTime(Timestamp),
PARTITION BY toDate(TimestampTime)
PRIMARY KEY (toStartOfFiveMinutes(TimestampTime), ServiceName, TimestampTime)

-- After
PARTITION BY toDate(Timestamp)
PRIMARY KEY (toStartOfFiveMinutes(Timestamp), ServiceName, Timestamp)

主キーが toStartOfFiveMinutes(Timestamp) を中心に再編成されると、その利点はほぼ消失しました。5 分バケットが望ましい時間的局所性を既に確立しており、Timestamp は各バケット内で行を自然に順序付けます。ベンチマークの結果、中間の TimestampTime カラムを保持しても計測可能な改善はなかったため、削除しました。これによりスキーマが簡素化され、主キーのメモリ使用量がわずかに減少し、多くのクエリで不要な timestamp 述語がなくなり、2 つの timestamp 表現を維持する必要もなくなりました。

緑のバーは TimestampTime を持つ以前のスキーマ、黄色のバーはそれを削除した後のスキーマを表しています。影響を受けたクエリのみを示しています。

Body にテキストインデックスを使用

ログの Body カラムは以前 tokenbf を使用しており、文字列のトークンに Bloom フィルタを適用していました。Bloom フィルタは確率的データ構造で、マッチを含まないグラニュールを確定的に除外できますが、偽陽性率があります(以前のスキーマでは 1%)。これにより多くのグラニュールを完全にスキップできますが、残りのグラニュールは依然として読み取り、マッチする行を検証する必要があります。Bloom フィルタは偽陽性を生成し得るため、それらのグラニュールの一部は最終的にマッチする行を全く含まないこともあります。

今年初め、私たちは ClickHouse のテキストインデックスが GA になったことを発表しました。これは転置インデックスを使用して、決定的な(偽陽性がない)優れたグラニュール枝刈りを提供します。

-- Before
INDEX idx_lower_body lower(Body) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8

-- After
INDEX idx_lower_body lower(Body) TYPE text(tokenizer = 'splitByNonAlpha')

私たちはこの新しいテキストインデックスを、tokenbf と同様の splitByNonAlpha トークナイザーで適用します。テストの結果、インサート時のオーバーヘッドとインデックスの追加ストレージフットプリントは発生するものの、読み取り性能は維持または向上することが分かりました。

ここでは緑のバーが Bloom フィルタ、黄色のバーが全文検索を表しています。全文検索インデックスを使用するクエリはインデックス評価によりフロアが高くなりますが、ボリュームの増加に対して良好にスケールします。評価の増加は特定ログのルックアップクエリで明らかで、後述するブロックオフセット最適化がこのリグレッションを緩和します。

09_specific_log_lookup クエリは、Bloom フィルタに比べてテキストインデックスの評価が大きくなるため、現在は劣化しています。このリグレッションは、スキーマ変更がいかに難しいか、そして最適化のために代表的なクエリがいかに重要かを示しています。今の段階ではリグレッションとして現れますが、これは後述するブロックオフセットとブロック番号の最適化によって対処されます。

さらに、テキストインデックスが行内に用語が存在すると判断した場合、ClickHouse は direct_read 最適化を通じてクエリの一部をインデックスから直接満たすことがよくあります。選択性の高い検索では、これにより基盤となるカラムデータの大部分を読み取って解凍する必要がなくなり、I/O と CPU のオーバーヘッドが両方削減されます。これは Bloom フィルタベースのアプローチとは異なります。Bloom フィルタはグラニュールにマッチが含まれる可能性があることを示せるだけで、依然としてデータを読み取って検証する必要があります。その結果、テキストインデックスは「干し草の中の針」的な検索(要求された用語を含むのが行のごく一部の場合)や、集計を伴う Body に対する検索フィルタ(例: 検索ヒストグラムページのカウント)に特に効果的です。

TraceId のテキストインデックス化

先に述べたように、Bloom フィルタには偽陽性率があります。テキストインデックスにはこれがなく、選択性の高い検索において特に効果的です。TraceId のルックアップはその好例で、通常は膨大なデータ量の中から 1 つの正確な識別子を探すクエリです。そこで、TraceId カラムから Bloom フィルタを削除し、代わりにテキストインデックスを使用する評価を行うことにしました。

-- Before
INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,

-- After
INDEX idx_trace_id TraceId TYPE text(tokenizer='array'),

以下のベンチマーク結果に示すように、Bloom フィルタをテキストインデックスに置き換えることで、ベンチマークでは TraceId 検索が最大 22% 改善され、ボリュームの増加に対しても良好にスケールしました。

これによりインデックスストレージは増加しますが、インデックスはオブジェクトストレージに置かれるため、追加コストは通常、それによって得られるクエリパフォーマンスの改善で相殺されます。

ブロックオフセットとブロック番号の設定

ClickStack のワークフローの多くは、最終的に単一の行を読み込む必要があります。個別のログ行を表示することがその一般的な例です。検索結果は通常、データ転送とクエリコストを最小限に抑えるためにカラムのサブセットのみを返すため、ユーザーが結果を掘り下げる際には、完全な行と関連するすべてのカラム値を取得する必要があります。

その行を効率的に特定することは、思ったほど単純ではありません。ClickHouse の主キーは一意性ではなくデータの局所性と枝刈りのために設計されており、最初の検索で返されるカラムに対して同じ値を持つ複数の行が存在する可能性があります。歴史的には、ClickStack は行を再読み込みする際に、返されたすべてのカラム値を述語として使用していました。例えば、検索が TimestampServiceNameSeverityTextBody を返した場合、これらの値はすべて後続のルックアップクエリに含まれていました。これは機能しますが、追加のカラムでのフィルタリングが必要で、必要以上のデータを読み取ることになる可能性があります。

このクエリのパフォーマンスは、テキストインデックスの評価オーバーヘッドにより、テキストインデックスの導入後にもリグレッションを起こしました。これが可能な場所でそのオーバーヘッドを排除する動機となりました。

ClickHouse は _block_number_block_offset の仮想カラムを通じて、より優れたメカニズムを提供しています。挿入された各行は、_block_number で識別されるブロックに属し、そのブロック内で一意の位置を _block_offset で持ちます。

-- Before
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;

-- After
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1, enable_block_number_column = 1, enable_block_offset_column = 1;

これらの値を永続化するために必要なテーブル設定を有効にすることで、ClickStack は検索結果と一緒にそれらを返し、完全な行を取得する際にそれらを使用できます。

これによりルックアップクエリが大幅に簡素化されます。多くの潜在的に大きなカラムでフィルタリングする代わりに、ClickStack は行を一意に識別するために主キーカラムと _block_number_block_offset だけを必要とします。ルックアップは主に主キーと正確な行位置によって駆動されるため、調査される行が少なくなり、完全なレコードがより効率的に取得されます。

より優れたカラム補完のためのマテリアライズドビュー

昨年、私たちは ClickStack にマテリアライズドビューのサポートを導入しました。ユーザーは自分のワークロードに合わせたマテリアライズドビューを作成し、それをソースに登録することができ、ClickStack はビューでクエリを満たせる場合には、基盤となるテレメトリテーブルの代わりにそれを自動的に使用するようになります。これにより、ユーザーは作業をクエリ時から挿入時に移すことができ、大幅なパフォーマンス向上がしばしば得られました。

この機能への経験を積むにつれて、私たちは製品の他の場所で同じアプローチを適用する機会を探し始めました。すぐに 1 つの領域が際立ちました。それは補完機能とフィルタ値の提案です。検索サイドバーやクエリビルダーなどの機能は、基盤となるテレメトリデータのイントロスペクションに依存して、利用可能な属性キーと値を発見します。歴史的には、これはライブの otel_logsotel_traces テーブルに対して直接クエリすることを必要としていました。単純ではあるものの、このアプローチはデータセットが大きくなるにつれてスケールが悪くなりました。ユーザーは、遅いイントロスペクションクエリと、有用な値を十分に浮上させられないサンプリング結果のいずれかを選ばざるを得ないことがしばしばありました。


この記事をシェア

  • Y Combinator icon
  • X icon
  • Bluesky icon
  • Facebook icon
  • LinkedIn icon

Subscribe to our newsletter

Stay informed on feature releases, product roadmap, support, and cloud offerings!