Проектирование схемы для обсервабилити
Мы рекомендуем пользователям всегда создавать собственную схему для логов и трейсов по следующим причинам:
- Выбор первичного ключа — Схемы по умолчанию используют
ORDER BY, оптимизированный под конкретные шаблоны доступа. Маловероятно, что ваши шаблоны доступа будут совпадать с ними. - Извлечение структуры — Возможно, вы захотите извлечь новые столбцы из существующих, например из столбца
Body. Это можно сделать с помощью материализованных столбцов (и materialized views в более сложных случаях). Для этого требуются изменения схемы. - Оптимизация Map — Схемы по умолчанию используют тип Map для хранения атрибутов. Эти столбцы позволяют хранить произвольные метаданные. Хотя это критически важная возможность (поскольку метаданные событий часто не определены заранее и, следовательно, не могут быть сохранены в строго типизированной базе данных, такой как ClickHouse), доступ к ключам Map и их значениям менее эффективен, чем доступ к обычному столбцу. Мы решаем это, модифицируя схему и вынося наиболее часто используемые ключи Map в отдельные столбцы верхнего уровня — см. "Извлечение структуры с помощью SQL". Это требует изменения схемы.
- Упрощение доступа к ключам Map — Доступ к ключам в Map требует более многословного синтаксиса. Это можно сгладить с помощью алиасов. См. "Использование алиасов" для упрощения запросов.
- Вторичные индексы — Схема по умолчанию использует вторичные индексы для ускорения доступа к Map и текстовым запросам. Как правило, они не требуются и увеличивают использование дискового пространства. Их можно применять, но следует протестировать, чтобы убедиться, что они действительно необходимы. См. "Secondary / Data Skipping indices".
- Использование Codecs — Возможно, вы захотите настроить кодеки для столбцов, если вы хорошо понимаете ожидаемые данные и у вас есть подтверждение, что это улучшает сжатие.
Ниже мы подробно описываем каждый из указанных выше вариантов использования.
Важно: Хотя пользователей и поощряют расширять и модифицировать свою схему для достижения оптимального сжатия и производительности запросов, по возможности им следует придерживаться соглашений OTel по наименованию ключевых столбцов. Плагин ClickHouse для Grafana предполагает наличие некоторых базовых столбцов OTel для облегчения построения запросов, например Timestamp и SeverityText. Требуемые столбцы для логов и трейсов задокументированы здесь: [1][2] и здесь соответственно. Вы можете изменить эти имена столбцов, переопределив значения по умолчанию в конфигурации плагина.
Извлечение структуры с помощью SQL
При приёме как структурированных, так и неструктурированных логов пользователям часто требуется возможность:
- Извлекать столбцы из строковых blob-объектов. Запросы к ним будут выполняться быстрее, чем использование строковых операций при выполнении запроса.
- Извлекать ключи из Map. Базовая схема помещает произвольные атрибуты в столбцы типа Map. Этот тип обеспечивает работу без заранее заданной схемы и имеет то преимущество, что пользователям не нужно предварительно определять столбцы для атрибутов при описании логов и трассировок — часто это невозможно при сборе логов из Kubernetes и необходимости гарантировать сохранение меток подов для последующего поиска. Доступ к ключам Map и их значениям медленнее, чем выполнение запросов по обычным столбцам ClickHouse. Поэтому извлечение ключей из Map в корневые столбцы таблицы часто бывает предпочтительным.
Рассмотрим следующие запросы:
Предположим, мы хотим посчитать, какие URL-пути получают больше всего POST-запросов, используя структурированные логи. JSON blob хранится в столбце Body как String. Дополнительно он может храниться в столбце LogAttributes как Map(String, String), если пользователь включил json_parser в коллекторе.
Предполагая, что LogAttributes доступен, запрос для подсчёта, какие URL-пути сайта получают больше всего POST-запросов:
Обратите внимание на использование синтаксиса map, например LogAttributes['request_path'], а также на функцию path для удаления параметров запроса из URL.
Если пользователь не включил разбор JSON в коллекторе, то LogAttributes будет пустым, и нам придется использовать JSON-функции для извлечения столбцов из строки Body.
В целом мы рекомендуем выполнять разбор JSON в ClickHouse для структурированных логов. Мы уверены, что ClickHouse предоставляет самую быструю реализацию разбора JSON. Однако мы понимаем, что вы можете захотеть отправлять логи в другие системы и не хотите, чтобы эта логика была реализована в SQL.
Теперь рассмотрим аналогичный пример для неструктурированных логов:
Аналогичный запрос для неструктурированных логов требует использования регулярных выражений с помощью функции extractAllGroupsVertical.
Повышенная сложность и стоимость запросов при разборе неструктурированных логов (обратите внимание на разницу в производительности) — причина, по которой мы рекомендуем пользователям по возможности всегда использовать структурированные логи.
Приведённый выше запрос можно оптимизировать за счёт использования словарей регулярных выражений. См. раздел Using Dictionaries для более подробной информации.
Обе эти задачи могут быть решены в ClickHouse путём переноса приведённой выше логики запроса на время вставки данных. Ниже мы рассмотрим несколько подходов, отмечая, когда каждый из них уместен.
Вы также можете выполнять обработку, используя процессоры и операторы OTel Collector, как описано здесь. В большинстве случаев вы увидите, что ClickHouse значительно более эффективно использует ресурсы и работает быстрее, чем процессоры коллектора. Основной недостаток выполнения всей обработки событий в SQL — это привязка вашего решения к ClickHouse. Например, вы можете захотеть направлять обработанные логи в другие системы из OTel Collector, например в S3.
Материализованные столбцы
Материализованные столбцы обеспечивают простейший способ извлечь структуру из других столбцов. Значения таких столбцов всегда вычисляются на этапе вставки и не могут быть указаны в запросах INSERT.
Материализованные столбцы создают дополнительные накладные расходы на хранение, так как значения извлекаются в новые столбцы на диске при вставке.
Материализованные столбцы поддерживают любые выражения ClickHouse и могут использовать любые аналитические функции для обработки строк (включая регулярные выражения (regex) и поиск) и URL-адресов, выполнения преобразования типов, извлечения значений из JSON или математических операций.
Мы рекомендуем использовать материализованные столбцы для базовой обработки. Они особенно полезны для извлечения значений из отображений (map), выноса их в корневые столбцы и выполнения преобразования типов. Чаще всего они наиболее полезны при использовании в очень простых схемах или в сочетании с materialized view. Рассмотрим следующую схему для логов, в которых JSON был извлечён коллектором в столбец LogAttributes:
Эквивалентную схему для извлечения данных с использованием JSON-функций из строки Body можно найти здесь.
Наши три материализованных столбца извлекают запрашиваемую страницу, тип запроса и домен реферера. Они обращаются к ключам map-структуры и применяют функции к их значениям. Последующий запрос выполняется значительно быстрее:
Материализованные столбцы по умолчанию не возвращаются при выполнении SELECT *. Это сделано для гарантии того, что результат SELECT * всегда можно вставить обратно в таблицу с помощью INSERT. Это поведение можно отключить, установив asterisk_include_materialized_columns=1, а также его можно изменить в Grafana (см. Additional Settings -> Custom Settings в конфигурации источника данных).
Materialized views
Materialized views предоставляют более мощный способ применения SQL‑фильтрации и преобразований к логам и трейсам.
Materialized Views позволяют перенести вычислительные затраты с момента выполнения запроса на момент вставки. materialized view в ClickHouse — это по сути триггер, который выполняет запрос на блоках данных по мере их вставки в таблицу. Результаты этого запроса вставляются во вторую, «целевую» таблицу.

Materialized views в ClickHouse обновляются в реальном времени по мере поступления данных в таблицу, на основе которой они построены, и функционируют скорее как постоянно обновляющиеся индексы. В отличие от этого, в других базах данных materialized views обычно являются статическими снимками результата запроса, которые необходимо периодически обновлять (аналогично ClickHouse Refreshable Materialized Views).
Запрос, связанный с materialized view, теоретически может быть любым запросом, включая агрегацию, хотя существуют ограничения при использовании Joins. Для задач преобразования и фильтрации, необходимых для логов и трейсинга, можно считать допустимым любой оператор SELECT.
Следует помнить, что запрос — это всего лишь триггер, выполняющийся над строками, вставляемыми в таблицу (исходную таблицу), а результаты отправляются в новую таблицу (целевую таблицу).
Чтобы гарантировать, что мы не будем хранить данные дважды (в исходной и целевой таблицах), мы можем изменить движок исходной таблицы на Null table engine, сохранив исходную схему. Наши OTel collectors будут продолжать отправлять данные в эту таблицу. Например, для логов таблица otel_logs становится:
Движок таблицы Null — это мощная оптимизация, его можно рассматривать как аналог /dev/null. Эта таблица не будет хранить какие-либо данные, но любые связанные с ней materialized view по‑прежнему будут выполняться над вставленными строками, прежде чем они будут отброшены.
Рассмотрим следующий запрос. Он преобразует наши строки в формат, который мы хотим сохранить, извлекая все столбцы из LogAttributes (мы предполагаем, что это было установлено коллектором с помощью оператора json_parser) и задавая значения SeverityText и SeverityNumber (на основе некоторых простых условий и определения этих столбцов). В этом случае мы также выбираем только те столбцы, которые, как нам известно, будут заполнены, игнорируя такие столбцы, как TraceId, SpanId и TraceFlags.
Мы также извлекаем столбец Body — на случай, если позже будут добавлены дополнительные атрибуты, которые не извлекаются нашим SQL. Этот столбец будет хорошо сжиматься в ClickHouse и к нему будут редко обращаться, поэтому он не повлияет на производительность запросов. Наконец, мы приводим Timestamp к типу DateTime (для экономии места — см. "Optimizing Types") с помощью приведения типа (cast).
Обратите внимание на использование conditionals выше для извлечения SeverityText и SeverityNumber. Эти функции чрезвычайно полезны для формулирования сложных условий и проверки, заданы ли значения в отображениях (map) — мы наивно предполагаем, что все ключи существуют в LogAttributes. Рекомендуем пользователям с ними познакомиться — это ваш надёжный помощник при разборе логов, в дополнение к функциям для обработки null values!
Нам требуется таблица для приёма этих результатов. Приведённая ниже целевая таблица соответствует приведённому выше запросу:
Выбранные здесь типы основаны на оптимизациях, рассмотренных в разделе "Optimizing types".
Обратите внимание, насколько существенно мы изменили схему. На практике у вас, вероятно, также будут столбцы трассировок, которые вам потребуется сохранить, а также столбец ResourceAttributes (обычно он содержит метаданные Kubernetes). Grafana может использовать столбцы трассировок для реализации связей между логами и трассировками — см. раздел "Using Grafana".
Ниже мы создаём materialized view otel_logs_mv, которая выполняет приведённый выше SELECT-запрос для таблицы otel_logs и записывает результаты в otel_logs_v2.
Вышеописанное можно представить схематически так:

Если теперь перезапустить конфигурацию коллектора, используемую в "Exporting to ClickHouse", данные появятся в otel_logs_v2 в нужном формате. Обратите внимание на использование типизированных функций извлечения данных из JSON.
Эквивалентный materialized view, который извлекает столбцы из столбца Body с помощью JSON‑функций, показан ниже:
Осторожно с типами
Приведённые выше materialized view опираются на неявное приведение типов — особенно при использовании map LogAttributes. ClickHouse часто прозрачно приводит извлечённое значение к типу целевой таблицы, сокращая необходимый синтаксис. Однако мы рекомендуем всегда тестировать такие представления, выполняя SELECT из view совместно с командой INSERT INTO в целевую таблицу с той же схемой. Это позволяет убедиться, что типы обрабатываются корректно. Особое внимание следует уделить следующим случаям:
- Если ключ не существует в map, будет возвращена пустая строка. В случае числовых значений вам потребуется сопоставить их с подходящим значением. Это можно сделать с помощью условных выражений, например
if(LogAttributes['status'] = ", 200, LogAttributes['status']), или функций приведения типов, если допустимы значения по умолчанию, напримерtoUInt8OrDefault(LogAttributes['status'] ). - Некоторые типы не всегда будут приводиться, например строковые представления чисел не будут приводиться к значениям enum.
- Функции извлечения из JSON возвращают значения по умолчанию для своего типа, если значение не найдено. Убедитесь, что эти значения корректны для вашей схемы!
Избегайте использования Nullable в ClickHouse для данных обсервабилити. В логах и трейсах редко требуется различать пустое значение и null. Эта возможность приводит к дополнительным накладным расходам на хранение и негативно влияет на производительность запросов. Подробности см. здесь.
Выбор первичного (упорядочивающего) ключа
После того как вы извлекли нужные столбцы, можно приступать к оптимизации вашего упорядочивающего/первичного ключа.
Для выбора упорядочивающего ключа можно применить несколько простых правил. Иногда они могут противоречить друг другу, поэтому рассматривайте их по порядку. В результате этого процесса вы сможете определить несколько ключей, обычно достаточно 4–5:
- Выбирайте столбцы, которые соответствуют вашим типичным фильтрам и шаблонам доступа. Если вы обычно начинаете расследования в рамках обсервабилити с фильтрации по определённому столбцу, например имени пода, этот столбец будет часто использоваться в выражениях
WHERE. Отдавайте приоритет включению таких столбцов в ключ по сравнению с теми, которые используются реже. - Предпочитайте столбцы, которые при фильтрации позволяют исключить большой процент всех строк, тем самым уменьшая объём данных, которые нужно прочитать. Имена сервисов и коды статуса часто являются хорошими кандидатами — во втором случае только если вы фильтруете по значениям, исключающим большинство строк. Например, фильтрация по кодам 200 в большинстве систем будет соответствовать большинству строк, в отличие от ошибок 500, которые будут соответствовать лишь небольшой части строк.
- Предпочитайте столбцы, которые, вероятно, будут сильно коррелировать с другими столбцами в таблице. Это поможет обеспечить их последовательное хранение рядом друг с другом, улучшая сжатие.
- Операции
GROUP BYиORDER BYдля столбцов в упорядочивающем ключе могут быть более эффективными по потреблению памяти.
Определив подмножество столбцов для упорядочивающего ключа, их необходимо объявить в определённом порядке. Этот порядок может существенно повлиять как на эффективность фильтрации по столбцам вторичного ключа в запросах, так и на коэффициент сжатия файлов данных таблицы. В общем случае лучше всего упорядочивать ключи в порядке возрастания их кардинальности. Это нужно сбалансировать с тем фактом, что фильтрация по столбцам, которые появляются позже в упорядочивающем ключе, будет менее эффективной, чем фильтрация по тем, которые стоят раньше в кортеже. Найдите баланс между этими свойствами и учитывайте ваши шаблоны доступа. И самое важное — тестируйте варианты. Для более глубокого понимания упорядочивающих ключей и способов их оптимизации мы рекомендуем эту статью.
Мы рекомендуем определять ваши упорядочивающие ключи после того, как вы структурируете свои логи. Не используйте ключи в картах атрибутов для упорядочивающего ключа или выражения извлечения JSON. Убедитесь, что ваши упорядочивающие ключи представлены корневыми столбцами в вашей таблице.
Использование Map
В предыдущих примерах показано использование синтаксиса map['key'] для доступа к значениям в столбцах типа Map(String, String). Помимо обращения к вложенным ключам через нотацию map, в ClickHouse доступны специализированные функции для работы с map для фильтрации или выборочного извлечения данных из этих столбцов.
Например, следующий запрос определяет все уникальные ключи, присутствующие в столбце LogAttributes, с помощью функции mapKeys, а затем функции groupArrayDistinctArray (комбинатор).
Мы не рекомендуем использовать точки в именах столбцов типа Map и в будущем можем отказаться от их поддержки. Используйте символ _.
Использование псевдонимов
Запросы к типам Map выполняются медленнее, чем к обычным столбцам — см. раздел "Ускорение запросов". Кроме того, их синтаксис сложнее, и писать такие запросы может быть неудобно. Чтобы решить последнюю из этих проблем, мы рекомендуем использовать столбцы типа ALIAS.
Столбцы типа ALIAS вычисляются во время выполнения запроса и не хранятся в таблице. Поэтому невозможно вставить значение оператором INSERT в столбец этого типа. Используя псевдонимы, мы можем ссылаться на ключи в Map и упростить синтаксис, прозрачно представляя элементы Map как обычные столбцы. Рассмотрим следующий пример:
У нас есть несколько материализованных столбцов и столбец ALIAS — RemoteAddr, который обращается к карте LogAttributes. Теперь мы можем запрашивать значения LogAttributes['remote_addr'] через этот столбец, тем самым упрощая наш запрос, т.е.
Кроме того, добавление ALIAS с помощью команды ALTER TABLE является тривиальной операцией. Эти столбцы сразу же становятся доступными, например:
По умолчанию SELECT * не включает столбцы ALIAS. Это поведение можно отключить, установив asterisk_include_alias_columns=1.
Оптимизация типов
Общие рекомендации по оптимизации типов в ClickHouse применимы и к данному сценарию использования ClickHouse.
Использование кодеков
Помимо оптимизации типов, вы можете придерживаться общих рекомендаций по использованию кодеков при оптимизации сжатия для схем ClickHouse Observability.
Как правило, на практике кодек ZSTD хорошо подходит для наборов данных логов и трейсов. Увеличение уровня сжатия относительно значения по умолчанию 1 может улучшить степень сжатия. Однако это следует проверять, так как более высокие значения приводят к большему использованию CPU во время вставки. Обычно мы наблюдаем лишь незначительный выигрыш от увеличения этого значения.
Кроме того, временные метки, хотя и выигрывают от дельта-кодирования с точки зрения сжатия, по имеющимся данным приводят к замедлению выполнения запросов, если этот столбец используется в первичном/упорядочивающем ключе. Мы рекомендуем пользователям оценить соответствующий баланс между сжатием и производительностью запросов.
Использование словарей
Словари — это ключевая возможность ClickHouse, обеспечивающая хранимое в памяти представление данных в формате key-value из различных внутренних и внешних источников, оптимизированное для сверхнизкой задержки при выполнении запросов поиска.

Это удобно во множестве сценариев — от обогащения данных, поступающих при ингестии, «на лету» без замедления процесса ингестии до общего повышения производительности запросов, где особенно выигрывают операции JOIN. Хотя операции JOIN редко требуются в сценариях обсервабилити, словари по‑прежнему могут быть полезны для обогащения — как на момент вставки, так и на момент выполнения запроса. Ниже мы приводим примеры обоих подходов.
Пользователи, заинтересованные в ускорении операций JOIN с помощью словарей, могут найти дополнительную информацию здесь.
Время вставки и время запроса
Словари могут использоваться для обогащения наборов данных во время запроса или во время вставки. У каждого из этих подходов есть свои преимущества и недостатки. Вкратце:
- Время вставки — Обычно подходит, если обогащающие значения не меняются и существуют во внешнем источнике, который можно использовать для заполнения словаря. В этом случае обогащение строки во время вставки позволяет избежать поиска в словаре при выполнении запроса. Это происходит за счет производительности вставки, а также дополнительного расхода места в хранилище, так как обогащённые значения будут храниться как столбцы.
- Время запроса — Если значения в словаре часто меняются, поиск во время запроса зачастую более уместен. Это избавляет от необходимости обновлять столбцы (и перезаписывать данные), если изменяются сопоставленные значения. Такая гибкость достигается ценой дополнительных затрат на поиск во время запроса. Эти затраты обычно заметны, если требуется поиск для большого количества строк, например при использовании словаря в фильтрующем выражении. Для обогащения результата, т.е. в
SELECT, эти накладные расходы обычно несущественны.
Мы рекомендуем пользователям ознакомиться с основами словарей. Словари предоставляют таблицу поиска в памяти, из которой значения могут быть получены с помощью специализированных функций.
Примеры простого обогащения см. в руководстве по словарям здесь. Ниже мы сосредоточимся на типичных задачах обогащения для обсервабилити.
Использование IP-словарей
Геообогащение логов и трейсов координатами (широтой и долготой) по IP-адресам — типовое требование в обсервабилити. Это можно реализовать с помощью структурированного словаря ip_trie.
Мы используем общедоступный набор данных DB-IP с детализацией до города, предоставленный DB-IP.com на условиях лицензии CC BY 4.0.
Из файла README видно, что данные структурированы следующим образом:
С учетом такой структуры давайте начнём с изучения данных с помощью табличной функции url():
Чтобы упростить задачу, давайте используем табличный движок URL(), чтобы создать в ClickHouse таблицу с нашими именами полей и проверить общее количество строк:
Поскольку наш словарь ip_trie требует, чтобы диапазоны IP-адресов были представлены в нотации CIDR, нам нужно будет преобразовать ip_range_start и ip_range_end.
CIDR для каждого диапазона можно просто вычислить с помощью следующего запроса:
В приведённом выше запросе происходит довольно много всего. Тем, кому интересно, рекомендую это отличное объяснение. В противном случае просто примите, что выше вычисляется CIDR для диапазона IP-адресов.
Для наших целей нам понадобится только диапазон IP-адресов, код страны и координаты, поэтому давайте создадим новую таблицу и вставим в неё наши данные Geo IP:
Чтобы выполнять низкозадержечные IP‑поиски в ClickHouse, мы будем использовать словари для хранения сопоставления ключей с атрибутами наших Geo IP‑данных в памяти. ClickHouse предоставляет структуру словаря ip_trie структура словаря для сопоставления наших сетевых префиксов (CIDR‑блоков) с координатами и кодами стран. Следующий запрос определяет словарь, используя эту структуру и приведённую выше таблицу в качестве источника.
Мы можем выбрать строки из словаря и убедиться, что этот набор данных доступен для обращений (lookups):
Словари в ClickHouse периодически обновляются на основе данных базовой таблицы и указанного выше выражения lifetime. Чтобы обновить наш словарь Geo IP в соответствии с последними изменениями в наборе данных DB-IP, нам нужно лишь повторно вставить данные из удалённой таблицы geoip_url в нашу таблицу geoip с применением необходимых преобразований.
Теперь, когда данные Geo IP загружены в наш словарь ip_trie (который для удобства также назван ip_trie), мы можем использовать его для геолокации по IP. Это можно сделать с помощью функции dictGet() следующим образом:
Обратите внимание на скорость выборки. Это позволяет нам обогащать логи. В данном случае мы выбираем выполнять обогащение на этапе выполнения запроса.
Возвращаясь к нашему исходному набору логов, мы можем использовать описанное выше, чтобы агрегировать наши логи по странам. Далее предполагается, что мы используем схему, полученную на основе ранее созданного materialized view, в которой уже есть выделенный столбец RemoteAddress.
Поскольку сопоставление IP‑адреса с географическим местоположением может меняться, пользователям, скорее всего, важно знать, откуда поступил запрос в момент его выполнения, а не каково текущее географическое местоположение для того же адреса. По этой причине здесь, вероятно, предпочтительно обогащение на этапе индексации. Это можно сделать с помощью материализованных столбцов, как показано ниже, или в операторе SELECT объекта materialized view:
Пользователям, скорее всего, потребуется, чтобы словарь обогащения IP-адресов периодически обновлялся на основе новых данных. Это можно реализовать с помощью директивы LIFETIME словаря, которая будет приводить к его периодической перезагрузке из исходной таблицы. Для обновления исходной таблицы см. "Refreshable Materialized views".
Приведённые выше страны и координаты обеспечивают возможности визуализации, выходящие за рамки простого группирования и фильтрации по странам. Для вдохновения см. "Visualizing geo data".
Использование словарей на основе регулярных выражений (разбор User-Agent)
Разбор строк User-Agent — это классическая задача на регулярные выражения и типичное требование для наборов данных на основе логов и трассировок. ClickHouse обеспечивает эффективный разбор строк User-Agent с использованием Regular Expression Tree Dictionaries.
Словари на основе дерева регулярных выражений определяются в ClickHouse open-source с использованием типа источника словаря YAMLRegExpTree, который указывает путь к YAML-файлу, содержащему дерево регулярных выражений. Если вы хотите использовать собственный словарь регулярных выражений, подробности о требуемой структуре можно найти здесь. Далее мы сосредоточимся на разборе User-Agent с использованием uap-core и загрузим наш словарь в поддерживаемом формате CSV. Этот подход совместим как с OSS, так и с ClickHouse Cloud.
В примерах ниже мы используем снимки актуальных регулярных выражений uap-core для разбора User-Agent по состоянию на июнь 2024 года. Актуальный файл, который периодически обновляется, можно найти здесь. Вы можете выполнить шаги, описанные здесь, чтобы загрузить данные в CSV-файл, используемый ниже.
Создайте следующие таблицы движка Memory. Они будут хранить наши регулярные выражения для разбора устройств, браузеров и операционных систем.
Эти таблицы можно заполнить данными из следующих публично доступных CSV‑файлов с помощью табличной функции URL:
После заполнения таблиц в памяти мы можем загрузить словари регулярных выражений. Обратите внимание, что необходимо указать ключевые значения в виде столбцов — это будут атрибуты, которые мы сможем извлекать из User-Agent.
Теперь, когда эти словари загружены, мы можем передать пример User-Agent и протестировать новые возможности извлечения данных с помощью словаря:
Учитывая, что правила, связанные с user-agent, будут меняться редко, а словарь нужно будет обновлять только по мере появления новых браузеров, операционных систем и устройств, имеет смысл выполнять это извлечение на этапе вставки данных.
Мы можем выполнить эту обработку либо с использованием materialized column, либо с использованием materialized view. Ниже мы изменим materialized view, которую использовали ранее:
Для этого нам необходимо изменить схему целевой таблицы otel_logs_v2:
После перезапуска коллектора и начала приёма структурированных логов, согласно ранее описанным шагам, мы можем выполнять запросы к нашим вновь извлечённым столбцам Device, Browser и OS.
Обратите внимание на использование кортежей (Tuple) для этих столбцов user agent. Кортежи рекомендуются для сложных структур с заранее известной иерархией. Подстолбцы обеспечивают ту же производительность, что и обычные столбцы (в отличие от ключей Map), при этом позволяют использовать разнородные типы.
Дополнительные материалы
Для дополнительных примеров и более подробной информации о словарях рекомендуем следующие материалы:
Ускорение запросов
ClickHouse поддерживает ряд методов для ускорения выполнения запросов. К следующим подходам следует обращаться только после того, как выбран подходящий первичный ключ/ключ сортировки, оптимизированный под наиболее распространённые шаблоны доступа и максимально эффективное сжатие. Обычно именно это даёт наибольший прирост производительности при наименьших затратах.
Использование materialized views (инкрементальных) для агрегаций
В предыдущих разделах мы рассмотрели использование materialized views для трансформации и фильтрации данных. Однако materialized views также можно использовать для предварительного вычисления агрегаций во время вставки данных и сохранения результата. Этот результат может обновляться при последующих вставках, тем самым позволяя фактически выполнять агрегацию заранее — на этапе вставки.
Основная идея заключается в том, что результаты часто представляют собой более компактное представление исходных данных (в случае агрегаций — частичный sketch). В сочетании с более простым запросом для чтения результатов из целевой таблицы время выполнения запроса будет меньше, чем если бы те же вычисления выполнялись по исходным данным.
Рассмотрим следующий запрос, в котором мы вычисляем суммарный трафик по часам, используя наши структурированные логи:
Мы можем представить, что это распространённый линейный график, который пользователи строят в Grafana. Этот запрос действительно очень быстрый — набор данных всего 10 млн строк, и ClickHouse очень быстр! Однако, если мы масштабируем объём данных до миллиардов и триллионов строк, нам желательно сохранить такую производительность запросов.
Этот запрос был бы в 10 раз быстрее, если бы мы использовали таблицу otel_logs_v2, которая получается из нашей ранее созданной materialized view, извлекающей ключ size из карты LogAttributes. Здесь мы используем сырые данные только в иллюстративных целях и рекомендуем использовать эту materialized view, если это типичный запрос.
Если мы хотим выполнять такое вычисление во время вставки с помощью materialized view, нам нужна таблица для приёма результатов. Эта таблица должна хранить только 1 строку в час. Если для уже существующего часа приходит обновление, остальные столбцы должны быть объединены со строкой этого часа. Чтобы слияние инкрементальных состояний происходило, частичные состояния должны храниться для остальных столбцов.
Для этого в ClickHouse требуется специальный тип движка таблицы: SummingMergeTree. Он заменяет все строки с одинаковым ключом сортировки одной строкой, которая содержит суммарные значения для числовых столбцов. Следующая таблица будет объединять любые строки с одинаковой датой, суммируя все числовые столбцы.
Чтобы продемонстрировать работу нашей materialized view, предположим, что таблица bytes_per_hour пуста и ещё не получила никаких данных. Наша materialized view выполняет вышеуказанный SELECT по данным, вставляемым в otel_logs (это будет выполняться по блокам заданного размера), а результаты передаются в bytes_per_hour. Синтаксис показан ниже:
Ключевым здесь является оператор TO, который указывает, куда будут отправлены результаты, т. е. в bytes_per_hour.
Если мы перезапустим наш OTel collector и повторно отправим логи, таблица bytes_per_hour будет постепенно заполняться результатом приведённого выше запроса. По завершении мы можем проверить объём данных в bytes_per_hour — в ней должна быть 1 строка за каждый час:
Мы фактически сократили число строк здесь с 10 млн (в otel_logs) до 113, сохранив результат нашего запроса. Ключевой момент заключается в том, что при вставке новых логов в таблицу otel_logs новые значения будут записываться в bytes_per_hour для соответствующего часа, где они будут автоматически асинхронно объединяться в фоновом режиме — сохраняя только одну строку в час, bytes_per_hour таким образом всегда будет и компактной, и актуальной.
Поскольку объединение строк происходит асинхронно, при выполнении запроса пользователем может существовать более одной строки для одного часа. Чтобы обеспечить слияние всех оставшихся строк во время выполнения запроса, у нас есть два варианта:
- Использовать модификатор
FINALдля имени таблицы (как мы сделали для запроса на подсчёт выше). - Агрегировать по ключу сортировки, используемому в нашей итоговой таблице, т.е. по Timestamp, и суммировать метрики.
Обычно второй вариант более эффективен и гибок (таблица может использоваться и для других целей), но первый может быть проще для некоторых запросов. Ниже мы покажем оба варианта:
Это ускорило выполнение нашего запроса с 0,6 с до 0,008 с — более чем в 75 раз!
Этот выигрыш может быть ещё больше на больших наборах данных с более сложными запросами. См. примеры здесь.
Более сложный пример
Приведённый выше пример агрегирует простое почасовое количество записей, используя SummingMergeTree. Для расчёта статистик, выходящих за рамки простых сумм, требуется другой движок целевой таблицы: AggregatingMergeTree.
Предположим, мы хотим вычислить количество уникальных IP-адресов (или уникальных пользователей) в день. Запрос для этого:
Для сохранения счетчика кардинальности при инкрементальном обновлении нужен движок AggregatingMergeTree.
Чтобы ClickHouse знал, что будут храниться агрегатные состояния, мы определяем столбец UniqueUsers как тип AggregateFunction, указывая агрегатную функцию — источник частичных состояний (uniq) и тип исходного столбца (IPv4). Как и в случае с SummingMergeTree, строки с одинаковым значением ключа ORDER BY будут объединяться (Hour в приведённом выше примере).
Связанная materialized view использует приведённый выше запрос:
Обратите внимание, что мы добавляем суффикс State в конец наших агрегатных функций. Это гарантирует, что будет возвращено агрегатное состояние функции, а не окончательный результат. Оно будет содержать дополнительную информацию, позволяющую объединить это частичное состояние с другими состояниями.
После того как данные были перезагружены посредством перезапуска Collector, мы можем подтвердить, что в таблице unique_visitors_per_hour доступно 113 строк.
Наш итоговый запрос должен использовать суффикс Merge в наших функциях (поскольку столбцы хранят состояния частичной агрегации):
Обратите внимание, что здесь мы используем оператор GROUP BY, а не FINAL.
Использование materialized views (инкрементальных) для быстрых выборок
При выборе ключа сортировки ClickHouse по столбцам, которые часто используются в предложениях фильтрации и агрегации, необходимо учитывать характер доступа к данным. Это может быть ограничивающим фактором в сценариях обсервабилити, где у пользователей более разнообразные паттерны доступа, которые невозможно выразить с помощью одного набора столбцов. Лучше всего это иллюстрирует пример, встроенный в стандартные схемы OTel. Рассмотрим стандартную схему для трассировок:
Эта схема оптимизирована для фильтрации по ServiceName, SpanName и Timestamp. В контексте трассировки пользователям также нужна возможность выполнять поиск по конкретному TraceId и получать спаны, связанные с этим трейсом. Хотя это поле присутствует в ключе сортировки, его положение в конце означает, что фильтрация будет менее эффективной и, вероятно, для получения одного трейса потребуется просканировать значительные объёмы данных.
OTel collector также разворачивает materialized view и связанную таблицу для решения этой задачи. Таблица и представление показаны ниже:
Это представление по сути гарантирует, что в таблице otel_traces_trace_id_ts хранятся минимальная и максимальная метки времени для трейса. Эта таблица, упорядоченная по TraceId, позволяет эффективно получать эти метки времени. Эти диапазоны меток времени, в свою очередь, могут использоваться при выполнении запросов к основной таблице otel_traces. Конкретнее, при получении трейса по его идентификатору Grafana использует следующий запрос:
Здесь CTE используется для определения минимальной и максимальной временных меток для trace id ae9226c78d1d360601e6383928e4d22d, после чего это используется для фильтрации основной таблицы otel_traces по связанным с ним span-ам.
Тот же подход может быть применён для похожих шаблонов доступа к данным. Похожий пример мы рассматриваем в разделе моделирования данных здесь.
Использование проекций
Проекции ClickHouse позволяют указать несколько конструкций ORDER BY для таблицы.
В предыдущих разделах мы рассмотрели, как materialized view можно использовать в ClickHouse для предварительного вычисления агрегаций, преобразования строк и оптимизации запросов обсервабилити под различные паттерны доступа.
Мы предоставили пример, в котором materialized view отправляет строки в целевую таблицу с ключом сортировки, отличающимся от ключа исходной таблицы, принимающей вставки, для оптимизации поиска по идентификатору трассировки.
Проекции можно использовать для решения той же задачи, позволяя пользователю оптимизировать запросы по столбцам, которые не входят в первичный ключ.
Теоретически эту возможность можно использовать для создания нескольких ключей сортировки для таблицы, однако у неё есть один существенный недостаток: дублирование данных. Конкретно, данные потребуется записывать как в порядке основного первичного ключа, так и в порядке, указанном для каждой проекции. Это замедлит операции вставки и увеличит потребление дискового пространства.
Проекции предоставляют многие из тех же возможностей, что и materialized views, однако их следует использовать ограниченно, отдавая предпочтение последним. Важно понимать недостатки проекций и ситуации, в которых они уместны. Например, хотя проекции можно использовать для предварительного вычисления агрегаций, мы рекомендуем применять для этого materialized views.

Рассмотрим следующий запрос, который фильтрует таблицу otel_logs_v2 по кодам ошибок 500. Это распространённый паттерн доступа при работе с логами, когда пользователям необходимо фильтровать данные по кодам ошибок:
Мы не выводим результаты, используя FORMAT Null. Это заставляет прочитать все результаты, но не возвращать их, тем самым предотвращая досрочное завершение запроса из-за LIMIT. Это нужно только для того, чтобы показать время, затраченное на сканирование всех 10 млн строк.
Приведенный выше запрос требует линейного сканирования при использовании выбранного нами ключа сортировки (ServiceName, Timestamp). Хотя мы могли бы добавить Status в конец ключа сортировки, что улучшило бы производительность данного запроса, мы также можем добавить проекцию.
Обратите внимание, что сначала необходимо создать проекцию, а затем материализовать её. Эта последняя команда приводит к двукратному сохранению данных на диске в двух различных порядках сортировки. Проекцию также можно определить при создании таблицы, как показано ниже, и она будет автоматически поддерживаться при вставке данных.
Важно: если проекция создаётся через ALTER, то при выполнении команды MATERIALIZE PROJECTION её создание происходит асинхронно. Вы можете отслеживать ход выполнения этой операции следующим запросом, ожидая is_done=1.
Если повторить приведенный выше запрос, можно увидеть, что производительность значительно улучшилась за счет дополнительного дискового пространства (см. раздел "Измерение размера таблицы и сжатия" о том, как это измерить).
В приведённом выше примере мы указываем столбцы, использованные в предыдущем запросе, в проекции. Это означает, что только эти указанные столбцы будут храниться на диске как часть проекции, упорядоченной по Status. Если бы вместо этого мы использовали здесь SELECT *, все столбцы были бы сохранены. Хотя это позволило бы большему числу запросов (использующих любое подмножество столбцов) использовать преимущества проекции, это привело бы к дополнительным затратам на хранение. Для измерения дискового пространства и степени сжатия см. «Измерение размера таблицы и сжатия».
Вторичные индексы / индексы пропуска данных
Независимо от того, насколько хорошо настроен первичный ключ в ClickHouse, некоторые запросы неизбежно будут требовать полного сканирования таблицы. Хотя это можно смягчить с помощью materialized view (и проекций для части запросов), они требуют дополнительного сопровождения, а также осведомлённости пользователей об их наличии, чтобы гарантировать их использование. В то время как традиционные реляционные базы данных решают эту задачу с помощью вторичных индексов, они неэффективны в столбцовых базах данных, таких как ClickHouse. Вместо этого ClickHouse использует индексы пропуска данных (skip indexes), которые могут значительно повысить производительность запросов, позволяя базе данных пропускать крупные фрагменты данных, не содержащие подходящих значений.
Базовые схемы OTel используют вторичные индексы в попытке ускорить доступ к данным типа Map. Хотя на практике мы считаем их в целом неэффективными и не рекомендуем копировать их в вашу пользовательскую схему, индексы пропуска данных всё же могут быть полезны.
Перед тем как пытаться применять такие индексы, необходимо прочитать и понять руководство по вторичным индексам пропуска данных.
В общем случае они эффективны, когда существует сильная корреляция между первичным ключом и целевым непервичным столбцом/выражением, а пользователи выполняют поиск по редким значениям, то есть по тем, которые встречаются не во многих гранулах.
Фильтры Блума для текстового поиска
Для запросов обсервабилити вторичные индексы могут быть полезны при необходимости выполнения текстового поиска. В частности, индексы фильтров Блума на основе n-грамм и токенов ngrambf_v1 и tokenbf_v1 могут использоваться для ускорения поиска по столбцам типа String с операторами LIKE, IN и hasToken. Важно отметить, что индекс на основе токенов генерирует токены, используя в качестве разделителей неалфавитно-цифровые символы. Это означает, что при выполнении запроса могут быть найдены только токены (или целые слова). Для более детального поиска можно использовать фильтр Блума на основе N-грамм. Он разбивает строки на n-граммы заданного размера, что позволяет выполнять поиск по частям слов.
Для оценки токенов, которые будут сгенерированы и затем сопоставлены, используйте функцию tokens:
Функция ngram предоставляет аналогичные возможности, при этом размер ngram можно указать вторым параметром:
ClickHouse также имеет экспериментальную поддержку инвертированных индексов в качестве вторичного индекса. В настоящее время мы не рекомендуем их использовать для логов, но ожидаем, что они заменят токен-ориентированные фильтры Блума после выхода в production.
Для целей данного примера мы используем набор данных структурированных логов. Предположим, что необходимо подсчитать логи, в которых столбец Referer содержит ultra.
Здесь нужно выполнить сопоставление с размером n-грамм, равным 3. Поэтому создаём индекс ngrambf_v1.
Индекс ngrambf_v1(3, 10000, 3, 7) принимает четыре параметра. Последний из них (значение 7) представляет собой начальное значение (seed). Остальные представляют размер n-граммы (3), значение m (размер фильтра) и количество хеш-функций k (7). Параметры k и m требуют настройки и будут зависеть от количества уникальных n-грамм/токенов и вероятности того, что фильтр даст истинно отрицательный результат, тем самым подтверждая отсутствие значения в грануле. Рекомендуем использовать эти функции для определения этих значений.
Если всё настроено правильно, ускорение может быть существенным:
Приведённое выше предназначено исключительно для иллюстрации. Мы рекомендуем пользователям извлекать структуру из своих логов при вставке данных, а не пытаться оптимизировать текстовый поиск с помощью bloom-фильтров на основе токенов. Тем не менее существуют случаи, когда у пользователей есть трассировки стека или другие большие строки, для которых текстовый поиск может быть полезен из-за менее детерминированной структуры.
Несколько общих рекомендаций по использованию bloom-фильтров:
Задача bloom-фильтра — отфильтровать гранулы, тем самым избегая необходимости загружать все значения для столбца и выполнять линейное сканирование. Оператор EXPLAIN с параметром indexes=1 можно использовать для определения количества пропущенных гранул. Рассмотрите результаты ниже для исходной таблицы otel_logs_v2 и таблицы otel_logs_bloom с bloom-фильтром ngram.
Фильтр Блума, как правило, будет быстрее только в том случае, если он меньше самого столбца. Если он больше, выигрыш в производительности, скорее всего, будет несущественным. Сравните размер фильтра с размером столбца, используя следующие запросы:
В приведённых выше примерах видно, что вторичный индекс на основе bloom-фильтра имеет размер 12 МБ — почти в 5 раз меньше, чем сжатый размер самого столбца (56 МБ).
Bloom-фильтры могут требовать значительной тонкой настройки. Рекомендуем следовать примечаниям здесь, которые помогут определить оптимальные настройки. Bloom-фильтры также могут быть ресурсоёмкими на этапах вставки и слияния данных. Оцените влияние на производительность вставки, прежде чем добавлять bloom-фильтры в продакшн-среду.
Дополнительные сведения о вторичных пропускающих индексах можно найти здесь.
Извлечение из типов Map
Тип Map широко используется в схемах OTel. Для этого типа требуется, чтобы значения и ключи были одного и того же типа — этого достаточно для метаданных, таких как метки Kubernetes. Имейте в виду, что при выполнении запроса к вложенному ключу (subkey) типа Map загружается весь родительский столбец. Если в типе Map много ключей, это может привести к существенному замедлению запроса, поскольку с диска нужно читать больше данных, чем если бы этот ключ существовал как отдельный столбец.
Если вы часто выполняете запросы к определённому ключу, рассмотрите возможность вынести его в отдельный столбец на корневом уровне. Обычно это задача, которая выполняется в ответ на типичные паттерны доступа уже после развертывания и может быть сложной для прогнозирования до выхода в продакшен. См. раздел "Managing schema changes" о том, как изменять схему после развертывания.
Измерение размера таблицы и степени сжатия
Одна из основных причин, по которой ClickHouse используют для задач обсервабилити, — это сжатие.
Помимо существенного снижения затрат на хранение, меньшее количество данных на диске означает меньше операций ввода-вывода (I/O) и более быстрые запросы и вставки. Снижение объёма I/O перевесит накладные расходы на CPU, связанные с любым алгоритмом сжатия. Поэтому улучшение сжатия данных должно быть первым приоритетом при работе над обеспечением высокой скорости выполнения запросов в ClickHouse.
Подробности об измерении степени сжатия можно найти здесь.