Это руководство посвящено наиболее распространённым и эффективным способам оптимизации производительности ClickStack, которых достаточно для оптимизации большинства реальных рабочих нагрузок обсервабилити — как правило, вплоть до десятков терабайт данных в день.
Оптимизации представлены в продуманной последовательности: от самых простых и дающих наибольший эффект методов к более продвинутой и специализированной настройке. Ранние оптимизации следует применять в первую очередь, и нередко уже они сами по себе дают значительный прирост. По мере роста объёмов данных и усложнения рабочих нагрузок всё более целесообразным становится применение последующих методов.
Основные понятия ClickHouse
Прежде чем применять описанные в этом руководстве оптимизации, важно понимать несколько ключевых понятий ClickHouse.
В ClickStack каждый источник данных напрямую сопоставляется с одной или несколькими таблицами ClickHouse. При использовании OpenTelemetry ClickStack создаёт и поддерживает набор таблиц по умолчанию для хранения журналов, трассировок и метрик. Если вы используете собственные схемы или сами управляете таблицами, скорее всего, эти понятия вам уже знакомы. Однако если вы просто отправляете данные через OpenTelemetry Collector, эти таблицы создаются автоматически, и именно к ним применяются все описанные ниже оптимизации.
Таблицы относятся к базам данных в ClickHouse. По умолчанию используется база данных default, но это можно изменить в OpenTelemetry Collector.
Сосредоточьтесь на журналах и трассировкахВ большинстве случаев настройка производительности сосредоточена на таблицах журналов и трассировок. Хотя таблицы метрик тоже можно оптимизировать для фильтрации, их схемы намеренно ориентированы на рабочие нагрузки в стиле Prometheus и обычно не требуют изменений для стандартных графиков. Таблицы журналов и трассировок, напротив, поддерживают более широкий спектр access patterns, поэтому именно они выигрывают от настройки больше всего. Для данных сеансов пользовательский сценарий фиксирован, поэтому их схему редко приходится менять.
Как минимум, вам следует понимать следующие базовые принципы ClickHouse:
| Concept | Description |
|---|
| Таблицы | Как источники данных в ClickStack соотносятся с базовыми таблицами ClickHouse. В ClickHouse таблицы в основном используют движок MergeTree. |
| Части | Как данные записываются в неизменяемые части и со временем сливаются. |
| Партиции | Партиции группируют части данных таблицы в упорядоченные логические единицы. Ими проще управлять, выполнять по ним запросы и оптимизировать их. |
| Слияния | Внутренний процесс слияния частей, который уменьшает их количество и тем самым снижает объём данных, по которым выполняются запросы. Это критически важно для поддержания производительности запросов. |
| Гранулы | Наименьшая единица данных, которую ClickHouse читает и исключает при выполнении запроса. |
| Первичные (сортировочные) ключи | Как ключ ORDER BY определяет размещение данных на диске, сжатие и отсечение данных при выполнении запросов. |
Эти понятия лежат в основе производительности ClickHouse. Они определяют, как записываются данные, как они организованы на диске и насколько эффективно ClickHouse может пропускать чтение данных во время выполнения запроса. Любая оптимизация в этом руководстве — будь то материализованные столбцы, индекс пропуска данных, первичные ключи, проекции или materialized view — опирается на эти базовые механизмы.
Перед началом настройки рекомендуется ознакомиться со следующей документацией ClickHouse:
Все описанные ниже оптимизации можно применять непосредственно к базовым таблицам с помощью стандартного ClickHouse SQL — либо через SQL-консоль ClickHouse Cloud, либо через клиент ClickHouse.
Оптимизация 1. Материализуйте часто запрашиваемые атрибуты
Первая и самая простая оптимизация для пользователей ClickStack — выявить часто запрашиваемые атрибуты в LogAttributes, ScopeAttributes и ResourceAttributes и вынести их в столбцы верхнего уровня с помощью материализованных столбцов.
Одной этой оптимизации часто достаточно, чтобы масштабировать развертывания ClickStack до десятков терабайт в день, и применять ее следует прежде, чем переходить к более продвинутым методам тонкой настройки.
Зачем материализовать атрибуты
ClickStack хранит метаданные, такие как метки Kubernetes, метаданные сервисов и пользовательские атрибуты, в столбцах Map(String, String). Это обеспечивает гибкость, но у запросов к вложенным ключам map есть важная особенность с точки зрения производительности.
При запросе одного ключа из столбца Map ClickHouse приходится читать с диска весь столбец map целиком. Если map содержит много ключей, это приводит к лишним операциям IO и замедляет запросы по сравнению с чтением отдельного столбца.
Материализация часто используемых атрибутов устраняет эти накладные расходы: значение извлекается во время вставки и сохраняется как полноценный столбец.
Материализованные столбцы:
- Вычисляются автоматически во время вставки
- Не могут быть явно заданы в операторах INSERT
- Поддерживают любые выражения ClickHouse
- Позволяют преобразовывать типы из String в более эффективные числовые типы или типы даты
- Позволяют использовать индекс пропуска данных и первичный ключ
- Сокращают объём чтения с диска, избавляя от необходимости читать map целиком
ClickStack автоматически обнаруживает материализованные столбцы, извлечённые из map, и прозрачно использует их при выполнении запросов, даже если пользователи продолжают обращаться к исходному пути атрибута.
Рассмотрим схему трассировок ClickStack по умолчанию, в которой метаданные Kubernetes хранятся в ResourceAttributes:
CREATE TABLE IF NOT EXISTS otel_traces
(
`Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
`TraceId` String CODEC(ZSTD(1)),
`SpanId` String CODEC(ZSTD(1)),
`ParentSpanId` String CODEC(ZSTD(1)),
`TraceState` String CODEC(ZSTD(1)),
`SpanName` LowCardinality(String) CODEC(ZSTD(1)),
`SpanKind` LowCardinality(String) CODEC(ZSTD(1)),
`ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
`ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
`ScopeName` String CODEC(ZSTD(1)),
`ScopeVersion` String CODEC(ZSTD(1)),
`SpanAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
`Duration` UInt64 CODEC(ZSTD(1)),
`StatusCode` LowCardinality(String) CODEC(ZSTD(1)),
`StatusMessage` String CODEC(ZSTD(1)),
`Events.Timestamp` Array(DateTime64(9)) CODEC(ZSTD(1)),
`Events.Name` Array(LowCardinality(String)) CODEC(ZSTD(1)),
`Events.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
`Links.TraceId` Array(String) CODEC(ZSTD(1)),
`Links.SpanId` Array(String) CODEC(ZSTD(1)),
`Links.TraceState` Array(String) CODEC(ZSTD(1)),
`Links.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
`__hdx_materialized_rum.sessionId` String MATERIALIZED ResourceAttributes['rum.sessionId'] CODEC(ZSTD(1)),
INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_rum_session_id __hdx_materialized_rum.sessionId 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_span_attr_key mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_span_attr_value mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_duration Duration TYPE minmax GRANULARITY 1,
INDEX idx_lower_span_name lower(SpanName) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
TTL toDate(Timestamp) + toIntervalDay(30)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
Пользователь может фильтровать трассировки, используя синтаксис Lucene, например: ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c":
В результате получается SQL-предикат, похожий на следующий:
ResourceAttributes['k8s.pod.name'] = 'checkout-675775c4cc-f2p9c'
Поскольку здесь используется ключ Map, ClickHouse должен читать весь столбец ResourceAttributes для каждой подходящей строки — он может быть очень большим, если Map содержит много ключей.
Если по этому атрибуту часто выполняются запросы, его следует материализовать как столбец верхнего уровня.
Чтобы извлекать имя пода во время вставки, добавьте материализованный столбец:
ALTER TABLE otel_v2.otel_traces
ADD COLUMN PodName String
MATERIALIZED ResourceAttributes['k8s.pod.name']
С этого момента новые данные будут сохранять имя пода в отдельном столбце PodName.
Теперь пользователи могут эффективно выполнять запросы по именам подов, используя синтаксис Lucene, например PodName:"checkout-675775c4cc-f2p9c"
Для вновь вставленных данных это позволяет полностью избежать доступа к Map и значительно сократить I/O.
Однако даже если пользователи продолжают выполнять запросы по исходному пути атрибута, например ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c", ClickStack автоматически перепишет запрос внутри системы так, чтобы использовать материализованный столбец PodName, то есть с применением предиката:
PodName = 'checkout-675775c4cc-f2p9c'
Это гарантирует, что пользователи смогут воспользоваться преимуществами оптимизации без изменения панелей мониторинга, оповещений или сохранённых запросов.
По умолчанию материализованные столбцы исключаются из запросов SELECT *. Это сохраняет инвариант, согласно которому результаты запроса всегда можно снова вставить в таблицу.
Материализация исторических данных
Материализованные столбцы автоматически применяются только к данным, вставленным после создания столбца. Для существующих данных запросы к материализованному столбцу будут прозрачно обращаться к чтению из исходного map.
Если производительность запросов к историческим данным критически важна, столбец можно дозагрузить с помощью мутации, например:
ALTER TABLE otel_v2.otel_traces
MATERIALIZE COLUMN PodName
При этом переписываются существующие части, чтобы заполнить столбец. Мутации выполняются в одном потоке для каждой части и на больших объёмах данных могут занимать значительное время. Чтобы ограничить их влияние, мутации можно применить только к конкретной партиции:
ALTER TABLE otel_v2.otel_traces
MATERIALIZE COLUMN PodName
IN PARTITION '2026-01-02'
За прогрессом мутации можно следить с помощью таблицы system.mutations, например.
SELECT *
FROM system.mutations
WHERE database = 'otel'
AND table = 'otel_traces'
ORDER BY create_time DESC;
Дождитесь, пока для соответствующей мутации значение is_done не станет равным 1.
Мутации создают дополнительную нагрузку на IO и CPU, поэтому их следует использовать как можно реже. Во многих случаях достаточно дать старым данным естественным образом устареть и полагаться на повышение производительности для вновь поступающих данных.
Оптимизация 2. Добавление индексов пропуска данных
После материализации часто запрашиваемых атрибутов следующий шаг оптимизации — добавить индексы пропуска данных, чтобы ещё сильнее сократить объём данных, которые ClickHouse должен читать при выполнении запросов.
Индексы пропуска данных позволяют ClickHouse не сканировать целые блоки данных, если система может определить, что подходящих значений в них нет. В отличие от традиционных вторичных индексов, индексы пропуска данных работают на уровне гранул и наиболее эффективны, когда фильтры запроса исключают значительную часть набора данных. При правильном использовании они могут существенно ускорить фильтрацию по атрибутам с высокой мощностью без изменения семантики запроса.
Рассмотрим схему traces по умолчанию для ClickStack, которая включает индексы пропуска данных:
CREATE TABLE IF NOT EXISTS otel_traces
(
`Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
`TraceId` String CODEC(ZSTD(1)),
`SpanId` String CODEC(ZSTD(1)),
`ParentSpanId` String CODEC(ZSTD(1)),
`TraceState` String CODEC(ZSTD(1)),
`SpanName` LowCardinality(String) CODEC(ZSTD(1)),
`SpanKind` LowCardinality(String) CODEC(ZSTD(1)),
`ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
`ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
`ScopeName` String CODEC(ZSTD(1)),
`ScopeVersion` String CODEC(ZSTD(1)),
`SpanAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
`Duration` UInt64 CODEC(ZSTD(1)),
`StatusCode` LowCardinality(String) CODEC(ZSTD(1)),
`StatusMessage` String CODEC(ZSTD(1)),
`Events.Timestamp` Array(DateTime64(9)) CODEC(ZSTD(1)),
`Events.Name` Array(LowCardinality(String)) CODEC(ZSTD(1)),
`Events.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
`Links.TraceId` Array(String) CODEC(ZSTD(1)),
`Links.SpanId` Array(String) CODEC(ZSTD(1)),
`Links.TraceState` Array(String) CODEC(ZSTD(1)),
`Links.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
`__hdx_materialized_rum.sessionId` String MATERIALIZED ResourceAttributes['rum.sessionId'] CODEC(ZSTD(1)),
INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_rum_session_id __hdx_materialized_rum.sessionId 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_span_attr_key mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_span_attr_value mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_duration Duration TYPE minmax GRANULARITY 1,
INDEX idx_lower_span_name lower(SpanName) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
TTL toDate(Timestamp) + toIntervalDay(30)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
Эти индексы ориентированы на два распространённых шаблона:
- Фильтрацию строк с высокой мощностью, таких как TraceId, идентификаторы сеансов, ключи или значения атрибутов
- Фильтрацию по числовым диапазонам, например по длительности span
Индексы типа bloom-фильтр — наиболее часто используемый тип индекса пропуска данных в ClickStack. Они хорошо подходят для строковых столбцов с высокой мощностью, как правило, не менее десятков тысяч различных значений. Уровень ложноположительных срабатываний 0.01 при гранулярности 1 — хорошая отправная точка по умолчанию, которая обеспечивает баланс между затратами на хранилище и эффективным отсечением данных.
Продолжая пример из Optimization 1, предположим, что имя пода Kubernetes было материализовано из ResourceAttributes:
ALTER TABLE otel_traces
ADD COLUMN PodName String
MATERIALIZED ResourceAttributes['k8s.pod.name']
Затем можно добавить индекс пропуска данных с bloom-фильтром, чтобы ускорить фильтрацию по этому столбцу:
ALTER TABLE otel_traces
ADD INDEX idx_pod_name PodName
TYPE bloom_filter(0.01)
GRANULARITY 1
После добавления индекс пропуска данных необходимо материализовать — см. “Материализуйте индекс пропуска данных.”
После создания и материализации ClickHouse может пропускать целые гранулы, которые заведомо не содержат запрошенное имя пода, что потенциально сокращает объём данных, считываемых при выполнении таких запросов, как PodName:"checkout-675775c4cc-f2p9c".
bloom-фильтры наиболее эффективны, когда распределение значений таково, что конкретное значение встречается в относительно небольшом числе частей. Это часто естественным образом происходит в рабочих нагрузках обсервабилити, где такие метаданные, как имена подов, trace ID или идентификаторы сеансов, коррелируют со временем и, следовательно, группируются по ключу сортировки таблицы.
Как и любые индексы пропуска данных, bloom-фильтры следует добавлять выборочно и проверять на реальных шаблонах запросов, чтобы убедиться, что они приносят измеримую пользу — см. “Оценка эффективности индекса пропуска данных.”
Индексы Minmax хранят минимальное и максимальное значение для каждой гранулы и очень легковесны. Они особенно эффективны для числовых столбцов и диапазонных запросов. Хотя они ускоряют не каждый запрос, они почти ничего не стоят и для числовых полей их почти всегда стоит добавлять.
Индексы Minmax лучше всего работают, когда числовые значения либо естественным образом упорядочены, либо ограничены узкими диапазонами в пределах каждой части.
Предположим, что по смещению Kafka часто выполняются запросы в SpanAttributes:
SpanAttributes['messaging.kafka.offset']
Это значение можно материализовать и привести к числовому типу:
ALTER TABLE otel_traces
ADD COLUMN KafkaOffset UInt64
MATERIALIZED toUInt64(SpanAttributes['messaging.kafka.offset'])
Затем можно добавить индекс minmax:
ALTER TABLE otel_traces
ADD INDEX idx_kafka_offset KafkaOffset TYPE minmax GRANULARITY 1
Это позволяет ClickHouse эффективно пропускать части при фильтрации по диапазонам смещений Kafka, например при отладке отставания потребителя или повторного воспроизведения данных.
Индекс должен быть материализован, прежде чем станет доступен.
Материализация индекса пропуска данных
После добавления индекс пропуска данных применяется только к новым данным. Исторические данные не будут использовать этот индекс, пока он не будет явно материализован.
Если вы уже добавили индекс пропуска данных, например:
ALTER TABLE otel_traces ADD INDEX idx_kafka_offset KafkaOffset TYPE minmax GRANULARITY 1;
Необходимо явно построить индекс для уже существующих данных:
ALTER TABLE otel_traces MATERIALIZE INDEX idx_kafka_offset;
Материализация индексов пропуска данныхМатериализация индекса пропуска данных обычно является лёгкой и безопасной операцией, особенно для minmax-индексов. Для индексов bloom-фильтра на больших датасетах может быть удобнее выполнять материализацию по одной партиции, чтобы лучше контролировать потребление ресурсов, например:ALTER TABLE otel_v2.otel_traces
MATERIALIZE INDEX idx_kafka_offset
IN PARTITION '2026-01-02';
Материализация индекса пропуска данных выполняется как мутация. Её ход можно отслеживать с помощью системных таблиц.
SELECT *
FROM system.mutations
WHERE database = 'otel'
AND table = 'otel_traces'
ORDER BY create_time DESC;
Дождитесь, пока для соответствующей мутации is_done = 1.
После завершения убедитесь, что данные индекса созданы:
SELECT database, table, name,
data_compressed_bytes,
data_uncompressed_bytes,
marks_bytes
FROM system.data_skipping_indices
WHERE database = 'otel'
AND table = 'otel_traces'
AND name = 'idx_kafka_offset';
Ненулевые значения указывают на то, что индекс был успешно материализован.
Важно понимать, что размер индекса пропуска данных напрямую влияет на производительность запроса. Очень большие индексы пропуска данных — порядка десятков или сотен гигабайт — могут требовать заметного времени на вычисление во время выполнения запроса, что может уменьшить или даже полностью свести на нет их пользу.
На практике индексы MinMax обычно очень компактны и почти не требуют затрат на вычисление, поэтому их материализация почти всегда безопасна. Индексы bloom-фильтра, напротив, могут значительно разрастаться в зависимости от мощности, гранулярности и вероятности ложноположительного срабатывания.
Размер bloom-фильтра можно уменьшить, повысив допустимую вероятность ложноположительного срабатывания. Например, увеличение параметра вероятности с 0.01 до 0.05 даёт индекс меньшего размера, который вычисляется быстрее, но ценой менее агрессивного отсечения. Хотя пропускаться будет меньше гранул, общая задержка запроса может снизиться за счёт более быстрой обработки индекса.
Поэтому настройка параметров bloom-фильтра — это оптимизация, зависящая от рабочей нагрузки, и её следует проверять на реальных шаблонах запросов и объёмах данных, близких к продакшн.
Дополнительные сведения об индексах пропуска данных см. в руководстве “Понимание индексов пропуска данных в ClickHouse.”
Оценка эффективности индекса пропуска данных
Самый надежный способ оценить эффективность отсечения индексом пропуска данных — использовать EXPLAIN indexes = 1, который показывает, сколько частей и гранул отсекается на каждом этапе планирования запроса. В большинстве случаев желательно видеть существенное сокращение числа гранул на этапе Skip — в идеале после того, как первичный ключ уже сузил пространство поиска. Индексы пропуска данных оцениваются после отсечения партиций и отсечения по первичному ключу, поэтому их влияние лучше всего измерять относительно оставшихся частей и гранул.
EXPLAIN подтверждает, происходит ли отсечение, но не гарантирует итогового ускорения. Вычисление индексов пропуска данных требует затрат, особенно если индекс большой. Всегда выполняйте бенчмарк запросов до и после добавления индекса и его материализации, чтобы подтвердить реальное повышение производительности.
Например, рассмотрим индекс пропуска данных с bloom-фильтром по умолчанию для TraceId, включенный в схему Traces по умолчанию:
INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1
Вы можете использовать EXPLAIN indexes = 1, чтобы оценить, насколько это эффективно для селективного запроса:
EXPLAIN indexes = 1
SELECT *
FROM otel_v2.otel_traces
WHERE (ServiceName = 'accounting')
AND (TraceId = 'aeea7f401feb75fc5af8eb25ebc8e974');
ReadFromMergeTree (otel_v2.otel_traces)
Indexes:
PrimaryKey
Keys:
ServiceName
Parts: 6/18
Granules: 255/35898
Skip
Name: idx_trace_id
Description: bloom_filter GRANULARITY 1
Parts: 1/6
Granules: 1/255
В этом случае фильтр по первичному ключу сначала существенно сокращает набор данных (с 35898 гранул до 255), а затем bloom-фильтр дополнительно отсекает всё до одной гранулы (1/255). Это идеальный сценарий для индексов пропуска данных: отсечение по первичному ключу сужает область поиска, а затем индекс пропуска данных отсекает большую часть оставшегося.
Чтобы оценить реальный эффект, выполните бенчмарк запроса со стабильными настройками и сравните время выполнения. Используйте FORMAT Null, чтобы избежать накладных расходов на сериализацию результата, и отключите кэш условий запроса, чтобы прогоны оставались воспроизводимыми:
SELECT *
FROM otel_traces
WHERE (ServiceName = 'accountingservice') AND (TraceId = '4512e822ca3c0c68bbf5d4a263f9943d')
SETTINGS use_query_condition_cache = 0
2 rows in set. Elapsed: 0.025 sec. Processed 8.52 thousand rows, 299.78 KB (341.22 thousand rows/s., 12.00 MB/s.)
Пиковое потребление памяти: 41.97 MiB.
Теперь выполните тот же запрос с отключёнными индексами пропуска данных:
SELECT *
FROM otel_traces
WHERE (ServiceName = 'accountingservice') AND (TraceId = '4512e822ca3c0c68bbf5d4a263f9943d')
FORMAT Null
SETTINGS use_query_condition_cache = 0, use_skip_indexes = 0;
0 rows in set. Elapsed: 0.702 sec. Processed 1.62 million rows, 56.62 MB (2.31 million rows/s., 80.71 MB/s.)
Peak memory usage: 198.39 MiB.
Отключение use_query_condition_cache гарантирует, что результаты не будут зависеть от кэшированных решений о фильтрации, а установка use_skip_indexes = 0 дает чистую отправную точку для сравнения. Если отсечение данных эффективно, а затраты на вычисление индекса невелики, запрос с индексом должен быть заметно быстрее, как в примере выше.
Если EXPLAIN показывает минимальное отсечение гранул или индекс пропуска данных очень велик, затраты на вычисление индекса могут свести на нет весь выигрыш. Используйте EXPLAIN indexes = 1, чтобы подтвердить отсечение, а затем выполните бенчмарк, чтобы подтвердить итоговый прирост производительности.
Когда добавлять индексы пропуска данных
Индексы пропуска данных следует добавлять выборочно, исходя из того, какие фильтры пользователи применяют чаще всего, и как устроены данные в частях и гранулах. Цель состоит в том, чтобы отсечь достаточно гранул и тем самым компенсировать затраты на вычисление самого индекса, поэтому бенчмаркинг на данных, близких к продакшн, крайне важен.
Для числовых столбцов, используемых в фильтрах, индекс пропуска данных minmax почти всегда является удачным выбором. Он лёгкий, дёшев в вычислении и может быть эффективен для диапазонных предикатов — особенно когда значения слабо упорядочены или ограничены узкими диапазонами внутри частей. Даже если minmax не помогает для конкретного шаблона запроса, его накладные расходы обычно настолько малы, что его всё равно имеет смысл оставить.
Строковые столбцы. Используйте bloom-фильтры, когда мощность множества значений высока, а сами значения разрежены.
Bloom-фильтры наиболее эффективны для строковых столбцов с высокой мощностью, где каждое значение встречается сравнительно редко, то есть в большинстве частей и гранул искомое значение отсутствует. Как правило, bloom-фильтры наиболее перспективны, когда столбец содержит не менее 10 000 различных значений, и часто показывают наилучшие результаты при 100 000+ различных значений. Они также эффективнее, когда совпадающие значения сгруппированы в небольшом числе последовательных частей, что обычно происходит, когда столбец коррелирует с ключом сортировки. И здесь результат может отличаться — ничто не заменит тестирование на реальных данных.
Оптимизация 3. Изменение первичного ключа
Первичный ключ — один из важнейших элементов настройки производительности ClickHouse для большинства рабочих нагрузок. Чтобы эффективно его настраивать, нужно понимать, как он работает и как соотносится с вашими шаблонами запросов. В конечном итоге первичный ключ должен соответствовать тому, как пользователи обращаются к данным, и особенно тому, по каким столбцам они чаще всего фильтруют данные.
Хотя первичный ключ также влияет на сжатие и структуру хранения, его основное назначение — производительность запросов. В ClickStack первичные ключи по умолчанию уже оптимизированы под наиболее распространённые паттерны доступа в задачах обсервабилити и обеспечивают хорошее сжатие. Ключи по умолчанию для таблиц журналов, трассировок и метрик спроектированы так, чтобы хорошо работать в типовых сценариях.
Фильтрация по столбцам, которые расположены раньше в первичном ключе, эффективнее, чем по столбцам, расположенным позже. Хотя конфигурации по умолчанию достаточно для большинства пользователей, в некоторых случаях изменение первичного ключа может повысить производительность для конкретных рабочих нагрузок.
Примечание о терминологииВ этом документе термин “ключ сортировки” используется как взаимозаменяемый с термином “первичный ключ”. Строго говоря, в ClickHouse это разные понятия, но в ClickStack они обычно относятся к одним и тем же столбцам, указанным в ORDER BY таблицы. Подробнее см. документацию ClickHouse о выборе первичного ключа, отличающегося от ключа сортировки.
Прежде чем изменять какой-либо первичный ключ, настоятельно рекомендуем ознакомиться с нашим руководством по пониманию работы первичных индексов в ClickHouse:
Настройка первичного ключа зависит от конкретной таблицы и типа данных. Изменение, полезное для одной таблицы и одного типа данных, может не подойти для других. Цель всегда состоит в том, чтобы оптимизировать работу с определённым типом данных, например с журналами.
Обычно оптимизируют таблицы журналов и трассировок. Необходимость изменять первичный ключ для других типов данных возникает редко.
Ниже приведены первичные ключи по умолчанию для таблиц ClickStack с журналами и метриками.
- Логи (
otel_logs) - (ServiceName, TimestampTime, Timestamp)
- Трассировки (‘otel_traces) -
(ServiceName, SpanName, toDateTime(Timestamp))
См. “Таблицы и схемы, используемые в ClickStack”, чтобы узнать, какие первичные ключи используются в таблицах для других типов данных. Например, таблицы трассировок оптимизированы для фильтрации по имени сервиса и имени span, а затем по временной метке и trace ID. Таблицы журналов, напротив, оптимизированы для фильтрации по имени сервиса, затем по дате и затем по временной метке. Хотя оптимально, если пользователь применяет фильтры в порядке столбцов первичного ключа, запросы всё равно существенно выигрывают от фильтрации по любому из этих столбцов в любом порядке, поскольку ClickHouse отсекает данные до чтения.
При выборе первичного ключа есть и другие факторы, которые влияют на оптимальный порядок столбцов. См. “Выбор первичного ключа.”
Первичные ключи следует изменять отдельно для каждой таблицы. То, что имеет смысл для журналов, может не подходить для трассировок или метрик.
Сначала определите, насколько ваши паттерны доступа для конкретной таблицы отличаются от значений по умолчанию. Например, если чаще всего вы фильтруете журналы по узлу Kubernetes, а уже потом по имени сервиса, и это основной сценарий работы, изменение первичного ключа может быть оправданно.
Изменение первичного ключа по умолчаниюПервичные ключи по умолчанию достаточны в большинстве случаев. Вносить изменения следует с осторожностью и только при четком понимании шаблонов запросов. Изменение первичного ключа может ухудшить производительность других сценариев, поэтому тестирование обязательно.
После того как вы определили нужные столбцы, можно приступать к оптимизации ключа сортировки/первичного ключа.
Чтобы упростить выбор ключа сортировки, можно руководствоваться несколькими простыми правилами. Иногда они могут противоречить друг другу, поэтому рассматривайте их по порядку. Старайтесь выбирать не более 4–5 ключей:
- Выбирайте столбцы, соответствующие вашим типичным фильтрам и паттернам доступа. Если вы обычно начинаете расследование в области обсервабилити с фильтрации по конкретному столбцу, например по имени пода, этот столбец будет часто использоваться в секциях
WHERE. Такие столбцы стоит включать в ключ в первую очередь, а не те, которые используются реже.
- Предпочитайте столбцы, которые при фильтрации позволяют исключить большую часть строк и тем самым сократить объем читаемых данных. Имена сервисов и коды status часто являются хорошими кандидатами — во втором случае только если вы фильтруете по значениям, исключающим большинство строк; например, фильтрация по кодам 200 в большинстве систем охватит большую часть строк, тогда как ошибки 500 будут соответствовать лишь небольшому подмножеству.
- Предпочитайте столбцы, которые, вероятно, будут сильно коррелировать с другими столбцами таблицы. Это поможет обеспечить их смежное хранение, что улучшит сжатие.
- Операции
GROUP BY (агрегации для диаграмм) и ORDER BY (сортировка) по столбцам из ключа сортировки могут быть более эффективны с точки зрения использования памяти.
После того как вы определили подмножество столбцов для ключа сортировки, их нужно объявить в определенном порядке. Этот порядок может существенно влиять как на эффективность фильтрации по вторичным столбцам ключа в запросах, так и на коэффициент сжатия файлов данных таблицы. В общем случае лучше располагать ключи в порядке возрастания мощности. Однако это нужно соотносить с тем, что фильтрация по столбцам, расположенным ближе к концу ключа сортировки, будет менее эффективной, чем по столбцам, находящимся в начале кортежа. Учитывайте этот баланс и свои паттерны доступа. Самое главное — тестируйте разные варианты. Чтобы лучше понять ключи сортировки и способы их оптимизации, рекомендуется прочитать «Выбор первичного ключа», а для еще более глубокого изучения настройки первичного ключа и внутренних структур данных см. «Практическое введение в первичные разреженные индексы в ClickHouse»
Изменение первичного ключа
Если вы заранее уверены в шаблонах доступа к данным, просто удалите и заново создайте таблицу для соответствующего типа данных.
В примере ниже показан простой способ создать новую таблицу журналов с существующей схемой, но с новым первичным ключом, в котором столбец SeverityText расположен перед ServiceName.
Создайте новую таблицу
CREATE TABLE otel_logs_temp AS otel_logs
PRIMARY KEY (SeverityText, ServiceName, TimestampTime)
ORDER BY (SeverityText, ServiceName, TimestampTime)
Ключ сортировки и первичный ключОбратите внимание: в примере выше необходимо указать PRIMARY KEY и ORDER BY.
В ClickStack они почти всегда совпадают.
ORDER BY управляет физической организацией данных, а PRIMARY KEY определяет разреженный индекс.
В редких случаях при очень больших рабочих нагрузках они могут различаться, но большинству пользователей следует держать их согласованными.
Обменяйте и удалите таблицу
Оператор EXCHANGE используется, чтобы атомарно поменять имена таблиц местами. Временную таблицу (теперь это старая таблица по умолчанию) можно удалить.EXCHANGE TABLES otel_logs_temp AND otel_logs
DROP TABLE otel_logs_temp
Однако первичный ключ нельзя изменить в существующей таблице. Чтобы изменить его, нужно создать новую таблицу.
Следующий процесс позволяет сохранить старые данные и при этом прозрачно продолжать выполнять к ним запросы (при необходимости — используя их существующий ключ в HyperDX), а новые данные направлять в новую таблицу, оптимизированную под шаблоны доступа пользователей. Такой подход позволяет не менять конвейеры приёма: данные по-прежнему отправляются в таблицы с именами по умолчанию, а все изменения остаются незаметными для пользователей.
Дозагрузка существующих данных в новую таблицу в крупных инсталляциях редко оправданна. Затраты на вычислительные ресурсы и IO обычно высоки и не окупаются выигрышем в производительности. Вместо этого дайте старым данным истечь через TTL, а новые данные пусть используют преимущества улучшенного ключа.
Ниже используется тот же пример, где SeverityText добавляется как первый столбец первичного ключа. В этом случае новая таблица создаётся для новых данных, а старая сохраняется для исторического анализа.
Создайте новую таблицу
Создайте новую таблицу с нужным первичным ключом. Обратите внимание на суффикс _23_01_2025 — замените его на текущую дату. Например:CREATE TABLE otel_logs_23_01_2025 AS otel_logs
PRIMARY KEY (SeverityText, ServiceName, TimestampTime)
ORDER BY (SeverityText, ServiceName, TimestampTime)
Создайте Merge-таблицу
Движок Merge (не путать с MergeTree) сам не хранит данные, но позволяет одновременно читать из любого числа других таблиц.CREATE TABLE otel_logs_merge
AS otel_logs
ENGINE = Merge(currentDatabase(), 'otel_logs*')
currentDatabase() предполагает, что команда выполняется в правильной базе данных. В противном случае явно укажите имя базы данных.
Теперь вы можете выполнить запрос к этой таблице и убедиться, что она возвращает данные из otel_logs.Обновите HyperDX, чтобы читать из merge-таблицы
Настройте HyperDX на использование otel_logs_merge в качестве таблицы для источника данных журналов.На этом этапе запись по-прежнему идёт в otel_logs с исходным первичным ключом, а чтение выполняется через merge-таблицу. Для пользователей ничего не меняется, и на ингестию это не влияет.Поменяйте таблицы местами
Теперь оператор EXCHANGE используется для атомарной перестановки имён таблиц otel_logs и otel_logs_23_01_2025.EXCHANGE TABLES otel_logs AND otel_logs_23_01_2025
Теперь запись идёт в новую таблицу otel_logs с обновлённым первичным ключом. Существующие данные остаются в otel_logs_23_01_2025 и по-прежнему доступны через merge-таблицу. Суффикс указывает дату применения изменения и соответствует самой поздней временной метке в этой таблице.Этот процесс позволяет изменить первичный ключ без прерывания приёма и без какого-либо заметного влияния для пользователей.
Этот процесс можно адаптировать, если потребуется внести дополнительные изменения в первичный ключ. Например, если через неделю вы решите, что в состав первичного ключа на самом деле должен входить SeverityNumber, а не SeverityText. Описанный ниже процесс можно повторять столько раз, сколько потребуется при изменении первичного ключа.
Создайте новую таблицу
Создайте новую таблицу с нужным первичным ключом.
В примере ниже 30_01_2025 используется в качестве суффикса, обозначающего дату таблицы. Например:CREATE TABLE otel_logs_30_01_2025 AS otel_logs
PRIMARY KEY (SeverityNumber, ServiceName, TimestampTime)
ORDER BY (SeverityNumber, ServiceName, TimestampTime)
Обменяйте таблицы
Теперь оператор EXCHANGE используется для атомарной замены имен таблиц otel_logs и otel_logs_30_01_2025.EXCHANGE TABLES otel_logs AND otel_logs_30_01_2025
Теперь запись идет в новую таблицу otel_logs с обновленным первичным ключом. Старые данные остаются в otel_logs_30_01_2025 и доступны через merge-таблицу.
Избыточные таблицыЕсли настроены политики TTL, что рекомендуется, таблицы со старыми первичными ключами, в которые больше не записываются данные, будут постепенно освобождаться по мере истечения срока хранения данных. Их следует отслеживать и периодически очищать, когда в них больше не остается данных. В настоящее время этот процесс очистки выполняется вручную.
Оптимизация 4. Использование materialized view
ClickStack может использовать incremental materialized view для ускорения визуализаций, которые зависят от запросов с интенсивной агрегацией, например при расчете средней длительности запросов по минутам во времени. Эта возможность может значительно повысить производительность запросов и обычно наиболее полезна для крупных развертываний — от 10 ТБ в день и выше, — при этом позволяя масштабироваться до диапазона PB в день. incremental materialized view находятся в статусе бета, поэтому использовать их следует с осторожностью.
Подробнее об использовании этой возможности в ClickStack см. в отдельном руководстве “ClickStack - Materialized Views.”
Оптимизация 5. Использование проекций
Проекции — это заключительный, продвинутый этап оптимизации, к которому стоит переходить после рассмотрения материализованных столбцов, индексов пропуска данных, первичных ключей и materialized views. Хотя проекции и materialized views могут показаться похожими, в ClickStack они решают разные задачи и лучше подходят для разных сценариев.
На практике проекцию можно рассматривать как дополнительную скрытую копию таблицы, которая хранит те же строки в ином физическом порядке. Благодаря этому у проекции есть собственный первичный индекс, отличный от ключа ORDER BY базовой таблицы, что позволяет ClickHouse эффективнее отсекать данные для шаблонов доступа, не соответствующих исходному порядку.
Materialized views могут давать схожий эффект, явно записывая строки в отдельную целевую таблицу с другим ключом сортировки. Главное отличие в том, что проекции поддерживаются ClickHouse автоматически и прозрачно, тогда как materialized views — это явные таблицы, которые ClickStack должен регистрировать и выбирать явно.
Когда запрос обращается к базовой таблице, ClickHouse оценивает базовую структуру и все доступные проекции, анализирует их первичные индексы и выбирает ту структуру, которая позволяет получить корректный результат, прочитав минимальное число гранул. Это решение автоматически принимает анализатор запросов.
Поэтому в ClickStack проекции лучше всего подходят для простого переупорядочивания данных, когда:
- Шаблоны доступа принципиально отличаются от первичного ключа по умолчанию
- Непрактично охватить все сценарии работы одним ключом сортировки
- Вы хотите, чтобы ClickHouse прозрачно выбирал оптимальную физическую структуру
Для предварительной агрегации и ускорения метрик ClickStack настоятельно рекомендует явные materialized views, которые дают уровню приложения полный контроль над выбором и использованием представлений.
Дополнительные сведения см. здесь:
Предположим, что ваша таблица трассировок оптимизирована под стандартный сценарий доступа в ClickStack:
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
Если у вас есть основной сценарий работы, в котором фильтрация выполняется по TraceId (или данные часто группируются и фильтруются по нему), вы можете добавить проекцию, которая хранит строки, отсортированные по TraceId и времени:
ALTER TABLE otel_v2.otel_traces
ADD PROJECTION prj_traceid_time
(
SELECT *
ORDER BY (TraceId, toDateTime(Timestamp))
);
Используйте подстановочные шаблоныВ примере с проекцией выше используется подстановочный шаблон (SELECT *). Хотя выбор подмножества столбцов может снизить накладные расходы на запись, это также ограничивает случаи, когда проекцию можно использовать, поскольку подходят только те запросы, которые можно полностью выполнить по этим столбцам. В ClickStack это часто сводит использование проекций к очень узким сценариям. Поэтому обычно рекомендуется использовать подстановочный шаблон, чтобы максимально расширить область применения.
Как и другие изменения структуры данных, проекция влияет только на вновь записанные части. Чтобы применить её к существующим данным, материализуйте её:
ALTER TABLE otel_v2.otel_traces
MATERIALIZE PROJECTION prj_traceid_time;
Материализация проекции может занять много времени и потребовать значительных ресурсов. Поскольку данные обсервабилити обычно удаляются по TTL, делать это следует только в случае крайней необходимости. В большинстве случаев достаточно, чтобы проекция применялась только к вновь принимаемым данным, оптимизируя наиболее часто запрашиваемые временные диапазоны, например последние 24 часа.
ClickHouse может автоматически выбрать проекцию, если оценит, что она будет сканировать меньше гранул, чем базовая структура. Проекции работают наиболее надежно, когда представляют собой простое переупорядочивание полного набора строк (SELECT *), а фильтры запроса хорошо согласуются с ORDER BY проекции.
Запросы с фильтрацией по TraceId (особенно на точное совпадение) и указанием временного диапазона выиграют от приведенной выше проекции. Например:
-- Быстрое получение конкретного трейса
SELECT *
FROM otel_traces
WHERE TraceId = 'aeea7f401feb75fc5af8eb25ebc8e974'
AND Timestamp >= now() - INTERVAL 1 DAY
ORDER BY Timestamp;
-- Агрегация в рамках трейса
SELECT
toStartOfMinute(Timestamp) AS t,
count() AS spans
FROM otel_traces
WHERE TraceId = 'aeea7f401feb75fc5af8eb25ebc8e974'
AND Timestamp >= now() - INTERVAL 1 DAY
GROUP BY t
ORDER BY t;
Запросы, которые не ограничивают TraceId или в основном фильтруют по другим измерениям, не являющимся первыми в ключе сортировки проекции, обычно не дают выигрыша (и вместо этого могут читать данные из базовой структуры).
Проекции также могут хранить агрегации (аналогично materialized views). В ClickStack агрегации на основе проекций обычно не рекомендуются, поскольку выбор зависит от анализатора ClickHouse, а их использование сложнее контролировать и предсказать. Вместо этого лучше использовать явные materialized views, которые ClickStack может регистрировать и целенаправленно выбирать на уровне приложения.
На практике проекции лучше всего подходят для сценариев, где вы часто переходите от более широкого поиска к детальному анализу конкретного трейса (например, извлекая все spans для определённого TraceId).
- Накладные расходы на вставку: Проекция
SELECT * с другим ключом сортировки фактически приводит к двойной записи данных, что увеличивает I/O при записи и может потребовать дополнительных ресурсов CPU и пропускной способности диска для поддержания ингестии.
- Используйте экономно: Проекции стоит применять только для действительно разных сценариев доступа, когда второй физический порядок даёт заметное отсечение данных для большой доли запросов — например, если две команды работают с одним и тем же набором данных принципиально по-разному.
- Проверяйте с помощью бенчмарков: Как и при любой оптимизации, сравнивайте реальную задержку запросов и использование ресурсов до и после добавления и материализации проекции.
Более подробно см.:
Облегчённые проекции с _part_offset
Облегчённые проекции — в статусе бета для ClickStackОблегчённые проекции на основе _part_offset не рекомендуются для рабочих нагрузок ClickStack. Хотя они уменьшают объём хранилища и I/O при записи, они могут приводить к большему числу произвольных обращений при выполнении запросов, а их поведение в продакшне при нагрузках масштаба обсервабилити всё ещё оценивается. Эта рекомендация может измениться по мере развития этой возможности и накопления большего объёма эксплуатационных данных.
Новые версии ClickHouse также поддерживают ещё более лёгкие проекции, которые хранят только ключ сортировки проекции и указатель _part_offset в базовой таблице вместо дублирования полных строк. Это может значительно сократить накладные расходы на хранение, а недавние улучшения позволяют выполнять pruning на уровне гранул, из-за чего такие проекции всё больше напоминают настоящие вторичные индексы. См.:
Если вам нужны несколько ключей сортировки, проекции — не единственный вариант. В зависимости от операционных ограничений и того, как вы хотите, чтобы ClickStack направлял запросы, рассмотрите следующие варианты:
- Настройте OpenTelemetry Collector так, чтобы он записывал данные в две таблицы с разными ключами
ORDER BY, и создайте отдельные источники ClickStack для каждой таблицы.
- Создайте materialized view как конвейер копирования, то есть подключите materialized view к основной таблице так, чтобы она записывала необработанные строки во вторичную таблицу с другим ключом сортировки (это шаблон денормализации или маршрутизации). Создайте источник для этой целевой таблицы. Примеры можно найти здесь.
Последнее изменение 10 июня 2026 г.