オブザーバビリティ用途でClickHouseを導入する場合、必ず大規模なデータセットを扱うことになり、それらを適切に管理する必要があります。ClickHouseには、データ管理に役立つさまざまな機能が用意されています。
パーティション
ClickHouse のパーティション化では、カラムまたは SQL 式に基づいて、データをディスク上で論理的に分離できます。データを論理的に分離することで、各パーティションを個別に操作できます。たとえば、削除することも可能です。これにより、パーティション、ひいてはそのサブセットを、時間に応じてストレージティア間で効率的に移動したり、データを期限切れにする/クラスターから効率的に削除することができます。
パーティション化は、テーブルを最初に定義する際に PARTITION BY 句で指定します。この句には、任意のカラムに対する SQL 式を含めることができ、その結果によって各行の格納先となるパーティションが決まります。

データパーツは、ディスク上で各パーティションに論理的に関連付けられており (共通のフォルダー名プレフィックスを介して) 、個別にクエリできます。以下の例では、デフォルトの otel_logs スキーマは、toDate(Timestamp) 式を使って日単位でパーティション化されています。行が ClickHouse に挿入されるたびに、この式が各行に対して評価され、対応するパーティションが存在すればそのパーティションに振り分けられます (その日の最初の行であれば、パーティションが作成されます) 。
CREATE TABLE default.otel_logs
(
...
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SeverityText, toUnixTimestamp(Timestamp), TraceId)パーティションに対しては、さまざまな操作を実行できます。これには、バックアップ、カラム操作、行単位でデータを変更/削除する mutation)、および索引のクリア (例: セカンダリ索引) が含まれます。
例として、otel_logs テーブルが日単位でパーティション分割されているとします。ここに構造化されたログデータセットを投入すると、数日分のデータが含まれます。
SELECT Timestamp::Date AS day,
count() AS c
FROM otel_logs
GROUP BY day
ORDER BY c DESC┌────────day─┬───────c─┐
│ 2019-01-22 │ 2333977 │
│ 2019-01-23 │ 2326694 │
│ 2019-01-26 │ 1986456 │
│ 2019-01-24 │ 1896255 │
│ 2019-01-25 │ 1821770 │
└────────────┴─────────┘
5 rows in set. Elapsed: 0.058 sec. Processed 10.37 million rows, 82.92 MB (177.96 million rows/s., 1.42 GB/s.)
Peak memory usage: 4.41 MiB.現在のパーティションは、シンプルなシステムテーブルのクエリで確認できます。
SELECT DISTINCT partition
FROM system.parts
WHERE `table` = 'otel_logs'┌─partition──┐
│ 2019-01-22 │
│ 2019-01-23 │
│ 2019-01-24 │
│ 2019-01-25 │
│ 2019-01-26 │
└────────────┘
5 rows in set. Elapsed: 0.005 sec.古いデータを保存するための別のテーブル otel_logs_archive を用意している場合があります。データはパーティション単位でこのテーブルへ効率的に移動できます (これは単なるメタデータ変更です) 。
CREATE TABLE otel_logs_archive AS otel_logs
--move data to archive table
ALTER TABLE otel_logs
(MOVE PARTITION tuple('2019-01-26') TO TABLE otel_logs_archive
--confirm data has been moved
SELECT
Timestamp::Date AS day,
count() AS c
FROM otel_logs
GROUP BY day
ORDER BY c DESC┌────────day─┬───────c─┐
│ 2019-01-22 │ 2333977 │
│ 2019-01-23 │ 2326694 │
│ 2019-01-24 │ 1896255 │
│ 2019-01-25 │ 1821770 │
└────────────┴─────────┘
4 rows in set. Elapsed: 0.051 sec. Processed 8.38 million rows, 67.03 MB (163.52 million rows/s., 1.31 GB/s.)
Peak memory usage: 4.40 MiB.SELECT Timestamp::Date AS day,
count() AS c
FROM otel_logs_archive
GROUP BY day
ORDER BY c DESC┌────────day─┬───────c─┐
│ 2019-01-26 │ 1986456 │
└────────────┴─────────┘
1 row in set. Elapsed: 0.024 sec. Processed 1.99 million rows, 15.89 MB (83.86 million rows/s., 670.87 MB/s.)
Peak memory usage: 4.99 MiB.これは、INSERT INTO SELECT を使用し、新しいターゲットテーブルにデータを書き換える必要がある他の手法とは対照的です。
さらに、データはパーティション単位で効率的に削除できます。これは代替手法 (mutation や論理削除) よりもはるかにリソース効率が高いため、こちらを優先すべきです。
ALTER TABLE otel_logs
(DROP PARTITION tuple('2019-01-25'))
SELECT
Timestamp::Date AS day,
count() AS c
FROM otel_logs
GROUP BY day
ORDER BY c DESC┌────────day─┬───────c─┐
│ 2019-01-22 │ 4667954 │
│ 2019-01-23 │ 4653388 │
│ 2019-01-24 │ 3792510 │
└────────────┴─────────┘適用例
上記の例は、データをパーティション単位で効率的に移動・操作できることを示しています。実際には、オブザーバビリティのユースケースでは、パーティション操作が最もよく使われるのは、主に次の 2 つのシナリオです。
- 階層型アーキテクチャ - データをストレージ階層間で移動し (ストレージティア を参照) 、ホット・コールド構成を実現できます。
- 効率的な削除 - データが指定した有効期限 (TTL) に達した場合 (Data management with TTL を参照)
以下では、この 2 つについて詳しく見ていきます。
クエリパフォーマンス
パーティションはクエリパフォーマンスの向上に役立つことがありますが、その効果はアクセスパターンに大きく左右されます。クエリが少数のパーティション (理想的には 1 つ) だけを対象とする場合は、パフォーマンスが向上する可能性があります。通常、これが有効なのは、パーティションキーが主キーに含まれておらず、そのキーでフィルタしている場合に限られます。一方で、多くのパーティションをまたぐ必要があるクエリでは、パーティション化しない場合よりもパフォーマンスが悪化することがあります (パーツが増える可能性があるためです) 。また、パーティションキーがすでに主キーの前方のエントリに含まれている場合は、単一パーティションを対象にする利点はさらに薄れ、ほとんど、あるいはまったくなくなります。さらに、各パーティション内の値が一意であれば、パーティション化を使って GROUP BY クエリを最適化することもできます。ただし、一般的には、まず主キーが最適化されていることを確認し、そのうえで、アクセスパターンがデータの特定の予測可能な部分集合にアクセスするような例外的なケースでのみ、パーティション化をクエリ最適化の手法として検討すべきです。たとえば、日単位でパーティション化し、ほとんどのクエリが直近 1 日を対象とするような場合です。この挙動の例については、こちらを参照してください。
有効期限 (TTL) によるデータ管理
Time-to-Live (TTL) は、ClickHouse を基盤とするオブザーバビリティソリューションにおいて、データ保持と管理を効率化するうえで重要な機能です。特に、大量のデータが継続的に生成される環境では、その重要性がさらに高まります。ClickHouse で TTL を実装すると、古いデータを自動的に期限切れとして削除できるため、手動で管理しなくてもストレージを最適に活用しながらパフォーマンスを維持できます。この機能は、データベースをスリムな状態に保ち、ストレージコストを削減し、最も重要で新しいデータに絞ってクエリを処理することで、高速かつ効率的なクエリ性能を維持するうえで不可欠です。さらに、データのライフサイクルを体系的に管理することで、データ保持ポリシーへの準拠にも役立ち、オブザーバビリティソリューション全体の持続性と拡張性を高めます。
ClickHouse では、TTL はテーブルレベルまたはカラムレベルで指定できます。
テーブルレベルの有効期限 (TTL)
ログとトレースの両方のデフォルトスキーマには、指定した期間が経過するとデータを期限切れにする有効期限 (TTL) が含まれています。これは、ClickHouse exporter の ttl キーで指定します (例) 。
exporters:
clickhouse:
endpoint: tcp://localhost:9000?dial_timeout=10s&compress=lz4&async_insert=1
ttl: 72hこの構文は現在、Golang の Duration 構文をサポートしています。h を使用し、パーティション化の期間に合わせることを推奨します。たとえば、日単位でパーティション化している場合は、日数の倍数になるようにしてください (例: 24h、48h、72h) 。 これにより、たとえば ttl: 96h の場合、テーブルに有効期限 (TTL) 句が自動的に追加されます。
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toUnixTimestamp(Timestamp), TraceId)
TTL toDateTime(Timestamp) + toIntervalDay(4)
SETTINGS ttl_only_drop_parts = 1デフォルトでは、有効期限 (TTL) が切れたデータは、ClickHouse がデータパーツをマージする際に削除されます。ClickHouse はデータの有効期限切れを検知すると、通常のスケジュール外でマージを実行します。
重要: ttl_only_drop_parts=1 設定の使用を推奨します (デフォルトのスキーマで適用されます) 。この設定を有効にすると、ClickHouse は、その中のすべての行の有効期限が切れている場合、パーツ全体を削除します。有効期限 (TTL) 切れの行を部分的にクリーンアップする代わりにパーツ全体を削除することで (ttl_only_drop_parts=0 の場合は、リソース負荷の高い mutation によって処理されます) 、merge_with_ttl_timeout をより短く設定でき、システム性能への影響も抑えられます。有効期限 (TTL) の期限切れを行う単位 (たとえば日単位) と同じ単位でデータをパーティション化していれば、パーツには自然にその期間のデータだけが含まれるようになります。これにより、ttl_only_drop_parts=1 を効率よく適用できます.
カラムレベルの有効期限 (TTL)
上記の例では、テーブルレベルでデータに有効期限を設定しています。有効期限はカラムレベルでも設定できます。データが古くなるにつれて、調査での有用性が保持に伴うリソース負荷に見合わないカラムを削除する用途に利用できます。たとえば、挿入時点でまだ抽出されていない新しい動的メタデータが追加される可能性に備えて、Body カラムは保持しておくことを推奨します。たとえば、新しい Kubernetes label などです。一定期間、たとえば 1 か月が経過すると、この追加メタデータが有用でないことが明らかになる場合があります。そうなると、Body カラムを保持しておく価値は限定的になります。
以下では、Body カラムを 30 日後に削除する方法を示します。
CREATE TABLE otel_logs_v2
(
`Body` String TTL Timestamp + INTERVAL 30 DAY,
`Timestamp` DateTime,
...
)
ENGINE = MergeTree
ORDER BY (ServiceName, Timestamp)データの再圧縮
通常、オブザーバビリティのデータには ZSTD(1) を推奨していますが、別の圧縮アルゴリズムや、たとえば ZSTD(3) のようなより高い圧縮レベルを試すこともできます。これはスキーマ作成時に指定できるだけでなく、一定期間の経過後に圧縮設定を変更するよう構成することも可能です。これは、コーデックや圧縮アルゴリズムによって圧縮率は向上するものの、クエリのパフォーマンスが低下する場合に適しています。このトレードオフは、参照頻度の低い古いデータでは許容できても、調査でより頻繁に使用される新しいデータには適さない可能性があります。
以下にその例を示します。ここでは、データを削除する代わりに、4 日後に ZSTD(3) で圧縮します。
CREATE TABLE default.otel_logs_v2
(
`Body` String,
`Timestamp` DateTime,
`ServiceName` LowCardinality(String),
`Status` UInt16,
`RequestProtocol` LowCardinality(String),
`RunTime` UInt32,
`Size` UInt32,
`UserAgent` String,
`Referer` String,
`RemoteUser` String,
`RequestType` LowCardinality(String),
`RequestPath` String,
`RemoteAddress` IPv4,
`RefererDomain` String,
`RequestPage` String,
`SeverityText` LowCardinality(String),
`SeverityNumber` UInt8,
)
ENGINE = MergeTree
ORDER BY (ServiceName, Timestamp)
TTL Timestamp + INTERVAL 4 DAY RECOMPRESS CODEC(ZSTD(3))有効期限 (TTL) の設定に関する詳細と例は、こちらを参照してください。テーブルやカラムに有効期限 (TTL) を追加・変更する方法などの例は、こちらにあります。有効期限 (TTL) によってホット/ウォームアーキテクチャのようなストレージ階層がどのように実現されるかについては、ストレージ階層を参照してください。
ストレージティア
ClickHouse では、異なるディスク上にストレージティアを作成できます。たとえば、ホットな新しいデータは SSD に配置し、古いデータは S3 をバックエンドとして保存できます。このアーキテクチャにより、調査で参照される頻度が低く、クエリ SLA に余裕のある古いデータには、より低コストなストレージを使用できます。
ストレージティアを作成するには、まずディスクを作成し、それを使ってストレージポリシーを定義する必要があります。ポリシー内のボリュームは、テーブル作成時に指定できます。データは、使用率、データパートのサイズ、ボリュームの優先順位に基づいて、ディスク間で自動的に移動できます。詳細はこちらを参照してください。
データは ALTER TABLE MOVE PARTITION コマンドを使ってディスク間で手動移動できますが、ボリューム間のデータ移動は有効期限 (TTL) で制御することもできます。完全な例はこちらにあります。
スキーマ変更の管理
ログやトレースのスキーマは、たとえばユーザーが異なるメタデータやポッドラベルを持つ新しいシステムを監視するようになるなど、システムの運用期間を通じて必然的に変化します。OTelスキーマを使用してデータを生成し、元のイベントデータを構造化フォーマットで保持しておくことで、ClickHouseのスキーマはこうした変更に強くなります。ただし、新しいメタデータが利用可能になったり、クエリのアクセスパターンが変化したりした場合は、それに合わせてスキーマを更新したくなるでしょう。
スキーマ変更時のダウンタイムを回避するために、ユーザーはいくつかの選択肢を取れます。以下でそれらを紹介します。
デフォルト値を使用する
DEFAULT values を使って、カラムをスキーマに追加できます。指定されたデフォルト値は、INSERT 時に値が指定されなかった場合に使用されます。
スキーマの変更は、materialized view の変換ロジックや OTel collector の設定を変更する前に行えます。これにより、新しいカラムを送信できるようになります。
スキーマを変更したら、OTel collectors を再設定できます。ここでは、ユーザーが "Extracting structure with SQL" で説明されている推奨プロセス、つまり OTel collectors がデータを Null table engine に送信し、materialized view がターゲットスキーマを抽出して、その結果を保存先のターゲットテーブルに送る構成を使用しているものとします。この場合、ビューは ALTER TABLE ... MODIFY QUERY syntax を使って変更できます。たとえば、OTel の構造化ログからターゲットスキーマを抽出するために、以下のターゲットテーブルと、それに対応する materialized view ("Extracting structure with SQL" で使用しているものと同様) を考えてみましょう。
CREATE TABLE default.otel_logs_v2
(
`Body` String,
`Timestamp` DateTime,
`ServiceName` LowCardinality(String),
`Status` UInt16,
`RequestProtocol` LowCardinality(String),
`RunTime` UInt32,
`UserAgent` String,
`Referer` String,
`RemoteUser` String,
`RequestType` LowCardinality(String),
`RequestPath` String,
`RemoteAddress` IPv4,
`RefererDomain` String,
`RequestPage` String,
`SeverityText` LowCardinality(String),
`SeverityNumber` UInt8
)
ENGINE = MergeTree
ORDER BY (ServiceName, Timestamp)
CREATE MATERIALIZED VIEW otel_logs_mv TO otel_logs_v2 AS
SELECT
Body,
Timestamp::DateTime AS Timestamp,
ServiceName,
LogAttributes['status']::UInt16 AS Status,
LogAttributes['request_protocol'] AS RequestProtocol,
LogAttributes['run_time'] AS RunTime,
LogAttributes['user_agent'] AS UserAgent,
LogAttributes['referer'] AS Referer,
LogAttributes['remote_user'] AS RemoteUser,
LogAttributes['request_type'] AS RequestType,
LogAttributes['request_path'] AS RequestPath,
LogAttributes['remote_addr'] AS RemoteAddress,
domain(LogAttributes['referer']) AS RefererDomain,
path(LogAttributes['request_path']) AS RequestPage,
multiIf(Status::UInt64 > 500, 'CRITICAL', Status::UInt64 > 400, 'ERROR', Status::UInt64 > 300, 'WARNING', 'INFO') AS SeverityText,
multiIf(Status::UInt64 > 500, 20, Status::UInt64 > 400, 17, Status::UInt64 > 300, 13, 9) AS SeverityNumber
FROM otel_logsLogAttributes から新しいカラム Size を抽出するとします。これは ALTER TABLE を使ってスキーマに追加し、デフォルト値を指定できます。
ALTER TABLE otel_logs_v2
(ADD COLUMN `Size` UInt64 DEFAULT JSONExtractUInt(Body, 'size'))上記の例では、デフォルト値として LogAttributes 内の size キーを指定しています (存在しない場合は 0 になります) 。つまり、このカラムにアクセスするクエリでは、その値が挿入されていない行については Map にアクセスする必要があるため、処理が遅くなります。これを定数、たとえば 0 として指定することも簡単にでき、その値を持たない行に対する後続のクエリのコストを削減できます。このテーブルをクエリすると、Map から期待どおりに値が設定されていることがわかります。
SELECT Size
FROM otel_logs_v2
LIMIT 5┌──Size─┐
│ 30577 │
│ 5667 │
│ 5379 │
│ 1696 │
│ 41483 │
└───────┘
5 rows in set. Elapsed: 0.012 sec.今後のすべてのデータにこの値が挿入されるよう、以下のように ALTER TABLE 構文を使って materialized view を変更できます。
ALTER TABLE otel_logs_mv
MODIFY QUERY
SELECT
Body,
Timestamp::DateTime AS Timestamp,
ServiceName,
LogAttributes['status']::UInt16 AS Status,
LogAttributes['request_protocol'] AS RequestProtocol,
LogAttributes['run_time'] AS RunTime,
LogAttributes['size'] AS Size,
LogAttributes['user_agent'] AS UserAgent,
LogAttributes['referer'] AS Referer,
LogAttributes['remote_user'] AS RemoteUser,
LogAttributes['request_type'] AS RequestType,
LogAttributes['request_path'] AS RequestPath,
LogAttributes['remote_addr'] AS RemoteAddress,
domain(LogAttributes['referer']) AS RefererDomain,
path(LogAttributes['request_path']) AS RequestPage,
multiIf(Status::UInt64 > 500, 'CRITICAL', Status::UInt64 > 400, 'ERROR', Status::UInt64 > 300, 'WARNING', 'INFO') AS SeverityText,
multiIf(Status::UInt64 > 500, 20, Status::UInt64 > 400, 17, Status::UInt64 > 300, 13, 9) AS SeverityNumber
FROM otel_logs以降の行では、挿入時に Size カラムに値が設定されます。
新しいテーブルを作成する
上記の手順の代替として、新しいスキーマを持つ新しいターゲットテーブルを作成するだけでも構いません。次に、任意の materialized view を、上記の ALTER TABLE MODIFY QUERY. を使って新しいテーブルを参照するように変更できます。この方法を使えば、たとえば otel_logs_v3 のようにテーブルをバージョン管理できます。
この方法では、ユーザーはクエリ対象のテーブルを複数扱うことになります。複数のテーブルをまたいでクエリするには、テーブル名にワイルドカードパターンを指定できる merge 関数 を使用できます。以下では、otel_logs テーブルの v2 と v3 をクエリする例を示します。
SELECT Status, count() AS c
FROM merge('otel_logs_v[2|3]')
GROUP BY Status
ORDER BY c DESC
LIMIT 5┌─Status─┬────────c─┐
│ 200 │ 38319300 │
│ 304 │ 1360912 │
│ 302 │ 799340 │
│ 404 │ 420044 │
│ 301 │ 270212 │
└────────┴──────────┘
5 rows in set. Elapsed: 0.137 sec. Processed 41.46 million rows, 82.92 MB (302.43 million rows/s., 604.85 MB/s.)エンドユーザーに対して複数のテーブルをまとめたテーブルを公開しつつ、merge 関数の使用を避けたい場合は、Merge テーブルエンジンを使用できます。以下でこれを示します。
CREATE TABLE otel_logs_merged
ENGINE = Merge('default', 'otel_logs_v[2|3]')
SELECT Status, count() AS c
FROM otel_logs_merged
GROUP BY Status
ORDER BY c DESC
LIMIT 5┌─Status─┬────────c─┐
│ 200 │ 38319300 │
│ 304 │ 1360912 │
│ 302 │ 799340 │
│ 404 │ 420044 │
│ 301 │ 270212 │
└────────┴──────────┘
5 rows in set. Elapsed: 0.073 sec. Processed 41.46 million rows, 82.92 MB (565.43 million rows/s., 1.13 GB/s.)これは、新しいテーブルが追加されるたびに、EXCHANGEテーブル構文を使って更新できます。たとえば、v4テーブルを追加するには、新しいテーブルを作成し、これを前のバージョンとアトミックに入れ替えることができます。
CREATE TABLE otel_logs_merged_temp
ENGINE = Merge('default', 'otel_logs_v[2|3|4]')
EXCHANGE TABLE otel_logs_merged_temp AND otel_logs_merged
SELECT Status, count() AS c
FROM otel_logs_merged
GROUP BY Status
ORDER BY c DESC
LIMIT 5┌─Status─┬────────c─┐
│ 200 │ 39259996 │
│ 304 │ 1378564 │
│ 302 │ 820118 │
│ 404 │ 429220 │
│ 301 │ 276960 │
└────────┴──────────┘
5 rows in set. Elapsed: 0.068 sec. Processed 42.46 million rows, 84.92 MB (620.45 million rows/s., 1.24 GB/s.)