- Выбор первичного ключа - В схемах по умолчанию используется
ORDER BY, оптимизированный под определённые паттерны доступа. Маловероятно, что ваши паттерны доступа будут им соответствовать. - Извлечение структуры - Возможно, вы захотите извлечь новые столбцы из существующих, например из столбца
Body. Это можно сделать с помощью материализованных столбцов (а в более сложных случаях — с помощью materialized view). Для этого требуется изменить схему. - Оптимизация Maps - В схемах по умолчанию для хранения атрибутов используется тип Map. Эти столбцы позволяют хранить произвольные метаданные. Хотя это важная возможность, поскольку метаданные событий часто заранее не определены и потому иначе не могут быть сохранены в строго типизированной базе данных, такой как ClickHouse, доступ к ключам Map и их значениям менее эффективен, чем доступ к обычному столбцу. Мы решаем это, изменяя схему и вынося наиболее часто используемые ключи Map в столбцы верхнего уровня — см. “Извлечение структуры с помощью SQL”. Для этого требуется изменить схему.
- Упрощение доступа к ключам Map - Доступ к ключам в Map требует более многословного синтаксиса. Это можно упростить с помощью псевдонимов. См. “Использование псевдонимов”, чтобы упростить запросы.
- Вторичные индексы - В схеме по умолчанию используются вторичные индексы для ускорения доступа к Maps и текстовых запросов. Обычно они не требуются и занимают дополнительное место на диске. Их можно использовать, но следует проверить, действительно ли они нужны. См. “Вторичные / индексы пропуска данных”.
- Использование кодеков - Возможно, вы захотите настроить кодеки для столбцов, если понимаете характер ожидаемых данных и у вас есть подтверждение, что это улучшает сжатие.
Извлечение структуры с помощью SQL
- Извлекать столбцы из строковых блобов. Запросы к ним будут выполняться быстрее, чем использование строковых операций во время выполнения запроса.
- Извлекать ключи из Map. Схема по умолчанию помещает произвольные атрибуты в столбцы типа Map. Этот тип позволяет работать без схемы, и поэтому пользователям не нужно заранее определять столбцы для атрибутов при описании журналов и трассировок — а это часто невозможно при сборе журналов из Kubernetes, если нужно сохранить labels подов для последующего поиска. Доступ к ключам Map и их значениям медленнее, чем выполнение запросов по обычным столбцам ClickHouse. Поэтому часто имеет смысл извлекать ключи из Map в столбцы корневой таблицы.
Body как String. Кроме того, он также может храниться в столбце LogAttributes как Map(String, String), если пользователь включил json_parser в коллекторе.
LogAttributes доступен, вот запрос, который позволяет подсчитать, какие URL-пути сайта получают больше всего POST-запросов:
LogAttributes['request_path'], и функции path для удаления параметров запроса из URL.
Если пользователь не включил парсинг JSON в коллекторе, LogAttributes будет пустым, поэтому нам придется использовать JSON-функции, чтобы извлечь столбцы из String Body.
Предпочитайте парсинг в ClickHouseКак правило, мы рекомендуем пользователям выполнять парсинг JSON структурированных журналов в ClickHouse. Мы уверены, что ClickHouse — самая быстрая реализация парсинга JSON. Однако мы понимаем, что вы можете захотеть отправлять журналы в другие источники и не выносить эту логику в SQL.
extractAllGroupsVertical.
Рассмотрите DictionariesПриведённый выше запрос можно оптимизировать, используя словари регулярных выражений. Подробнее см. в разделе Using Dictionaries.
OTel или ClickHouse для обработки?Вы также можете выполнять обработку с помощью процессоров и операторов OTel коллектор, как описано здесь. В большинстве случаев ClickHouse будет значительно эффективнее по ресурсам и быстрее, чем процессоры коллектор. Основной недостаток выполнения всей обработки событий в SQL — привязка вашего решения к ClickHouse. Например, вы можете захотеть отправлять обработанные журналы из OTel коллектор в другие пункты назначения, например в S3.
Материализованные столбцы
Накладные расходыМатериализованные столбцы требуют дополнительного места в хранилище, поскольку при вставке их значения извлекаются в новые столбцы на диске.
LogAttributes:
Body типа String с помощью JSON-функций можно найти здесь.
Наши три материализованных столбца извлекают страницу запроса, тип запроса и домен реферера. Они обращаются к ключам в map и применяют функции к их значениям. В результате следующий запрос выполняется значительно быстрее:
Материализованные столбцы по умолчанию не включаются в результат
SELECT *. Это сделано для сохранения инварианта: результат SELECT * всегда можно вставить обратно в таблицу с помощью INSERT. Это поведение можно отключить, установив asterisk_include_materialized_columns=1; его также можно включить в Grafana (см. Additional Settings -> Custom Settings в конфигурации источника данных).Materialized views
Обновления в реальном времениMaterialized views в ClickHouse обновляются в реальном времени по мере поступления данных в таблицу, на которой они основаны, и работают скорее как постоянно обновляемые индексы. В отличие от этого, в других базах данных materialized views обычно представляют собой статические снимки запроса, которые нужно обновлять (аналогично ClickHouse Refreshable Materialized Views).
SELECT.
Следует помнить, что запрос — это всего лишь триггер, который выполняется над строками, вставляемыми в таблицу (исходную таблицу), а результаты отправляются в новую таблицу (целевую таблицу).
Чтобы не сохранять данные дважды (в исходной и целевой таблицах), мы можем изменить движок исходной таблицы на Null table engine, сохранив исходную схему. Наши OTel коллекторы продолжат отправлять данные в эту таблицу. Например, для журналов таблица otel_logs будет выглядеть так:
/dev/null. Эта таблица не хранит данные, но все подключённые materialized view по-прежнему будут выполняться для вставляемых строк, прежде чем те будут отброшены.
Рассмотрим следующий запрос. Он преобразует наши строки в формат, который мы хотим сохранить, извлекая все столбцы из LogAttributes (предполагается, что это поле задаётся коллектором с помощью оператора json_parser), устанавливая SeverityText и SeverityNumber (на основе нескольких простых условий и определения этих столбцов). В этом случае мы также выбираем только те столбцы, которые, как мы знаем, будут заполнены, игнорируя такие столбцы, как TraceId, SpanId и TraceFlags.
Body — на случай, если позднее будут добавлены дополнительные атрибуты, не охваченные нашим SQL. Этот столбец хорошо сжимается в ClickHouse и будет запрашиваться редко, поэтому не влияет на производительность запросов. Наконец, мы приводим Timestamp к типу DateTime (для экономии места — см. «Оптимизация типов») с помощью приведения типа.
Условные выраженияОбратите внимание на использование условных выражений выше для извлечения
SeverityText и SeverityNumber. Они чрезвычайно полезны для построения сложных условий и проверки того, заданы ли значения в maps — здесь мы наивно предполагаем, что все ключи существуют в LogAttributes. Рекомендуем хорошо с ними ознакомиться: при парсинге логов они очень выручают, наряду с функциями для обработки NULL-значений!Обратите внимание, насколько сильно мы изменили схему. На практике у вас, скорее всего, будут и столбцы Trace, которые тоже стоит сохранить, а также столбец
ResourceAttributes (обычно в нём содержатся метаданные Kubernetes). Grafana может использовать столбцы Trace, чтобы связывать журналы и трейсы — см. “Использование Grafana”.otel_logs_mv, которое выполняет приведённый выше запрос SELECT для таблицы otel_logs и отправляет результаты в otel_logs_v2.
otel_logs_v2 в нужном нам формате. Обратите внимание на использование типизированных функций извлечения из JSON.
Body с помощью JSON-функций:
Будьте осторожны с типами
LogAttributes. ClickHouse часто автоматически приводит извлечённое значение к типу целевой таблицы, что позволяет упростить синтаксис. Однако мы рекомендуем всегда проверять свои view: выполнять оператор SELECT из view вместе с оператором INSERT INTO для целевой таблицы с той же схемой. Это поможет убедиться, что типы обрабатываются корректно. Особое внимание стоит обратить на следующие случаи:
- Если ключ отсутствует в map, будет возвращена пустая строка. Для числовых значений её нужно преобразовать в подходящее значение. Это можно сделать с помощью условных функций, например
if(LogAttributes['status'] = ", 200, LogAttributes['status']), или функций приведения типов, если допустимы значения по умолчанию, напримерtoUInt8OrDefault(LogAttributes['status'] ) - Некоторые типы приводятся не всегда — например, строковые представления чисел не будут приведены к значениям enum.
- Функции извлечения JSON возвращают значения по умолчанию для своего типа, если значение не найдено. Убедитесь, что эти значения действительно подходят!
Избегайте NullableИзбегайте использования Nullable в ClickHouse для данных обсервабилити. В журнале и трассировке редко требуется различать пустое значение и null. Эта возможность создаёт дополнительные накладные расходы на хранение и негативно влияет на производительность запросов. Подробнее см. здесь.
Выбор первичного ключа (ключа сортировки)
- Выбирайте столбцы, которые соответствуют вашим типичным фильтрам и сценариям доступа. Если вы обычно начинаете расследование в обсервабилити с фильтрации по конкретному столбцу, например по имени пода, этот столбец будет часто использоваться в секциях
WHERE. При прочих равных отдавайте приоритет таким столбцам при включении в ключ по сравнению с теми, которые используются реже. - Предпочитайте столбцы, которые при фильтрации позволяют исключить большую долю всех строк, тем самым уменьшая объем данных, которые нужно читать. Имена сервисов и коды состояния часто хорошо подходят на эту роль — во втором случае только если вы фильтруете по значениям, исключающим большую часть строк. Например, фильтрация по кодам 200 в большинстве систем будет соответствовать большей части строк, тогда как ошибки 500 затронут лишь небольшое подмножество.
- Предпочитайте столбцы, которые, вероятно, будут сильно коррелировать с другими столбцами в таблице. Это поможет сделать так, чтобы эти значения тоже хранились рядом, что улучшит сжатие.
- Операции
GROUP BYиORDER BYдля столбцов, входящих в ключ сортировки, можно сделать более эффективными с точки зрения памяти.
После того как подмножество столбцов для ключа сортировки определено, их нужно объявить в определенном порядке. Этот порядок может существенно влиять как на эффективность фильтрации по вторичным столбцам ключа в запросах, так и на коэффициент сжатия файлов данных таблицы. В общем случае столбцы ключа лучше располагать в порядке возрастания мощности. При этом нужно учитывать, что фильтрация по столбцам, расположенным позже в ключе сортировки, будет менее эффективной, чем по тем, которые стоят раньше в кортеже. Учитывайте этот компромисс и ваши сценарии доступа. И самое главное — тестируйте разные варианты. Чтобы лучше понять ключи сортировки и способы их оптимизации, рекомендуем эту статью.
Сначала структураМы рекомендуем определяться с ключами сортировки после того, как вы структурировали свои журналы. Не используйте ключи из map атрибутов или выражения извлечения JSON в качестве ключа сортировки. Убедитесь, что ключи сортировки представлены в вашей таблице как корневые столбцы.
Использование Map
map['key'] для доступа к значениям в столбцах типа Map(String, String). Помимо нотации map для доступа к вложенным ключам, в ClickHouse также доступны специализированные функции для map, которые позволяют фильтровать или выбирать данные из этих столбцов.
Например, следующий запрос определяет все уникальные ключи, доступные в столбце LogAttributes, с помощью функции mapKeys, а затем функции groupArrayDistinctArray (комбинатора).
Избегайте точекМы не рекомендуем использовать точки в именах столбцов Map и в будущем можем отказаться от их поддержки. Используйте
_.Использование псевдонимов
ALIAS RemoteAddr, который обращается к map LogAttributes. Теперь мы можем запрашивать значения LogAttributes['remote_addr'] через этот столбец, тем самым упрощая запрос, то есть
ALIAS с помощью команды ALTER TABLE очень просто. Эти столбцы сразу становятся доступными, например:
Столбцы ALIAS по умолчанию исключаютсяПо умолчанию
SELECT * не включает столбцы ALIAS. Это поведение можно отключить, задав asterisk_include_alias_columns=1.Оптимизация типов
Использование кодеков
ZSTD хорошо подходит для датасетов журнала и трасс. Увеличение уровня сжатия относительно значения по умолчанию, равного 1, может улучшить сжатие. Однако это следует проверять на практике, поскольку более высокие значения увеличивают нагрузку на CPU во время вставки. Обычно прирост от увеличения этого значения невелик.
Кроме того, хотя временные метки выигрывают от дельта-кодирования с точки зрения сжатия, если этот столбец используется в первичном ключе/ключе сортировки, это может приводить к снижению производительности запросов. Мы рекомендуем оценить соответствующий компромисс между степенью сжатия и производительностью запросов.
Использование словарей
Ускорение JOINПользователи, которым интересно ускорить JOIN с помощью словарей, могут найти дополнительные сведения здесь.
При вставке vs при выполнении запроса
- При вставке - Обычно этот подход подходит, если значение обогащения не меняется и хранится во внешнем источнике, который можно использовать для заполнения словаря. В этом случае обогащение строки при вставке позволяет избежать обращения к словарю во время выполнения запроса. Плата за это — снижение производительности вставки и дополнительные затраты на хранение, поскольку обогащённые значения будут сохраняться в виде столбцов.
- При выполнении запроса - Если значения в словаре часто меняются, обращения к словарю во время выполнения запроса обычно более уместны. Это позволяет избежать обновления столбцов (и перезаписи данных) при изменении сопоставленных значений. Однако за такую гибкость приходится платить дополнительными затратами на обращение к словарю во время выполнения запроса. Обычно эти затраты заметны, если поиск по ключу требуется для многих строк, например при использовании поиска по ключу по словарю в условии фильтрации. Для обогащения результатов, то есть в
SELECT, эти накладные расходы обычно несущественны.
Использование IP-словарей
ip_trie.
Мы используем общедоступный набор данных DB-IP с геоданными до уровня города, предоставляемый DB-IP.com на условиях лицензии CC BY 4.0.
Из README видно, что данные имеют следующую структуру:
URL(), чтобы создать в ClickHouse объект таблицы с именами наших полей и проверить общее количество строк:
ip_trie требует, чтобы диапазоны IP-адресов были заданы в нотации CIDR, нам потребуется преобразовать ip_range_start и ip_range_end.
CIDR для каждого диапазона можно легко вычислить с помощью следующего запроса:
В приведённом выше запросе много всего происходит. Если интересно, прочитайте это отличное объяснение. В противном случае просто примите, что этот код вычисляет CIDR для диапазона IP-адресов.
ip_trie структуру словаря, чтобы сопоставлять наши сетевые префиксы (CIDR-блоки) с координатами и кодами стран. Следующий запрос задаёт словарь с этой структурой и использует указанную выше таблицу в качестве источника.
Периодическое обновлениеСловари в ClickHouse периодически обновляются на основе данных базовой таблицы и параметра lifetime, указанного выше. Чтобы обновить наш словарь Geo IP и отразить в нём последние изменения в наборе данных DB-IP, достаточно повторно вставить данные из удалённой таблицы geoip_url в нашу таблицу
geoip, применив преобразования.ip_trie (который для удобства тоже называется ip_trie), мы можем использовать его для геолокации по IP-адресу. Это можно сделать с помощью функции dictGet() следующим образом:
RemoteAddress.
SELECT materialized view:
Периодическое обновлениеПользователи, скорее всего, захотят, чтобы словарь IP-обогащения периодически обновлялся по мере поступления новых данных. Это можно настроить с помощью предложения
LIFETIME словаря, благодаря которому словарь будет периодически перезагружаться из базовой таблицы. О том, как обновлять базовую таблицу, см. “Refreshable Materialized views”.Использование словарей на основе регулярных выражений (разбор user-agent)
Создайте следующие таблицы движка Memory. В них хранятся регулярные выражения для разбора устройств, браузеров и операционных систем.
otel_logs_v2:
Tuple для сложных структурОбратите внимание на использование типа Tuple в этих столбцах user-agent. Для сложных структур, где иерархия известна заранее, рекомендуется использовать Tuple. Подстолбцы обеспечивают ту же производительность, что и обычные столбцы (в отличие от ключей Map), при этом позволяя использовать неоднородные типы.
Дополнительные материалы
Ускорение запросов
Использование Materialized views (incremental) для агрегаций
Этот запрос был бы в 10 раз быстрее, если бы мы использовали таблицу
otel_logs_v2, полученную из нашей ранее созданной materialized view, которая извлекает ключ size из Map LogAttributes. Здесь мы используем исходные данные только в иллюстративных целях и рекомендуем использовать предыдущую view, если такой запрос встречается часто.bytes_per_hour пуста и в неё ещё не поступали данные. Наш materialized view выполняет приведённый выше SELECT для данных, вставляемых в otel_logs (это выполняется по блокам заданного размера), а результаты отправляются в bytes_per_hour. Синтаксис показан ниже:
TO, указывающий, куда будут отправлены результаты, то есть в bytes_per_hour.
Если перезапустить наш OTel collector и повторно отправить журналы, table bytes_per_hour будет постепенно заполняться результатами приведённого выше запроса. После завершения мы можем проверить размер bytes_per_hour — у нас должна быть 1 строка на каждый час:
otel_logs) до 113, сохранив результат нашего запроса. Ключевой момент в том, что если в таблицу otel_logs вставляются новые записи журнала, новые значения будут отправляться в bytes_per_hour для соответствующего часа, где они будут автоматически асинхронно объединяться в фоновом режиме — поскольку в bytes_per_hour хранится только одна строка на каждый час, эта таблица всегда остается и компактной, и актуальной.
Поскольку слияние строк происходит асинхронно, на момент выполнения запроса у пользователя может оказаться более одной строки на час. Чтобы гарантировать, что все ожидающие строки будут слиты во время выполнения запроса, у нас есть два варианта:
- Использовать модификатор
FINALу имени таблицы (что мы и сделали для запроса подсчета выше). - Выполнить агрегацию по ключу сортировки, используемому в нашей итоговой таблице, то есть по Timestamp, и суммировать метрики.
На более крупных наборах данных и при более сложных запросах выигрыш может быть еще больше. Примеры см. здесь.
Более сложный пример
UniqueUsers тип AggregateFunction, указывая функцию, из которой формируются частичные состояния (uniq), и тип исходного столбца (IPv4). Как и в случае с SummingMergeTree, строки с одинаковым значением ключа ORDER BY будут объединяться (Hour в приведённом выше примере).
Соответствующее materialized view использует предыдущий запрос:
State. Это гарантирует, что будет возвращаться агрегатное состояние функции, а не итоговый результат. Оно содержит дополнительную информацию, которая позволяет этому частичному состоянию объединяться с другими состояниями.
После повторной загрузки данных из-за перезапуска Collector мы можем убедиться, что в таблице unique_visitors_per_hour доступно 113 строк.
GROUP BY, а не FINAL.
Использование materialized views (incremental) для быстрого поиска
ServiceName, SpanName и Timestamp. Однако при трассировке пользователям также нужна возможность выполнять поиск по конкретному TraceId и получать связанные с этим трейсом спаны. Хотя это поле входит в ключ упорядочивания, его расположение в конце означает, что фильтрация будет менее эффективной, и, вероятно, при извлечении одного трейса потребуется сканировать значительные объёмы данных.
OTel collector также устанавливает materialized view и связанную с ней таблицу, чтобы решить эту проблему. Таблица и представление показаны ниже:
otel_traces_trace_id_ts хранятся минимальная и максимальная временные метки для трассировки. Эта таблица, упорядоченная по TraceId, позволяет эффективно извлекать эти временные метки. Эти диапазоны временных меток, в свою очередь, можно использовать при выполнении запросов к основной таблице otel_traces. Точнее, при получении трассировки по её идентификатору Grafana использует следующий запрос:
ae9226c78d1d360601e6383928e4d22d, а затем использует их, чтобы отфильтровать основную таблицу otel_traces по связанным с ним спанам.
Этот же подход можно применять и для похожих сценариев доступа. Мы рассматриваем аналогичный пример в разделе «Моделирование данных» здесь.
Использование проекций
ORDER BY.
В предыдущих разделах мы рассматривали, как materialized view можно использовать в ClickHouse для предварительного вычисления агрегаций, преобразования строк и оптимизации запросов обсервабилити для разных сценариев доступа.
Мы приводили пример, в котором materialized view отправляет строки в целевую таблицу с другим ключом сортировки, чем у исходной таблицы, принимающей вставки, чтобы оптимизировать поиск по trace ID.
Проекции можно использовать для решения той же задачи, позволяя пользователю оптимизировать запросы по столбцу, который не входит в первичный ключ.
Теоретически эту возможность можно использовать, чтобы задать для таблицы несколько ключей сортировки, но у этого есть один существенный недостаток: дублирование данных. В частности, данные нужно будет записывать в порядке основного первичного ключа, а также в порядке, заданном для каждой проекции. Это замедлит вставки и потребует больше места на диске.
Проекции и materialized viewПроекции предоставляют многие из тех же возможностей, что и materialized view, но использовать их следует с осторожностью, поскольку во многих случаях предпочтительнее именно materialized view. Важно понимать их недостатки и когда их уместно применять. Например, хотя проекции можно использовать для предварительного вычисления агрегаций, для этого мы рекомендуем использовать Materialized views.
otel_logs_v2 по кодам ошибок 500. Вероятно, это распространенный сценарий для журналирования, когда пользователи хотят фильтровать данные по кодам ошибок:
Используйте Null для оценки производительностиЗдесь мы не выводим результаты с помощью
FORMAT Null. Это заставляет систему прочитать все результаты, но не возвращать их, и тем самым предотвращает досрочное завершение запроса из-за LIMIT. Это нужно лишь для того, чтобы показать, сколько времени занимает сканирование всех 10 млн строк.(ServiceName, Timestamp). Хотя мы могли бы добавить Status в конец ключа сортировки, чтобы повысить производительность этого запроса, можно также добавить проекцию.
ALTER, то после выполнения команды MATERIALIZE PROJECTION она создаётся асинхронно. Ход этой операции можно проверить с помощью следующего запроса, дождавшись значения is_done=1.
SELECT *, будут сохранены все столбцы. Это позволит большему числу запросов (использующих любое подмножество столбцов) воспользоваться проекцией, но потребует дополнительного места на диске. О том, как измерить дисковое пространство и степень сжатия, см. “Измерение размера таблицы и степени сжатия”.
Вторичные индексы / индексы пропуска данных
Текстовый индекс для полнотекстового поиска
tokenizer в своём определении. При необходимости также можно указать функцию предобработки, чтобы преобразовать входную строку перед токенизацией.
Для поиска по индексу рекомендуется использовать функции hasAnyTokens и hasAllTokens.
Некоторые традиционные функции поиска по строкам также автоматически оптимизируются при наличии текстового индекса.
Подробности и список поддерживаемых функций см. в документации здесь и здесь.
В примерах ниже мы используем набор данных со структурированными журналами.
hasAnyTokens без текстового индекса, но тогда запрос выполнит медленное полное сканирование столбца Body:
Добавление текстового индекса
ALTER TABLE:
Использование препроцессора
msg, id, ctx, attr и т. д.).
Предположим, что нас интересует поиск только по полю msg.
Вместо индексации всей JSON-строки мы можем определить препроцессор, чтобы извлекать только значение msg перед токенизацией.
Например:
- уменьшает объём текста, который подвергается токенизации и индексированию,
- уменьшает размер индекса,
- снижает вероятность ложных срабатываний и
- повышает производительность запросов.