Проектирование схемы для мониторинга
Мы рекомендуем пользователям всегда создавать свою собственную схему для логов и трейсов по следующим причинам:
- Выбор первичного ключа - Стандартные схемы используют
ORDER BY
, который оптимизирован для определённых шаблонов доступа. Мало вероятно, что ваши шаблоны доступа совпадут с этим. - Извлечение структуры - Пользователи могут захотеть извлекать новые колонки из существующих колонок, например, из колонки
Body
. Это можно сделать с помощью материализованных колонок (и материализованных представлений в более сложных случаях). Это требует изменений в схеме. - Оптимизация Maps - Стандартные схемы используют тип Map для хранения атрибутов. Эти колонки позволяют хранить произвольные метаданные. Несмотря на то, что это необходимость, так как метаданные из событий часто не определены заранее и поэтому не могут быть сохранены в строго типизированной базе данных, такой как ClickHouse, доступ к ключам карты и их значениям не так эффективен, как доступ к обычной колонке. Мы решаем это, модифицируя схему и обеспечивая, чтобы наиболее часто используемые ключи карты были верхнеуровневыми колонками - см. "Извлечение структуры с помощью SQL". Это также требует изменения схемы.
- Упрощение доступа к ключам карты - Доступ к ключам в картах требует более многословного синтаксиса. Пользователи могут смягчить это с помощью алиасов. См. "Использование алиасов" для упрощения запросов.
- Вторичные индексы - Стандартная схема использует вторичные индексы для ускорения доступа к Maps и ускорения текстовых запросов. Обычно они не требуются и занимают дополнительное место на диске. Их можно использовать, но следует протестировать, чтобы убедиться, что они необходимы. См. "Вторичные / Индексы пропуска данных".
- Использование кодеков - Пользователи могут захотеть настроить кодеки для колонок, если они понимают ожидаемые данные и имеют доказательства того, что это улучшает сжатие.
Мы подробно описываем каждый из вышеуказанных случаев использования ниже.
Важно: Хотя пользователям рекомендуется расширять и изменять свою схему для достижения оптимального сжатия и производительности запросов, они должны придерживаться наименований схемы OTel для основных колонок, когда это возможно. Плагин ClickHouse Grafana предполагает наличие некоторых основных колонок OTel для помощи в построении запросов, например, Timestamp и SeverityText. Требуемые колонки для логов и трейсов задокументированы здесь [1][2] и здесь соответственно. Вы можете изменить эти имена колонок, переопределив стандартные значения в конфигурации плагина.
Извлечение структуры с помощью SQL
Независимо от того, принимаете ли вы структурированные или неструктурированные логи, пользователям часто требуется возможность:
- Извлекать колонки из строковых BLOBов. Запросы к ним будут быстрее, чем использование строковых операций при выполнении запроса.
- Извлекать ключи из карт. Стандартная схема помещает произвольные атрибуты в колонки типа Map. Этот тип предоставляет безсхемную возможность, которая имеет преимущество в том, что пользователи не должны заранее определять колонки для атрибутов при определении логов и трейсов - это часто невозможно, когда логи собираются из Kubernetes и необходимо обеспечить сохранение меток подов для последующего поиска. Доступ к ключам карты и их значениям медленнее, чем запросы к обычным колонкам ClickHouse. Извлечение ключей из карт в корневые колонки таблицы, следовательно, часто желательно.
Рассмотрим следующие запросы:
Предположим, мы хотим подсчитать, какие URL-адреса получают наибольшее количество POST-запросов с использованием структурированных логов. JSON BLOB хранится в колонке Body
как строка. Кроме того, он может также храниться в колонке LogAttributes
как Map(String, String)
, если пользователь включил json_parser в сборщике.
Предположим, что LogAttributes
доступен, запрос для подсчета, какие URL-адреса сайта получают наибольшее количество POST-запросов:
Обратите внимание на использование синтаксиса карты здесь, например LogAttributes['request_path']
, и функцию path
для удаления параметров запроса из URL.
Если пользователь не включил разбор JSON в сборщике, то LogAttributes
будет пустым, и нам придется использовать функции JSON для извлечения колонок из строкового Body
.
Мы обычно рекомендуем пользователям выполнять разбор JSON в ClickHouse для структурированных логов. Мы уверены, что ClickHouse - это самое быстрое реализация разбора JSON. Однако мы понимаем, что пользователи могут захотеть отправить логи в другие источники и не хотят, чтобы эта логика располагалась в SQL.
Теперь рассмотрим то же самое для неструктурированных логов:
Аналогичный запрос для неструктурированных логов требует использования регулярных выражений через функцию extractAllGroupsVertical
.
Возросшая сложность и стоимость запросов для разбора неструктурированных логов (обратите внимание на разницу в производительности) - вот почему мы рекомендуем пользователям всегда использовать структурированные логи, когда это возможно.
Вышеупомянутый запрос можно оптимизировать для использования регулярных выражений словарей. См. Использование словарей для получения более подробной информации.
Оба этих случая использования могут быть удовлетворены с помощью ClickHouse, переместив вышеупомянутую логику запросов на время вставки. Мы исследуем несколько подходов ниже, подчеркивая, когда каждый из них подходит.
Пользователи также могут выполнять обработку с помощью процессоров и операторов OTel Collector, как описано здесь. В большинстве случаев пользователи обнаружат, что ClickHouse значительно эффективнее по ресурсам и быстрее, чем процессоры сборщика. Основной недостаток выполнения всей обработки событий в SQL - это связывание вашего решения с ClickHouse. Например, пользователи могут захотеть отправить обработанные логи в альтернативные назначения из сборщика OTel, например, S3.
Материализованные колонки
Материализованные колонки предлагают простейшее решение для извлечения структуры из других колонок. Значения таких колонок всегда вычисляются в момент вставки и не могут быть указаны в запросах INSERT.
Материализованные колонки имеют дополнительные накладные расходы по хранению, так как значения извлекаются в новые колонки на диске в момент вставки.
Материализованные колонки поддерживают любое выражение ClickHouse и могут использовать любые аналитические функции для обработки строк (включая регулярные выражения и поиск и URL, выполняя преобразования типов, извлекая значения из JSON или математические операции.
Мы рекомендуем материализованные колонки для базовой обработки. Они особенно полезны для извлечения значений из карт, продвижения их к корневым колонкам и выполнения преобразований типов. Они наиболее полезны, когда используются в очень простых схемах или в сочетании с материализованными представлениями. Рассмотрим следующую схему для логов, из которых JSON был извлечён в колонку LogAttributes
сборщиком:
Эквивалентная схема для извлечения с помощью функций JSON из строкового Body
может быть найдена здесь.
Наши три материлизованные колонки извлекают страницу запроса, тип запроса и домен реферера. Эти колонки получают доступ к ключам карты и применяют функции к их значениям. Наш последующий запрос значительно быстрее:
Материализованные колонки по умолчанию не будут возвращаться в SELECT *
. Это необходимо для сохранения инварианта, что результат SELECT *
всегда может быть снова вставлен в таблицу с помощью INSERT. Это поведение можно отключить, установив asterisk_include_materialized_columns=1
, и можно включить в Grafana (см. Дополнительные настройки -> Пользовательские настройки
в конфигурации источника данных).
Материализованные представления
Материализованные представления предоставляют более мощные средства для применения SQL-фильтрации и трансформаций к логам и трейсам.
Материализованные представления позволяют пользователям перенести затраты на вычисление с времени запроса на время вставки. Материализованное представление ClickHouse - это просто триггер, который запускает запрос на блоках данных по мере их вставки в таблицу. Результаты этого запроса вставляются во вторую "целевую" таблицу.

Материализованные представления в ClickHouse обновляются в реальном времени по мере поступления данных в таблицу, на которой они основаны, функционируя больше как постоянно обновляющиеся индексы. В отличие от этого, в других базах данных материализованные представления обычно являются статическими моментами запроса, которые должны быть обновлены (аналогично обновляемым материализованным представлениям ClickHouse).
Запрос, связанный с материализованным представлением, теоретически может быть любым запросом, включая агрегацию, хотя существуют ограничения с Joins. Для трансформаций и рабочих нагрузок фильтрации, требуемых для логов и трейсов, пользователи могут считать любое выражение SELECT
возможным.
Пользователи должны помнить, что запрос - это просто триггер, выполняющийся над строками, вставляемыми в таблицу (исходная таблица), с результатами, отправляемыми в новую таблицу (целевую таблицу).
Чтобы убедиться, что мы не сохраняем данные дважды (в исходной и целевой таблицах), мы можем изменить движок таблицы исходной таблицы на Null table engine, сохраняя исходную схему. Наши сборщики OTel будут продолжать отправлять данные в эту таблицу. Например, для логов таблица otel_logs
становится:
Null table engine - это мощная оптимизация - рассматривайте её как /dev/null
. Эта таблица не будет хранить никакие данные, но любые прикрепленные материализованные представления будут все равно выполняться над вставленными строками, прежде чем они будут отброшены.
Рассмотрим следующий запрос. Он преобразует наши строки в формат, который мы хотим сохранить, извлекая все колонки из LogAttributes
(предполагаем, что это было установлено сборщиком с использованием оператора json_parser
), устанавливая SeverityText
и SeverityNumber
(на основе некоторых простых условий и определения этих колонок). В этом случае мы также выбираем только те колонки, которые мы знаем, что будут заполнены - игнорируя такие колонки, как TraceId
, SpanId
и TraceFlags
.
Мы также извлекаем колонку Body
выше - на случай, если позже будут добавлены дополнительные атрибуты, которые не извлечены нашим SQL. Эта колонка должна хорошо сжиматься в ClickHouse и будет редко запрашиваться, таким образом, не влияя на производительность запроса. Наконец, мы уменьшаем Timestamp до DateTime (для экономии места - см. "Оптимизация типов") с приведением типов.
Обратите внимание на использование условий выше для извлечения SeverityText
и SeverityNumber
. Они чрезвычайно полезны для формулирования сложных условий и проверки наличия значений в картах - мы наивно предполагаем, что все ключи присутствуют в LogAttributes
. Рекомендуем пользователям стать с ними знакомыми - они ваши союзники в разбираемом логировании, помимо функций для обработки нулевых значений!
Нам нужна таблица для получения этих результатов. Ниже целевая таблица соответствует вышеизложенному запросу:
Выбранные здесь типы основываются на оптимизациях, обсужденных в "Оптимизация типов".
Обратите внимание, как мы кардинально изменили нашу схему. На практике пользователи, вероятно, также захотят сохранить колонки Trace, а также колонку ResourceAttributes
(она обычно содержит метаданные Kubernetes). Grafana может использовать колонки Trace для обеспечения функциональности соединения между логами и трейса - см. "Использование Grafana".
Ниже мы создаем материализованное представление otel_logs_mv
, которое выполняет вышеуказанный выбор для таблицы otel_logs
и отправляет результаты в otel_logs_v2
.
Это визуализируется ниже:

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

Это удобно в различных сценариях, от обогащения принимаемых данных на лету без замедления процесса приема и улучшения производительности запросов в целом, при этом JOINы особенно выигрывают. Хотя JOINы редко требуются в сценариях наблюдаемости, словари все же могут быть полезны для обогащения - как во время вставки, так и во время запроса. Ниже мы предоставляем примеры обоих случаев.
Пользователи, заинтересованные в ускорении соединений с помощью словарей, могут найти дополнительные сведения здесь.
Время вставки vs время запроса
Словари могут использоваться для обогащения наборов данных как во время запроса, так и во время вставки. Каждому из этих подходов свойственны свои плюсы и минусы. В общем:
- Время вставки - Это обычно подойдет, если значение обогащения не изменяется и существует во внешнем источнике, который можно использовать для заполнения словаря. В этом случае обогащение строки во время вставки избегает времени поиска в словаре. Это отрицательно сказывается на производительности вставки, а также создает добавленную нагрузку на хранилище, так как обогащенные значения будут храниться как колонки.
- Время запроса - Если значения в словаре часто меняются, запросы на поиск по времени запроса часто более применимы. Это исключает необходимость обновления колонок (и переписывания данных), если сопоставленные значения изменяются. Эта гибкость обеспечивается за счет дополнительных затрат на поиск во время запроса. Эти затраты на время запроса обычно ощутимы, если поиск требуется для многих строк, например, при использовании поиска по словарю в условии фильтра. Для обогащения результата, т.е. в
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
dictionary structure для сопоставления наших сетевых префиксов (CIDR-блоков) с координатами и кодами стран. Следующий запрос определяет словарь, используя эту структуру и вышеуказанную таблицу в качестве источника.
Мы можем выбрать строки из словаря и подтвердить, что этот набор данных доступен для выборок:
Словари в ClickHouse периодически обновляются на основе данных исходной таблицы и используемого выше условия жизни. Чтобы обновить наш гео IP словарь, чтобы отразить последние изменения в наборе данных DB-IP, нам просто нужно повторно вставить данные из удаленной таблицы geoip_url в нашу таблицу geoip
с применением преобразований.
Теперь, когда у нас загружены данные Geo IP в словарь ip_trie
(удобно также названный ip_trie
), мы можем использовать его для геолокации IP. Это можно сделать, используя функцию dictGet()
следующим образом:
Обратите внимание на скорость извлечения здесь. Это позволяет нам обогащать журналы. В данном случае мы решили выполнить обогащение во время запроса.
Возвращаясь к нашему исходному набору данных журналов, мы можем использовать вышеуказанное для агрегации наших журналов по странам. Следующий запрос предполагает, что мы используем схему, полученную из нашего предыдущего материализованного представления, в которой есть извлеченная колонка RemoteAddress
.
Поскольку сопоставление IP к географической локации может измениться, пользователи вероятно захотят знать, откуда пришел запрос в момент его отправки - а не о том, каково текущее географическое местоположение для того же адреса. По этой причине, вероятно, предпочтительно обогащение во время индексации. Это можно сделать с помощью материализованных колонок, как показано ниже, или в запросе материализованного представления:
Пользователи, вероятно, захотят, чтобы словарь обогащения IP периодически обновлялся на основе новых данных. Это можно достичь с помощью условия LIFETIME
словаря, которое заставит словарь периодически перезагружаться из исходной таблицы. Чтобы обновить исходную таблицу, смотрите "Обновляемые материализованные представления".
Указанные выше страны и координаты предлагают возможности визуализации, помимо группировки и фильтрации по странам. Для вдохновения смотрите "Визуализация геоданных".
Использование Регулярных Словарей (Парсинг User Agent)
Парсинг строк user-agent является классической проблемой регулярного выражения и общей необходимостью для наборов данных на основе журналов и трассировок. ClickHouse предоставляет эффективный парсинг user agents с использованием Словарей Регулярных Выражений.
Словари регулярных выражений определяются в ClickHouse с использованием типа источника словаря YAMLRegExpTree, который предоставляет путь к YAML-файлу, содержащему дерево регулярных выражений. Если вы хотите предоставить свой собственный словарь регулярных выражений, информация о требуемой структуре может быть найдена здесь. Ниже мы сосредоточимся на парсинге user-agent, используя uap-core и загрузим наш словарь для поддерживаемого формата CSV. Этот подход совместим с OSS и ClickHouse Cloud.
Создайте следующие таблицы Memory. Эти таблицы хранят наши регулярные выражения для парсинга устройств, браузеров и операционных систем.
Эти таблицы могут быть заполнены из следующих файлов CSV, которые размещены в открытом доступе, с использованием табличной функции url:
С нашими заполненными таблицами Memory мы можем загрузить наши словари Регулярных Выражений. Обратите внимание, что нам нужно указать значения ключей как колонки - это будут атрибуты, которые мы сможем извлечь из user agent.
После загрузки этих словарей мы можем предоставить пример user-agent и протестировать наши новые возможности извлечения словаря:
Учитывая, что правила, касающиеся user agents, будут редко меняться, и словарь будет нуждаться в обновлении только в ответ на новые браузеры, операционные системы и устройства, имеет смысл выполнять это извлечение во время вставки.
Мы можем либо выполнить эту работу с использованием материализованной колонки, либо используя материализованное представление. Ниже мы изменим материализованное представление, использованное ранее:
Это требует от нас изменить схему целевой таблицы otel_logs_v2
:
После перезапуска сборщика и загрузки структурированных журналов, основываясь на ранее задокументированных шагах, мы можем запрашивать наши новые извлеченные колонки Device, Browser и Os.
Обратите внимание на использование кортежей для этих колонок user agent. Кортежи рекомендуются для сложных структур, где иерархия известна заранее. Подколонки имеют такую же производительность, как обычные колонки (в отличие от ключей Map), позволяя использовать различный типы.
Дополнительное чтение
Для получения дополнительных примеров и деталей по словарям, мы рекомендуем следующие статьи:
Ускорение запросов
ClickHouse поддерживает ряд методов для ускорения производительности запросов. Следующие методы следует рассмотреть только после выбора подходящего первичного/упорядочивающего ключа, чтобы оптимизировать наиболее популярные паттерны доступа и максимально увеличить сжатие. Это обычно будет иметь наибольшее влияние на производительность за наименьшие усилия.
Использование материализованных представлений (инкрементные) для агрегаций
В предыдущих разделах мы рассмотрели использование материализованных представлений для преобразования и фильтрации данных. Однако материализованные представления также могут использоваться для предварительного вычисления агрегаций во время вставки и сохранения результата. Этот результат можно обновлять с результатами последующих вставок, что фактически позволяет выполнять агрегацию на этапе вставки.
Основная идея заключается в том, что результаты часто будут меньшей репрезентацией оригинальных данных (частичный набросок в случае агрегаций). В сочетании с более простым запросом для чтения результатов из целевой таблицы, время выполнения запросов будет быстрее, чем если бы те же вычисления выполнялись на оригинальных данных.
Рассмотрим следующий запрос, где мы вычисляем общий трафик в час, используя наши структурированные логи:
Мы можем предположить, что это может быть общая линейная диаграмма, которую пользователи строят с помощью Grafana. Этот запрос, безусловно, очень быстрый – в наборе данных всего 10 миллионов строк, и ClickHouse быстр! Однако, если мы масштабируем это до миллиардов и триллионов строк, мы, в идеале, хотели бы поддерживать такую производительность запроса.
Этот запрос был бы в 10 раз быстрее, если бы мы использовали таблицу otel_logs_v2
, которая является результатом нашего ранее созданного материализованного представления, извлекающего ключ размера из LogAttributes
map. Мы используем необработанные данные здесь только для иллюстративных целей и рекомендуем использовать ранее созданное представление, если это общий запрос.
Нам нужна таблица для получения результатов, если мы хотим вычислять это во время вставки, используя материализованное представление. Эта таблица должна хранить только 1 строку за час. Если поступает обновление для существующего часа, другие колонки должны объединяться в строку существующего часа. Для того чтобы это слияние инкрементальных состояний происходило, частичные состояния должны храниться для других колонок.
Это требует особого типа движка в ClickHouse: SummingMergeTree. Этот движок заменяет все строки с одинаковым ключом сортировки на одну строку, содержащую суммированные значения числовых колонок. Следующая таблица будет объединять любые строки с одинаковой датой, суммируя любые числовые колонки.
Чтобы продемонстрировать наше материализованное представление, предположим, что наша таблица bytes_per_hour
пуста и еще не получила никаких данных. Наше материализованное представление выполняет вышеуказанный 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.
Чтобы обеспечить хранение агрегированных состояний, мы определяем колонку UniqueUsers
как тип AggregateFunction
, указывая исходную функцию частичных состояний (uniq) и тип исходной колонки (IPv4). Как и в случае с SummingMergeTree, строки с одинаковым значением ключа ORDER BY
будут объединяться (Hour в приведенном выше примере).
Связанное материализованное представление использует предыдущий запрос:
Обратите внимание, как мы добавляем суффикс State
к концу наших агрегатных функций. Это гарантирует, что возвращается состояние агрегата функции, а не финальный результат. Это будет содержать дополнительную информацию, позволяющую этому частичному состоянию объединяться с другими состояниями.
После перезагрузки данных через перезапуск Collectora мы можем подтвердить, что в таблице unique_visitors_per_hour
доступны 113 строк.
Наш окончательный запрос должен использовать суффикс Merge для наших функций (так как колонки хранят частичные состояния агрегации):
Обратите внимание, что мы используем GROUP BY
здесь вместо использования FINAL
.
Использование материализованных представлений (инкрементные) для быстрого поиска
Пользователи должны учитывать свои шаблоны доступа при выборе ключа сортировки в ClickHouse с колонками, которые часто используются в блоках фильтрации и агрегации. Это может быть ограничительным в случаях наблюдаемости, где пользователи имеют более разнообразные шаблоны доступа, которые не могут быть обобщены в едином наборе колонок. Это лучше всего проиллюстрировать на примере, встроенном в стандартные схемы OTel. Рассмотрим стандартную схему для трассировки:
Эта схема оптимизирована для фильтрации по ServiceName
, SpanName
и Timestamp
. В трассировке пользователи также должны иметь возможность выполнять поиск по конкретному TraceId
и извлекать соответствующие спаны трассировки. Хотя это присутствует в ключе сортировки, его положение в конце означает, что фильтрация не будет такой эффективной и, вероятно, означает, что значительные объемы данных нужно будет просканировать при извлечении одной трассировки.
OTel Collector также устанавливает материализованное представление и связную таблицу, чтобы решить эту проблему. Таблица и представление показаны ниже:
Представление эффективно обеспечивает наличие в таблице otel_traces_trace_id_ts
минимальной и максимальной метки времени для трассировки. Эта таблица, отсортированная по TraceId
, позволяет эффективно извлекать эти временные метки. Эти временные диапазоны могут, в свою очередь, быть использованы при запросе основной таблицы otel_traces
. Более конкретно, когда мы извлекаем трассировку по её идентификатору, Grafana использует следующий запрос:
CTE здесь идентифицирует минимальную и максимальную метку времени для идентификатора трассировки ae9226c78d1d360601e6383928e4d22d
, прежде чем использовать это для фильтрации основной таблицы otel_traces
по её связанным спанам.
Такой же подход может быть применён для аналогичных шаблонов доступа. Мы исследуем похожий пример в моделировании данных здесь.
Использование проекций
Проекции ClickHouse позволяют пользователям задавать несколько клаузул ORDER BY
для таблицы.
В предыдущих разделах мы исследовали, как материализованные представления могут быть использованы в ClickHouse для предварительного вычисления агрегаций, преобразования строк и оптимизации запросов наблюдаемости для различных шаблонов доступа.
Мы привели пример, где материализованное представление отправляет строки в целевую таблицу с другим ключом сортировки, чем оригинальная таблица, принимающая вставки, для оптимизации поиска по идентификатору трассировки.
Проекции могут быть использованы для решения той же проблемы, позволяя пользователю оптимизировать запросы по колонке, которая не является частью первичного ключа.
Теоретически, эта возможность может быть использована для предоставления нескольких ключей сортировки для таблицы, с одним явным недостатком: дублированием данных. В частности, данные должны быть записаны в порядке основного первичного ключа, помимо указанного порядка для каждой проекции. Это замедлит вставки и потребует больше места на диске.
Проекции предлагают многие из тех же возможностей, что и материализованные представления, но их следует использовать с осторожностью, так как последние часто предпочитаются. Пользователи должны понимать недостатки и когда они уместны. Например, хотя проекции могут быть использованы для предварительного вычисления агрегаций, мы рекомендуем пользователям использовать материализованные представления для этого.

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