Введение
Основные понятия ClickHouse
Таблицы относятся к базам данных в ClickHouse. По умолчанию используется база данных
default, но это можно изменить в OpenTelemetry Collector.
Как минимум, вам следует понимать следующие базовые принципы ClickHouse:
Эти понятия лежат в основе производительности ClickHouse. Они определяют, как записываются данные, как они организованы на диске и насколько эффективно ClickHouse может пропускать чтение данных во время выполнения запроса. Любая оптимизация в этом руководстве — будь то материализованные столбцы, индекс пропуска данных, первичные ключи, проекции или materialized view — опирается на эти базовые механизмы.
Перед началом настройки рекомендуется ознакомиться со следующей документацией ClickHouse:
- Создание таблиц в ClickHouse - Простое введение в таблицы.
- Части
- Партиции
- Слияния
- Первичные ключи/индексы
- Как ClickHouse хранит данные: части и гранулы - Более продвинутое руководство о том, как данные организованы и запрашиваются в ClickHouse, с подробным разбором гранул и первичных ключей.
- MergeTree- Расширенное справочное руководство по MergeTree, полезное для команд и внутренних деталей.
Оптимизация 1. Материализуйте часто запрашиваемые атрибуты
LogAttributes, ScopeAttributes и ResourceAttributes и вынести их в столбцы верхнего уровня с помощью материализованных столбцов.
Одной этой оптимизации часто достаточно, чтобы масштабировать развертывания ClickStack до десятков терабайт в день, и применять ее следует прежде, чем переходить к более продвинутым методам тонкой настройки.
Зачем материализовать атрибуты
Map(String, String). Это обеспечивает гибкость, но у запросов к вложенным ключам map есть важная особенность с точки зрения производительности.
При запросе одного ключа из столбца Map ClickHouse приходится читать с диска весь столбец map целиком. Если map содержит много ключей, это приводит к лишним операциям IO и замедляет запросы по сравнению с чтением отдельного столбца.
Материализация часто используемых атрибутов устраняет эти накладные расходы: значение извлекается во время вставки и сохраняется как полноценный столбец.
Материализованные столбцы:
- Вычисляются автоматически во время вставки
- Не могут быть явно заданы в операторах INSERT
- Поддерживают любые выражения ClickHouse
- Позволяют преобразовывать типы из String в более эффективные числовые типы или типы даты
- Позволяют использовать индекс пропуска данных и первичный ключ
- Сокращают объём чтения с диска, избавляя от необходимости читать map целиком
ClickStack автоматически обнаруживает материализованные столбцы, извлечённые из map, и прозрачно использует их при выполнении запросов, даже если пользователи продолжают обращаться к исходному пути атрибута.
Пример
ResourceAttributes:
ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c":
В результате получается SQL-предикат, похожий на следующий:
ResourceAttributes для каждой подходящей строки — он может быть очень большим, если Map содержит много ключей.
Если по этому атрибуту часто выполняются запросы, его следует материализовать как столбец верхнего уровня.
Чтобы извлекать имя пода во время вставки, добавьте материализованный столбец:
PodName.
Теперь пользователи могут эффективно выполнять запросы по именам подов, используя синтаксис Lucene, например PodName:"checkout-675775c4cc-f2p9c"
Для вновь вставленных данных это позволяет полностью избежать доступа к Map и значительно сократить I/O.
Однако, даже если пользователи продолжат выполнять запросы по исходному пути атрибута, например ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c", ClickStack автоматически перепишет запрос внутри системы так, чтобы использовать материализованный столбец PodName, то есть с предикатом:
По умолчанию материализованные столбцы исключаются из запросов
SELECT *. Это сохраняет инвариант, согласно которому результаты запроса всегда можно повторно вставить в таблицу.Материализация исторических данных
system.mutations, например.
is_done не станет равным 1.
Оптимизация 2. Добавление индексов пропуска данных
- Фильтрацию строк с высокой мощностью, таких как TraceId, идентификаторы сеансов, ключи или значения атрибутов
- Фильтрацию по подключам Map, ускоренную текстовыми индексами в столбцах
*AttributeItems - Фильтрацию по числовым диапазонам, например по длительности span
text(tokenizer = 'array') вместо bloom-фильтров, а также добавляет индекс text(tokenizer = 'splitByNonAlpha') для lower(Body) для полнотекстового поиска. Полный DDL см. в разделе “Таблицы и схемы, используемые ClickStack”.
bloom-фильтры
PodName:"checkout-675775c4cc-f2p9c".
bloom-фильтры наиболее эффективны, когда распределение значений таково, что конкретное значение встречается в относительно небольшом числе частей. Это часто естественным образом происходит в рабочих нагрузках обсервабилити, где такие метаданные, как имена подов, trace ID или идентификаторы сеансов, коррелируют со временем и, следовательно, группируются по ключу сортировки таблицы.
Как и любые индексы пропуска данных, bloom-фильтры следует добавлять выборочно и проверять на реальных шаблонах запросов, чтобы убедиться, что они приносят измеримую пользу — см. “Оценка эффективности индекса пропуска данных.”
Текстовые индексы
WHERE. Текстовые индексы — это инвертированные индексы, которые сопоставляют токены с точными смещениями внутри части. Поскольку они анализируют смещения, а не гранулы, и не дают ложноположительных срабатываний, они обычно позволяют обработать условие WHERE без загрузки исходного столбца. Эта оптимизация называется прямое чтение. Поскольку загрузка данных часто занимает основную часть времени выполнения запроса, прямое чтение может заметно снизить задержку запроса.
Кроме того, по самим текстовым индексам тоже можно выполнять запросы, что обеспечивает автодополнение и другие возможности интроспекции в ClickStack.
Два токенизатора покрывают большинство сценариев ClickStack:
Токенизатор Array для Map и столбцов типа Array
mapKeys и материализованных массивов элементов
используется токенизатор array:
splitByNonAlpha для тел log-записей
Body помогает текстовый
индекс splitByNonAlpha. ClickStack определяет этот индекс для lower(Body),
чтобы его могли использовать регистронезависимые поисковые запросы Lucene:
text(tokenizer = 'splitByNonAlpha') для
lower(Body), он преобразует Lucene-запросы с неявно указанным столбцом, такие как error или
"connection refused", в hasAllTokens(lower(Body), lower(...)), так что
индекс может обработать их без чтения всего столбца Body. Для большинства
рабочих нагрузок с логами в обсервабилити это самое существенное доступное
ускорение фильтрации.
Текстовые индексы и
tokenbf_v1Более старый тип индекса tokenbf_v1 (он по-прежнему используется в схеме traces по умолчанию для
lower(SpanName)) функционально похож, но в ClickHouse 26.2
и выше помечен как устаревший. Для новых индексов полнотекстового поиска следует использовать text(tokenizer = ...).Текстовые индексы в схеме журналов по умолчанию
otel_logs по умолчанию, синхронизированная с upstream, содержит все рассмотренные выше текстовые индексы: text(tokenizer = 'array') для TraceId, каждого mapKeys(...) и массива *AttributeItems, а также text(tokenizer = 'splitByNonAlpha') для lower(Body) для полнотекстового поиска. Канонический DDL см. в разделе “Таблицы и схемы, используемые ClickStack”; та же схема приведена ниже.
Индексы minmax
SpanAttributes:
Материализация индекса пропуска данных
Материализация индексов пропуска данныхМатериализация индекса пропуска данных обычно является лёгкой и безопасной операцией, особенно для индексов minmax. Для индексов bloom-фильтра на больших датасетах может быть удобнее выполнять материализацию по одной партиции, чтобы лучше контролировать потребление ресурсов, например:
is_done = 1.
После завершения убедитесь, что данные индекса созданы:
0.01 до 0.05 даёт индекс меньшего размера, который вычисляется быстрее, но ценой менее агрессивного отсечения. Хотя пропускаться будет меньше гранул, общая задержка запроса может снизиться за счёт более быстрой обработки индекса.
Поэтому настройка параметров bloom-фильтра — это оптимизация, зависящая от рабочей нагрузки, и её следует проверять на реальных шаблонах запросов и объёмах данных, близких к продакшн.
Дополнительные сведения об индексах пропуска данных см. в руководстве “Понимание индексов пропуска данных в ClickHouse.”
Оценка эффективности индекса пропуска данных
EXPLAIN indexes = 1, который показывает, сколько частей и гранул отсекается на каждом этапе планирования запроса. В большинстве случаев желательно видеть существенное сокращение числа гранул на этапе Skip — в идеале после того, как первичный ключ уже сузил пространство поиска. Индексы пропуска данных оцениваются после отсечения партиций и отсечения по первичному ключу, поэтому их влияние лучше всего измерять относительно оставшихся частей и гранул.
EXPLAIN подтверждает, происходит ли отсечение, но не гарантирует итогового ускорения. Вычисление индексов пропуска данных требует затрат, особенно если индекс большой. Всегда выполняйте бенчмарк запросов до и после добавления индекса и его материализации, чтобы подтвердить реальное повышение производительности.
Например, рассмотрим индекс пропуска данных с bloom-фильтром по умолчанию для TraceId, включенный в схему Traces по умолчанию:
EXPLAIN indexes = 1, чтобы оценить, насколько это эффективно для селективного запроса:
FORMAT Null, чтобы избежать накладных расходов на сериализацию результата, и отключите кэш условий запроса, чтобы прогоны оставались воспроизводимыми:
use_query_condition_cache гарантирует, что результаты не будут зависеть от кэшированных решений о фильтрации, а установка use_skip_indexes = 0 дает чистую отправную точку для сравнения. Если отсечение данных эффективно, а затраты на вычисление индекса невелики, запрос с индексом должен быть заметно быстрее, как в примере выше.
Когда добавлять индексы пропуска данных
IN, что и bloom-фильтры, а также дополнительно позволяют использовать предикаты на основе токенов (hasToken, hasAllTokens, has), применяемые в полнотекстовом поиске и оптимизации прямого чтения Map. В старых кластерах, которые ещё не поддерживают текстовые индексы, bloom-фильтры по-прежнему остаются надёжным выбором.
bloom-фильтры наиболее эффективны для строковых столбцов с высокой мощностью, где каждое значение встречается сравнительно редко, то есть в большинстве частей и гранул искомое значение отсутствует. Как правило, bloom-фильтры наиболее перспективны, когда столбец содержит не менее 10 000 различных значений, и часто показывают наилучшие результаты при 100 000+ различных значений. Они также эффективнее, когда совпадающие значения сгруппированы в небольшом числе последовательных частей, что обычно происходит, когда столбец коррелирует с ключом сортировки. И здесь результат может отличаться — ничто не заменит тестирование на реальных данных.
Оптимизация 3. Прямое чтение Map
LogAttributes['k8s.pod.name'] = 'checkout', ClickHouse должен прочитать с диска весь столбец Map LogAttributes
и распаковать каждую строку, чтобы проверить предикат. Материализация часто запрашиваемых атрибутов
решает эту задачу для ключей, которые известны заранее, но не масштабируется
на произвольные атрибуты, по которым пользователи фильтруют по мере необходимости.
Даже если схема содержит индексы по mapKeys и mapValues, эти индексы могут показать, есть ли у строки заданный ключ и есть ли у неё заданное значение, но не могут определить, относятся ли ключ и значение к одной и той же записи. Иначе говоря, mapKeys отвечает на mapContainsKey(ResourceAttributes, 'foo'), а mapValues — на mapContainsValue(ResourceAttributes, 'bar'), но ни один из них не отвечает на ResourceAttributes['foo'] = 'bar'.
За счёт объединения ключей и значений в один столбец Array(String)
оптимизация прямого чтения Map позволяет обрабатывать
ResourceAttributes['foo'] = 'bar' без загрузки исходной Map. Maps часто бывают большими и
увеличиваются в размере по мере роста объёма. В сочетании с переписыванием запроса
на уровне приложения фильтры равенства по любому подключу Map сводятся к одному вызову has(...),
использующему этот индекс, без десериализации Map во время выполнения запроса. Кроме того,
дополнительные затраты на хранилище есть только у текстового индекса, поскольку исходный столбец является
столбцом ALIAS и не хранится.
Эта оптимизация работает автоматически. ClickStack включает необходимые столбцы и
индексы в стандартные таблицы журналов и трассировок и переписывает фильтры
по обращению к элементам Map во время выполнения, когда подключённый ClickHouse server поддерживает базовый
примитив. Если в вашей схеме нет этих столбцов или у вас есть
дополнительные столбцы Map, которые вы хотите ускорить сверх стандартного набора, читайте далее,
чтобы включить их.
Схема
Array(String) ALIAS, в котором каждый ключ соединяется со значением через =:
text(tokenizer = 'array') индекс пропуска данных для
столбца ALIAS хранит один токен для каждой пары key=value, который ClickHouse использует,
чтобы отсекать гранулы, не обращаясь к исходному Map:
Перезапись запроса
LogAttributeItems, отсекает
целые строки, которые не содержат токен key=value, и никогда не десериализует
исходный Map LogAttributes для несовпадающих строк. Для рабочих нагрузок
обсервабилити с высокой мощностью это обычно даёт сокращение I/O на порядок
по сравнению с доступом к элементам Map по индексу.
Преобразование выполняется автоматически — сохранённые запросы, панели мониторинга и алерты,
которые ссылаются на LogAttributes['key'], получают ускорение без каких-либо изменений.
Требования к версии ClickHouse
SELECT version(), кэшируется для каждого подключения) и
использует rewritten form только тогда, когда версия сервера не ниже пороговой.
На более старых серверах автоматически используется исходная форма с обращением к Map по индексу.
Почему ALIAS, а не MATERIALIZEDМассив items — это представление данных, которые уже хранятся в столбце Map.
Хранение этих данных дважды — один раз в Map, второй раз в массиве — удвоило бы I/O
при записи, не открыв новых шаблонов запроса. Текстовый индекс для столбца
ALIAS
строится во время вставки из тех же исходных данных, поэтому эта оптимизация добавляет на диск
только объём самого индекса.Оптимизация 4. Изменение первичного ключа
Примечание о терминологииВ этом документе термин “ключ сортировки” используется как взаимозаменяемый с термином “первичный ключ”. Строго говоря, в ClickHouse это разные понятия, но в ClickStack они обычно относятся к одним и тем же столбцам, указанным в
ORDER BY таблицы. Подробнее см. документацию ClickHouse о выборе первичного ключа, отличающегося от ключа сортировки.- Логи (
otel_logs) -(toStartOfFiveMinutes(Timestamp), ServiceName, Timestamp) - Трассировки (
otel_traces) -(ServiceName, SpanName, toDateTime(Timestamp))
Выбор первичного ключа
Изменение первичного ключа по умолчаниюПервичные ключи по умолчанию достаточны в большинстве случаев. Вносить изменения следует с осторожностью и только при четком понимании шаблонов запросов. Изменение первичного ключа может ухудшить производительность других сценариев, поэтому тестирование обязательно.
- Выбирайте столбцы, соответствующие вашим типичным фильтрам и паттернам доступа. Если вы обычно начинаете расследование в области обсервабилити с фильтрации по конкретному столбцу, например по имени пода, этот столбец будет часто использоваться в секциях
WHERE. Такие столбцы стоит включать в ключ в первую очередь, а не те, которые используются реже. - Предпочитайте столбцы, которые при фильтрации позволяют исключить большую часть строк и тем самым сократить объем читаемых данных. Имена сервисов и коды status часто являются хорошими кандидатами — во втором случае только если вы фильтруете по значениям, исключающим большинство строк; например, фильтрация по кодам 200 в большинстве систем охватит большую часть строк, тогда как ошибки 500 будут соответствовать лишь небольшому подмножеству.
- Предпочитайте столбцы, которые, вероятно, будут сильно коррелировать с другими столбцами таблицы. Это поможет обеспечить их смежное хранение, что улучшит сжатие.
- Операции
GROUP BY(агрегации для диаграмм) иORDER BY(сортировка) по столбцам из ключа сортировки могут быть более эффективны с точки зрения использования памяти.
Изменение первичного ключа
SeverityText расположен перед ServiceName.
1
Создайте новую таблицу
Ключ сортировки и первичный ключОбратите внимание: в примере выше необходимо указать
PRIMARY KEY и ORDER BY.
В ClickStack они почти всегда совпадают.
ORDER BY управляет физической организацией данных, а PRIMARY KEY определяет разреженный индекс.
В редких случаях при очень больших рабочих нагрузках они могут различаться, но большинству пользователей следует держать их согласованными.Дозагрузка существующих данных в новую таблицу в крупных инсталляциях редко оправданна. Затраты на вычислительные ресурсы и IO обычно высоки и не окупаются выигрышем в производительности. Вместо этого дайте старым данным истечь через TTL, а новые данные пусть используют преимущества улучшенного ключа.
SeverityText добавляется как первый столбец первичного ключа. В этом случае новая таблица создаётся для новых данных, а старая сохраняется для исторического анализа.
1
Создайте новую таблицу
Создайте новую таблицу с нужным первичным ключом. Обратите внимание на суффикс_23_01_2025 — замените его на текущую дату. Например:2
Создайте Merge-таблицу
Движок Merge (не путать с MergeTree) не хранит данные сам, но позволяет одновременно читать из любого количества других таблиц.currentDatabase() предполагает, что команда выполняется в нужной базе данных. В противном случае явно укажите имя базы данных.otel_logs.3
Обновите интерфейс ClickStack, чтобы читать из merge-таблицы
Настройте интерфейс ClickStack так, чтобы для источника данных журналов использовалась таблицаotel_logs_merge.На этом этапе запись по-прежнему идёт в otel_logs с исходным первичным ключом, а чтение использует merge-таблицу. Для пользователей нет видимых изменений, и на ингестию это не влияет.4
Обменяйте таблицы
Теперь для атомарного обмена именами таблицotel_logs и otel_logs_23_01_2025 используется оператор EXCHANGE.otel_logs с обновлённым первичным ключом. Существующие данные остаются в otel_logs_23_01_2025 и по-прежнему доступны через merge-таблицу. Суффикс указывает дату применения изменения и соответствует самой поздней временной метке в этой таблице.Этот процесс позволяет изменять первичный ключ без прерывания приёма и без видимого влияния на пользователей.SeverityNumber, а не SeverityText. Описанный ниже процесс можно повторять столько раз, сколько потребуется при изменении первичного ключа.
1
Создайте новую таблицу
Создайте новую таблицу с нужным первичным ключом. В примере ниже30_01_2025 используется в качестве суффикса, обозначающего дату таблицы. Например:2
Обменяйте таблицы
Теперь операторEXCHANGE используется для атомарной замены имен таблиц otel_logs и otel_logs_30_01_2025.otel_logs с обновлённым первичным ключом. Старые данные остаются в otel_logs_30_01_2025 и доступны через merge-таблицу.Избыточные таблицыЕсли настроены политики TTL, что рекомендуется, таблицы со старыми первичными ключами, в которые больше не записываются данные, будут постепенно освобождаться по мере истечения срока хранения данных. Их следует отслеживать и периодически очищать, когда в них больше не остается данных. В настоящее время этот процесс очистки выполняется вручную.
Ускорение поиска по строкам с помощью блочных столбцов
(_block_number, _block_offset), которая однозначно идентифицирует её внутри
части. Когда вы нажимаете строку журнала в интерфейсе ClickStack, чтобы открыть панель сведений, ClickStack
выполняет дополнительный запрос, чтобы получить именно эту строку. Без блочных столбцов
предложение WHERE для строки должно включать достаточно столбцов — обычно первичный ключ
плюс Body и SeverityText — чтобы однозначно определить строку. С блочными столбцами
достаточно первичного ключа, _block_number и _block_offset. Большие
столбцы, такие как Body, при таком поиске не читаются, что фактически ускоряет запрос.
ClickStack определяет эту настройку из оператора CREATE таблицы и автоматически формирует
более компактное предложение WHERE, когда включены оба столбца. Никаких
изменений в конфигурации приложения не требуется.
Чтобы применить эту оптимизацию к существующей таблице журналов или трассировок:
ALTER. Существующие части по-прежнему
используют старый построчный lookup, пока их не перепишет процесс слияния.
Оптимизация 5. Использование materialized view
Оптимизация 6. Использование проекций
ORDER BY базовой таблицы, что позволяет ClickHouse эффективнее отсекать данные для шаблонов доступа, не соответствующих исходному порядку.
Materialized views могут давать схожий эффект, явно записывая строки в отдельную целевую таблицу с другим ключом сортировки. Главное отличие в том, что проекции поддерживаются ClickHouse автоматически и прозрачно, тогда как materialized views — это явные таблицы, которые ClickStack должен регистрировать и выбирать явно.
Когда запрос обращается к базовой таблице, ClickHouse оценивает базовую структуру и все доступные проекции, анализирует их первичные индексы и выбирает ту структуру, которая позволяет получить корректный результат, прочитав минимальное число гранул. Это решение автоматически принимает анализатор запросов.
Поэтому в ClickStack проекции лучше всего подходят для простого переупорядочивания данных, когда:
- Шаблоны доступа принципиально отличаются от первичного ключа по умолчанию
- Непрактично охватить все сценарии работы одним ключом сортировки
- Вы хотите, чтобы ClickHouse прозрачно выбирал оптимальную физическую структуру
Пример проекций
Используйте подстановочные шаблоныВ примере с проекцией выше используется подстановочный шаблон (
SELECT *). Хотя выбор подмножества столбцов может снизить накладные расходы на запись, это также ограничивает случаи, когда проекцию можно использовать, поскольку подходят только те запросы, которые можно полностью выполнить по этим столбцам. В ClickStack это часто сводит использование проекций к очень узким сценариям. Поэтому обычно рекомендуется использовать подстановочный шаблон, чтобы максимально расширить область применения.Материализация проекции может занять много времени и потребовать значительных ресурсов. Поскольку данные обсервабилити обычно удаляются по TTL, делать это следует только в случае крайней необходимости. В большинстве случаев достаточно, чтобы проекция применялась только к вновь принимаемым данным, оптимизируя наиболее часто запрашиваемые временные диапазоны, например последние 24 часа.
SELECT *), а фильтры запроса хорошо согласуются с ORDER BY проекции.
Запросы с фильтрацией по TraceId (особенно на точное совпадение) и указанием временного диапазона выиграют от приведенной выше проекции. Например:
TraceId или в основном фильтруют по другим измерениям, не являющимся первыми в ключе сортировки проекции, обычно не дают выигрыша (и вместо этого могут читать данные из базовой структуры).
Проекции также могут хранить агрегации (аналогично materialized views). В ClickStack агрегации на основе проекций обычно не рекомендуются, поскольку выбор зависит от анализатора ClickHouse, а их использование сложнее контролировать и предсказать. Вместо этого лучше использовать явные materialized views, которые ClickStack может регистрировать и целенаправленно выбирать на уровне приложения.
Издержки и рекомендации
- Накладные расходы на вставку: Проекция
SELECT *с другим ключом сортировки фактически приводит к двойной записи данных, что увеличивает I/O при записи и может потребовать дополнительных ресурсов CPU и пропускной способности диска для поддержания ингестии. - Используйте экономно: Проекции стоит применять только для действительно разных сценариев доступа, когда второй физический порядок даёт заметное отсечение данных для большой доли запросов — например, если две команды работают с одним и тем же набором данных принципиально по-разному.
- Проверяйте с помощью бенчмарков: Как и при любой оптимизации, сравнивайте реальную задержку запросов и использование ресурсов до и после добавления и материализации проекции.
Облегчённые проекции с _part_offset
Облегчённые проекции — в статусе бета для ClickStackОблегчённые проекции на основе
_part_offset не рекомендуются для рабочих нагрузок ClickStack. Хотя они уменьшают объём хранилища и I/O при записи, они могут приводить к большему числу произвольных обращений при выполнении запросов, а их поведение в продакшне при нагрузках масштаба обсервабилити всё ещё оценивается. Эта рекомендация может измениться по мере развития этой возможности и накопления большего объёма эксплуатационных данных._part_offset в базовой таблице вместо дублирования полных строк. Это может значительно сократить накладные расходы на хранение, а недавние улучшения позволяют выполнять pruning на уровне гранул, из-за чего такие проекции всё больше напоминают настоящие вторичные индексы. См.:
Альтернативы
- Настройте OpenTelemetry Collector так, чтобы он записывал данные в две таблицы с разными ключами
ORDER BY, и создайте отдельные источники ClickStack для каждой таблицы. - Создайте materialized view как конвейер копирования, то есть подключите materialized view к основной таблице так, чтобы она записывала необработанные строки во вторичную таблицу с другим ключом сортировки (это шаблон денормализации или маршрутизации). Создайте источник для этой целевой таблицы. Примеры можно найти здесь.