---
title: "ClickHouseのオブザーバビリティ向けにClickStackを5倍高速化した方法"
date: "2026-08-19T00:21:01.103Z"
author: "Aaron Knudtson"
category: "Engineering"
excerpt: "ClickStackチームがベンチマーク駆動のプロセスを用いて、主キーの再設計、テキストインデックス、エイリアスカラム、マテリアライズドビューを組み合わせることで、一般提供開始に先立ちオブザーバビリティクエリのレイテンシを5倍以上削減した方法をご紹介します。"
---

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

## 概要

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

## TLDR; {#tldr}

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

## はじめに {#introduction}

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

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

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

## デフォルトスキーマの継承 {#inheriting_a_default_schema}

ClickStack のデフォルトスキーマは、もともと ClickStack が推奨する取り込みパイプラインである [OpenTelemetry Collector 用 ClickHouse エクスポーター](https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/exporter/clickhouseexporter/README.md)から継承したものです。しっかりとした基盤と OpenTelemetry データとの幅広い互換性を提供していたものの、コミュニティが提供したものであり、ClickHouse の新しい機能を完全に活用するようには進化していませんでした。

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

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

## 改善余地の特定 {#identifying_areas_for_improvement}

こうした課題を受けて、今年初めに私たちはクエリパフォーマンスの問題に対処し、すべてのユーザーにとって 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_term | Body 式に対する 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_histogram | Body フレーズクエリに対する集計 | 検索ページのヒストグラム(例: Body 中のフレーズを SeverityText でグルーピングしたカウント) |
| 09_specific_log_lookup | 特定ログのルックアップ | 個別ログの確認 |
| 10_distinct_keys | 非構造化キーの distinct なイントロスペクション | 補完キーの表示 |
| 11_distinct_values, 13_distinct_column_values | 非構造化値の distinct なイントロスペクション | 補完/フィルタ値の表示 |

![](https://clickhouse.com/uploads/clickstack_perf_jul2026_image10_8072bfcc3c.png)
*Kubernetes namespace による検索とチャート*

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

## ベンチマークの必要性 {#the_need_for_a_benchmark}

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

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

OpenTelemetry のログとトレースを使って ClickHouse インスタンスに対する調整可能な取り込みとベンチマークを可能にするプログラムとして、ClickCannon はテストパラメータを定義して反復的なテストを連続実行することで、実際のワークロードをシミュレートすることを可能にしました。ClickCannon の詳細については、[こちら](https://clickhouse.com/blog/building-clickcannon-a-tool-for-benchmark-clickhouse#intro)をご覧ください。

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

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

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

## テストアプローチ {#testing_approach}

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

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

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

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

## 改善の評価 {#evaluating_improvements}

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

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

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

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

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

## スキーマ最適化 {#schema_optimizations}

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

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

<pre><code type='click-ui' language='sql'>
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;
</code></pre>

このスキーマにはいくつかの注目すべき設計判断が含まれていました。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 を含めても、追加の枝刈り効果はほとんどなく、主キーインデックスのメモリ消費を増やすだけになるからです。

### 主キーの時間バケット化 {#time_bucketing_the_primary_key}

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

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

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

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

<pre><code type='click-ui' language='sql'>
-- Before
PRIMARY KEY (ServiceName, TimestampTime)
ORDER BY (ServiceName, TimestampTime, Timestamp)

-- After
PRIMARY KEY (toStartOfFiveMinutes(TimestampTime), ServiceName, TimestampTime)
</code></pre>

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

![](https://clickhouse.com/uploads/clickstack_perf_jul2026_image11_fc3daccaeb.png)
*ここでは緑のバーが以前のスキーマ、黄色が提案された変更を表しています。クエリ 1 と 2 はレイテンシが増加し、3 と 4 は低下します。次の最適化でリグレッションは元に戻ります。*

### 主キーから TimestampTime を削除 {#dropping_timestamptime_from_the_primary_key}

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

<pre><code type='click-ui' language='sql'>
-- 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)
</code></pre>

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

![](https://clickhouse.com/uploads/clickstack_perf_jul2026_image6_eedc8c391d.png)
*緑のバーは TimestampTime を持つ以前のスキーマ、黄色のバーはそれを削除した後のスキーマを表しています。影響を受けたクエリのみを示しています。*

### Body にテキストインデックスを使用 {#using_text_index_for_the_body}

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

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

<pre><code type='click-ui' language='sql'>
-- 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')
</code></pre>

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

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

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

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

### TraceId のテキストインデックス化 {#traceid_as_text_index}

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

<pre><code type='click-ui' language='sql'>
-- Before
INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,

-- After
INDEX idx_trace_id TraceId TYPE text(tokenizer='array'),
</code></pre>

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

![](https://clickhouse.com/uploads/clickstack_perf_jul2026_image5_2c3676b63e.png)

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

### ブロックオフセットとブロック番号の設定 {#block_offset_and_block_number_settings}

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

![](https://clickhouse.com/uploads/clickstack_perf_jul2026_image3_ca65b2b40f.png)

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

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

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

<pre><code type='click-ui' language='sql'>
-- 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;
</code></pre>

これらの値を永続化するために必要な[テーブル設定](https://clickhouse.com/docs/operations/settings/merge-tree-settings#enable_block_number_column)を有効にすることで、ClickStack は検索結果と一緒にそれらを返し、完全な行を取得する際にそれらを使用できます。

![](https://clickhouse.com/uploads/clickstack_perf_jul2026_image9_cf879ccc59.png)

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

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

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

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



<video autoplay="1" muted="1" loop="1" controls="0">
  <source src="https://clickhouse.com/uploads/autocomplete_v2_8759ebcc76.mp4" type="video/mp4" />
</video>

この課題に対応するため、ClickStack のデフォルトスキーマの一部としてマテリアライズドビューを提供するようにしました。クエリ実行時にテレメトリテーブルをスキャンする代わりに、ClickStack はメタデータ探索専用のコンパクトなロールアップテーブルを維持するようになっています。これらのビューは [`AggregatingMergeTree`](https://clickhouse.com/docs/engines/table-engines/mergetree-family/aggregatingmergetree) エンジンを使用して、属性のキー、値、およびそれらの出現頻度を15分単位のバケットで(挿入時に)事前集計します。

<pre><code type='click-ui' language='sql'>
CREATE MATERIALIZED VIEW IF NOT EXISTS otel_logs_attr_kv_rollup_15m_mv
TO otel_logs_kv_rollup_15m
AS WITH elements AS (
    SELECT 'NativeColumn' AS ColumnIdentifier,
        toStartOfFifteenMinutes(Timestamp) AS Timestamp,
        'SeverityText' AS Key,
        CAST(SeverityText AS String) AS Value
    FROM otel_logs
    UNION ALL
    -- similar UNION ALL branches for ServiceName, ScopeName, etc.
)
SELECT Timestamp, ColumnIdentifier, Key, Value, count() AS count
FROM elements
GROUP BY Timestamp, ColumnIdentifier, Key, Value;
</code></pre>

ソース探索の際、ClickStack はこれらのロールアップを自動的に検出し、利用可能な場合には常にオートコンプリートリクエストをそちらへルーティングします。

![](https://clickhouse.com/uploads/clickstack_perf_jul2026_image13_bc96331304.png)
*ロールアップテーブルはソース単位で設定されます。*

その結果、オートコンプリートクエリは通常の検索ワークロードと競合しなくなりました。事前集計済みのはるかに小さなデータセットからサジェスチョンを取得できるため、大規模なデプロイメントでのレイテンシが大幅に低減します。さらに、ロールアップの過程で頻度が追跡されているため、結果を人気度でランク付けすることもでき、追加のキャッシュ層を必要とせずに、より関連性の高いサジェスチョンを提供できます。当初はユーザーが設定可能な最適化として始まったものが、今ではデフォルトの ClickStack 体験の中核となっています。

![](https://clickhouse.com/uploads/clickstack_perf_jul2026_image4_62b27ef4a7.png)

### direct_read を可能にするマップ用エイリアスカラム {#alias_columns_for_maps_to_enable_direct_read}

これは、ベストな話を最後まで取っておいたブログ記事です。

オブザーバビリティスキーマに対する最もよく聞かれ、かつ正当な不満の一つが、動的な属性へのアクセスが遅くなり得ることです。OpenTelemetry は任意のメタデータでテレメトリを充実させることを推奨しているため、ユーザーがクエリしたいあらゆる属性を事前に予測することは不可能です。このため、ClickStack はリソース属性、スコープ属性、ログ属性を ClickHouse の `Map` 型で格納しています。これによりオブザーバビリティワークロードに必要な柔軟性が得られますが、その代わりに、単一のキーを問い合わせるにはマップ全体を読み取って解析する必要があるというトレードオフがあります。

代替案も評価してきました。JSON 型は特に興味深く、頻繁にアクセスされるパスを専用のサブカラムへ自動的にマテリアライズすることで、クエリ時に読み込むべきデータ量を削減できます。しかし実際には、属性がスパースかつ変動が大きく、絶えず進化するオブザーバビリティワークロードでは、追加の挿入オーバーヘッドが[割に合わないケースが多い](https://clickhouse.com/docs/use-cases/observability/clickstack/ingesting-data/schema/map-vs-json)のが実情です。現時点では、動的なテレメトリ属性を表現する上で、マップが最も実用的な選択肢です。

> より最近では、[シャーディングされたマップ](https://clickhouse.com/docs/sql-reference/data-types/map#bucketed-map-serialization) が個々のマップ値へのアクセスコストを削減する有望なアプローチとして登場しました。この機能は、以下の最適化を補完するもので、こちらは読み取りコストの削減ではなく、マップ属性に対する検索の高速化に焦点を当てています。

先に示した元のスキーマでは、すでにマップのキーと値の両方にブルームフィルタを使用していました。これはマップに対する条件評価時に活用できます。

<pre><code type='click-ui' language='sql'>
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
</code></pre>

例えば `LogAttributes['foo'] = 'bar'` を検索する場合、一方のブルームフィルタでキー `foo` が存在しないグラニュールを除外でき、もう一方の値 `bar` が存在しないグラニュールを除外できます。これはそれなりに機能していましたが、ブルームフィルタは確率的であり、グラニュール単位で動作します。プルーニング後でも ClickHouse は残りのデータを読み取って検証する必要があり、偽陽性の存在により一部のグラニュールが不必要に読み込まれる可能性があります。

ログ本文に対するテキストインデックスの成功を受け、同じアプローチをマップにも適用できないかと考えるようになりました。ブルームフィルタとは異なり、テキストインデックスは正確であり、`direct_read` 最適化に参加できます。これにより ClickHouse は、選択性の高い検索をカラムを読み取ることなくインデックスから直接満たせるようになります。

課題は、マップキーとマップ値それぞれに個別のテキストインデックスを付けても、あるレコードに `foo` と `bar` が存在するかどうかは分かっても、それらが同じキー・バリューペアに属するかまでは分からないことです。

これを解決するため、各キーとその値を連結して単一の文字列にし、それを Array として格納します。セマンティクスが少し独特なので、順を追って説明します。

<pre><code type='click-ui' language='sql'>
ResourceAttributeItems Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), ResourceAttributes::Array(Tuple(String, String)))
</code></pre>

データを保存領域に書き込まないよう、`ALIAS` カラムを使用しています。欲しいのは実際の配列値ではなく、これに対するインデックスだけだからです。`arrayMap` 関数は、内部的にはタプルの配列として表現される `ResourceAttributes` マップの各要素に、指定したラムダを適用します。ラムダの中で、キーと値を `=` セパレータで連結しています。

クエリ実行時には、等価クエリをテキストインデックス上の操作に変換します。例えば `ResourceAttributes['foo']='bar'` は `has(ResourceAttributeItems, concat('foo', '=', 'bar'))` になります。これによりテキストインデックスを利用でき、さらに重要なことに [`direct_read`](https://clickhouse.com/docs/engines/table-engines/mergetree-family/textindexes#direct-read) 最適化も活用できます。厳密な等価演算に関しては、クエリ時にマップをロードする痛みが実質的になくなりました。すべてのクエリがこの恩恵を大きく受けており、ベンチマークでは最大10倍の改善が見られたものもあります。

![](https://clickhouse.com/uploads/clickstack_perf_jul2026_image12_715d184dff.png)
*緑のバーはブルームフィルタ、黄色のバーは direct_read 最適化を用いたテキストインデックスを表しています。*

さらに嬉しいのは、テキストインデックス自体が検索可能である点です。インデックスはトークン単位で整理されているため、`startsWith` を用いたプレフィックス検索を効率的にサポートできます。これにより新たなイントロスペクションの可能性が広がります。例えば、連結されたキー・バリューペアをインデックス化しておけば、`foo=` で始まるトークンを問い合わせることで、あるキーに対するユニークな値を素早く発見できます。ClickStack はこれらの機能を活用して、オートコンプリート、フィルタサジェスト、その他アプリケーション全体のメタデータ駆動機能を、基盤となるテレメトリデータをスキャンすることなく実現しています。キーと値のイントロスペクションクエリの改善は、これまで実用的でなかった数々のプロダクト機能を可能にします。

![](https://clickhouse.com/uploads/clickstack_perf_jul2026_image1_3ba5976db0.png)
*緑のバーは以前の ClickStack のイントロスペクションクエリ、黄色のバーは同じテキストインデックスによって強化されたイントロスペクションを示しています。*

## まとめ {#conclusion}

以上で説明した最適化はすべて、デフォルトの ClickStack スキーマに組み込まれています。これらは、クエリ性能、取り込み効率、ストレージオーバーヘッド、運用の複雑さのバランスを取りつつ、最も一般的なオブザーバビリティのクエリパターンに対応します。結果として得られたスキーマは、当初のものから大きく様変わりしており、実際のワークロードに対する数ヶ月にわたるベンチマーク、反復、検証が反映されています。

<pre><code type='click-ui' language='sql'>
CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_logs (
  `Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
  `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)),
  `EventName` 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)),
  `ResourceAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), ResourceAttributes::Array(Tuple(String, String))),
  `ScopeAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), ScopeAttributes::Array(Tuple(String, String))),
  `LogAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), LogAttributes::Array(Tuple(String, String))),
  INDEX idx_trace_id TraceId TYPE text(tokenizer = 'array'),
  INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE text(tokenizer = 'array'),
  INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array'),
  INDEX idx_scope_attr_key mapKeys(ScopeAttributes) TYPE text(tokenizer = 'array'),
  INDEX idx_scope_attr_items ScopeAttributeItems TYPE text(tokenizer = 'array'),
  INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE text(tokenizer = 'array'),
  INDEX idx_log_attr_items LogAttributeItems TYPE text(tokenizer = 'array'),
  INDEX idx_lower_body lower(Body) TYPE text(tokenizer = 'splitByNonAlpha')
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (toStartOfFiveMinutes(Timestamp), ServiceName, Timestamp)
TTL toDateTime(Timestamp) + ${TABLES_TTL}
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1, enable_block_number_column = 1, enable_block_offset_column = 1;
</code></pre>

![](https://clickhouse.com/uploads/clickstack_perf_jul2026_image14_ae58510e68.png)

![](https://clickhouse.com/uploads/clickstack_perf_jul2026_image8_124b1be29c.png)
*緑のバーは以前のスキーマ、黄色のバーは最適化後のスキーマを表します。Query 11 はスケールが大きいため、別途表示しています。*

その成果はすでに実を結んでいます。以前は大規模になると苦戦していたクエリが、プライマリキーの設計やテキストインデックスから、エイリアスカラム、マテリアライズドビューに至るまで、スタックの複数レイヤーにまたがる改善の恩恵を受けています。特定のワークロード向けの単発的な最適化に頼るのではなく、スキーマ全体が汎用的な強固な基盤となりました。同じくらい重要なのは、ここで説明した多くの手法が ClickHouse ネイティブの機能を土台としており、新しいリリースが公開されるたびに、データベース自身の継続的な改善の恩恵を受けられる点です。

今回の取り組みは、物語のほんの一部にすぎません。本シリーズの次回の記事では、ClickCannon と進化したこのスキーマを用いて、ClickStack のための包括的なベンチマークフレームワークをどのように構築したかを取り上げます。オブザーバビリティのワークロードをどのようにモデル化したか、スループットをインフラ要件にどう変換したか、そしてその結果得られたベンチマークが ClickStack を大規模に運用する際のコストについて何を語るのか、順を追って見ていきます。

