TL;DR: Мы взяли схему OTel-logs из эталонного бенчмарка компании ClickHouse Inc. и применили четыре этапа изменений: уточнение типов для ResourceAttributes, гигиену кодеков, использование optimize_row_order с повторной вставкой (re-insert) и оптимизацию конвейера загрузки данных. Результаты:
• Сжатый объем: 39,5 ГиБ → 16,8 ГиБ (−57%) на том же срезе из 1 млрд строк. • Столбец ResourceAttributes: 16,1 ГиБ → 60 МиБ (−99,6%, в 270 раз меньше). • Сквозные бенчмарки Q1–Q9 (прогретый кэш): 0,43 с → 0,36 с (−16%).
Код эталонного бенчмарка: ClickHouse/TextBench
Улучшение таблиц в ClickHouse не всегда требует масштабного редизайна. Несколько простых изменений в типах данных, сжатии и упорядочивании строк могут значительно сократить использование дискового пространства и ускорить запросы. В этой статье мы рассмотрим типичные ошибки проектирования схем и покажем, как их исправить.
Наша отправная точка
TextBench — это эталонный бенчмарк полнотекстового поиска, созданный ClickHouse для обеспечения честного и воспроизводимого измерения задержки запросов и эффективности хранения для рабочих нагрузок полнотекстового поиска, типичных при анализе логов. В бенчмарке используется 50 синтетических OpenTelemetry логов с 1 млрд строк.
Мы поставили перед собой задачу оптимизировать схему, используемую в примере ClickHouse, улучшив производительность запросов и использование хранилища. Мы применили четыре раунда изменений:
- Уточнение типа столбца ResourceAttributes
- Применение надлежащей гигиены кодеков, включая использование ZSTD(3) только для тяжелых столбцов
- Использование optimize_row_order с повторной вставкой
- Оптимизация конвейера загрузки данных
Мы расскажем о причинах принятых нами изменений и покажем результаты для каждого этапа, а затем подведем итог обзором всего, чего мы добились с помощью финального редизайна.
Конфигурация оборудования и исходная схема
В качестве оборудования мы использовали инстанс m8g.8xlarge и хранилище gp3 объемом 2,9 ТиБ с пропускной способностью 1500 МБ/с и 16000 IOPS. Том был выделен с небольшим запасом сверх лимита EBS для m8g.8xlarge в 1250 МБ/с, но это ближайший доступный размер. Как и в оригинальном бенчмарке, мы использовали ClickHouse версии 26.3.
Вот базовая схема, скопированная без изменений из эталонного бенчмарка:
Мы используем срез на 1 млрд строк, загруженный с помощью:
Мы отключили native_reader_v3 здесь, поскольку он работал в 2–3 раза медленнее варианта на базе Arrow. Это происходит из-за общего бюджета памяти для извлеченных сжатых блоков файлов Parquet и несжатых/распакованных блоков со столбцами. Несжатые данные используют большую часть бюджета памяти, что блокирует предварительную выборку следующих блоков. Финальная часть занимает 39,46 ГиБ в сжатом виде / 642,45 ГиБ в несжатом, то есть ровно в 16 раз меньше.
Вот объем данных по столбцам (обратите внимание на логарифмическую шкалу на графике):
Рисунок 1. Относительные размеры столбцов
Три столбца занимают 84% дискового пространства после сжатия:
Рисунок 2. Большая часть данных находится в трех столбцах
В этой статье мы следуем правилам сравнения эталонного бенчмарка:
- Тот же порядок на диске: (ServiceName, Timestamp) в качестве ключа сортировки ClickHouse и ключа индекса Elasticsearch.
- Тот же алгоритм сжатия (ZSTD).
- Инвертированный полнотекстовый индекс только для Body; все остальные текстовые поля предназначены только для точного совпадения (exact-match).
Есть еще одно ограничение, скорее подразумеваемое, чем заявленное: отсутствие каких-либо дополнительных структур помимо исходных столбцов и единственного текстового индекса по Body.
Запрос, который мы используем для проверки объема памяти каждого столбца, приводится здесь один раз и далее используется неявно. Замените <table> на имя рассматриваемой таблицы.
Шаг 1: Уточнение типа столбца ResourceAttributes
Полезный первый шаг для любого нового набора данных — подсчет уникальных значений для каждого столбца:
Рисунок 3. Уникальные значения на столбец
ResourceAttributes — это самый большой столбец на диске (16 ГиБ), но при этом он содержит всего около 2000 уникальных полных значений-карт на миллиард строк. Он описывает метаданные для каждого источника/пода, и существует всего несколько тысяч уникальных подов. Тип Map(LowCardinality(String), String) кодирует словарем ключи, но значения представляют собой обычную строку (String), и тип не использует повторения на уровне всей карты целиком.
У нас есть три варианта:
- Map(LowCardinality(String), LowCardinality(String)) — добавляет словарь для значений; сохраняет семантику доступа attrs[‘key’].
- LowCanonical(String) (LowCardinality(String)) — «сплющивает» карту в сериализованную строку; теряет доступ на уровне ключей, но обрабатывает всю карту как единое значение с низкой кардинальностью.
- Таблица поиска по хэшу (Hash-keyed lookup table) — хранит хэш (fingerprint) для каждой строки, а полные атрибуты хранит в отдельной таблице, соединенной по хэшу (JOIN). Это наиболее инвазивный и эффективный вариант, но он выходит за рамки ограничений бенчмарка. Мы не стали его здесь исследовать.
Примечание к варианту 1: существует известная проблема с производительностью для типов Map(LowCardinality, …) при использовании бакетной сериализации — см. ClickHouse#103804. Это не влияет на результаты в данной статье, так как функция не включена по умолчанию.
Мы тестируем варианты 1 и 2, добавляя теневые столбцы:
Хранилище
Давайте посмотрим на используемое дисковое пространство и задержку фильтрации:
Рисунок 4. Дисковое пространство и задержка фильтрации
Падение несжатого размера для плоского варианта (с 359 до 1,87 ГиБ) отражает то, как LowCardinality хранит данные для каждой строки: небольшой индекс на строку плюс один общий словарь на парт (кусок данных). system.parts_columns.column_data_uncompressed_bytes сообщает размер байтов только для словаря и файла столбца, а не размер словаря, развернутого в одну запись на строку.
Путь чтения
Сканирование всего столбца (SELECT count() FROM otel_logs_lc WHERE NOT ignore(<column>)):
Рисунок 5. Запросы выполняются до 65 раз быстрее
Факультативно (они не являются частью оригинального бенчмарка) мы можем быстро взглянуть на запросы или более точные фильтры, которые можно ожидать от системы, использующей данные логов. Мы также отключим кэширование предикатов, используя настройку сеанса use_query_condition_cache = 0:
Точный поиск по карте: Map(LC, String)
Тот же шаблон доступа, меньший столбец: Map(LC, LC)
Уплощенный поиск: поиск подстроки по сериализованной форме: LowCardinality(String) + LIKE
Рисунок 6. Обработано на 92% меньше байтов
Предостережение относительно запроса LIKE. LIKE ‘%k8s.pod.name%<value>%’ ищет обе подстроки в нужном порядке в любом месте сериализованного значения — это не строго эквивалентно attrs['k8s.pod.name'] = '<value>'. В этом наборе данных результаты совпадают, поскольку никакой другой ключ не содержит литерал k8s.pod.name, но для общего использования лучше выбрать более строгий шаблон, например '%"k8s.pod.name":"<value>"%', который отражает сериализованное представление.
Восстановление точного совпадения и группировки по уплощенному столбцу
Уплощение до LowCardinality(String) на самом деле не запрещает нам использовать преимущества доступа по ключу — оно просто переносит его из типизированного оператора в синтаксический анализ строк. В ClickHouse есть функция extractKeyValuePairs(s, key_delim, pair_delim, quote), которая разбирает сериализованную карту обратно в Map во время выполнения запроса. Полезны два шаблона.
1. Строгое точное совпадение. Объедините дешевый оператор LIKE с поиском через extractKeyValuePairs. LIKE — это грубый префильтр, который выполняет основную работу со словарем LC; синтаксический анализ запускается только для тех строк, которые прошли этот фильтр.
-- 0,289 с, обработано 2,00 ГБ
Это эквивалентно точному совпадению с исходным attrs[‘k8s.pod.name’] = ‘<value>’ в Map:
-- 13,86 с, обработано 25,4 ГБ
Версия с LIKE является более сложным запросом, но ее задержка, примерно в 50 раз меньшая, чем у базового Map(LC, String) (0.289 с против 13.86 с), оправдывает эти усилия.
2. Группировка по извлеченному ключу. Наивный подход — вызывать extractKeyValuePairs для каждой строки:
-- 7.49 с, 2.00 ГБ, 2.15 ГиБ пиковой памяти
Но синтаксический анализ — это самый ресурсоемкий шаг, а словарь LC означает, что большинство строк разделяют одно и то же сериализованное значение. Сначала сгруппируйте по всему сериализованному столбцу (это дешево — группировка идет по индексам словаря), агрегируйте до частичного состояния, а затем выполните синтаксический анализ и повторную агрегацию на гораздо меньшем сгруппированном результате:
-- 0.233 с, 428.71 млн строк, 857 МБ
Теперь синтаксический анализ выполняется один раз на каждую уникальную сериализованную карту (тысячи раз), а не один раз на строку (сотни миллионов раз). Возвращается тот же результат из 56 строк.
Сравнение для более широкого запроса развертывания «cart» рядом:
Рисунок 7. Комбинированные методы работают в 86 раз быстрее.
В остальной части этой статьи мы будем использовать LowCardinality(String) для ResourceAttributes. Шаблон доступа поддается восстановлению — он просто выглядит иначе во время выполнения запроса, а описанный выше двухуровневый шаблон работает быстрее любого из вариантов типизированных Map. Если вы предпочитаете сохранить исходный синтаксис attrs[‘key’], то Map(LC, LC) — это готовый вариант: он все так же в 6 раз меньше и в 4–6 раз быстрее базового варианта, без каких-либо изменений на стороне вызывающего кода.
Шаг 2. Применение правильной гигиены кодеков
В исходной схеме использовался ZSTD(1) для каждого столбца. Применение дорогостоящего кодека к столбцу, который и так хорошо сжимается, требует затрат ЦП как на запись, так и на чтение ради ничтожного выигрыша в объеме хранилища. Эти затраты суммируются для аналитических запросов, которые читают лишь подмножество столбцов: каждый байт ненужного распаковывания в горячем столбце — это чистые накладные расходы.
Удаление ZSTD из небольших / хорошо сжимающихся столбцов
Эвристика: удалите кодек для любых столбцов, которые оказывают незначительное влияние на общий сжатый размер таблицы. В нашем случае мы выберем все столбцы с размером в сжатом виде менее ~1 ГиБ. В сумме они составляют менее 10% от общего сжатого размера, но затраты на их распаковку оплачиваются при каждом сканировании всей строки.
Имейте в виду, что изменения кодеков происходят отложено; мы должны использовать OPTIMIZE для принудительной перезаписи. Мы также исправили ScopeName, который в базовом варианте был типом String, несмотря на наличие всего 21 уникального значения.
Рисунок 8. Удаление дорогих кодеков на небольших столбцах немного увеличивает использование хранилища.
Объем хранилища немного увеличился, но мы компенсируем это временем ЦП. Вот сканирование всей строки с помощью WHERE NOT ignore(*):
Рисунок 9. Более мелкие столбцы, использующие кодек по умолчанию, работают лучше.
Закономерность: чем меньше набор для чтения, тем больше относительный выигрыш — как раз потому, что те небольшие столбцы, которые мы только что декодировали, и были теми, что платили накладные расходы за ZSTD.
Тестирование уровней ZSTD для каждого столбца
Распространенное заблуждение заключается в том, что более высокие уровни ZSTD всегда работают медленнее и обеспечивают меньший размер. Уровни переключают различные эвристики; на данных в виде логов уровень 3 может быть как меньше, так и быстрее уровня 1.
Эталонная конфигурация Elasticsearch использует best_compression, что maps to ZSTD level 3 since 8.16. Для корректного сравнения в равных условиях это тот уровень, который фактически должен использоваться в исходной статье.
— Перезаписывайте только измененные столбцы, пропуская остальные:
Рисунок 10. Время сканирования по столбцам — размер практически не изменился, но время сканирования — нет.
Для этого набора данных ZSTD(3) однозначно лучше для LogAttributes. Для столбцов ID с высокой кардинальностью это нейтрально или немного больше на диске, но стабильно быстрее декодируется. Мы сохраняем ZSTD(3) ради выигрыша во времени чтения.
Шаг 3. Использование optimize_row_order
Бенчмарк обязывает нас использовать ORDER BY (ServiceName, Timestamp), но ClickHouse может уточнять порядок строк внутри групп ключей сортировки для улучшения локальности сжатия. За это отвечает настройка optimize_row_order = 1.
После этого — никаких изменений. ResourceAttributes по-прежнему составляет 1.28 ГиБ; общая сумма по-прежнему составляет 24.24 ГиБ.
Ловушка OPTIMIZE FINAL
optimize_row_order применяется во время слияний вновь вставленных частей. OPTIMIZE FINAL для существующих частей, которые были вставлены при старой настройке, не будет переупорядочивать их задним числом. Чтобы получить выигрыш на существующих данных, выполните повторную вставку через INSERT … SELECT:
Рисунок 11. Переупорядочение строк с последующей вставкой.
ResourceAttributes сжимается, потому что строки со значениями идентичной полной карты теперь группируются внутри гранул, что резко улучшает локальность сжатия. Столбцы ID с высокой кардинальностью почти не меняются — их значения генерируются случайным образом, поэтому их сортировка в другом порядке не сильно поможет.
Индекс text_idx также уменьшается
Переупорядочение строк также улучшает сжатие текстового индекса для Body — соседние строки теперь используют больше общих токенов, что используется компрессором инвертированных списков. Запрос к system.data_skipping_indices:
Рисунок 12. text_idx тоже уменьшается.
Это сокращение самого индекса на 38% в дополнение к полученным выше результатам по данным столбцов. С учетом итогового объема на диске (system.tables.total_bytes, который учитывает данные столбцов + засечки (marks) + первичный индекс + пропускающие индексы):
Рисунок 13. Наши результаты на данный момент.
Примечание: optimize_row_order работает очень хорошо в текущем примере, потому что есть много строк, которые имеют одинаковый ORDER BY (ServiceName, Timestamp) в пределах одной гранулы, где Timestamp округлен до секунд.
При правильно настроенном ORDER BY это, вероятно, не сильно улучшит ситуацию, потому что хороший ORDER BY должен использовать все доступное байтовое «пространство», предоставляемое index_granularity.
Шаг 4. Конвейер приема данных по умолчанию
Три небольших изменения в совокупности приводят к значительному удешевлению пути приема данных.
1. Не материализуйте тяжелые пропускающие индексы при вставке
Части, создаваемые с помощью INSERT и первых нескольких поколений слияний, недолговечны — они будут повторно объединены в течение нескольких секунд в любом кластере с активным приемом данных. Построенные на них индексы перестраиваются при каждом слиянии. Это контролируется двумя настройками сеанса:
Отключение материализации при вставке откладывает работу до первого слияния и любых последующих последовательных слияний. Индексы по-прежнему строятся автоматически, просто позже. Существуют соответствующие настройки merge_tree *_skip_indexes_on_merge, но их использование полностью отключает автоматическую материализацию индексов; в этом случае вам придется запускать ALTER TABLE … MATERIALIZE INDEX … вручную.
Для аудита стоимости материализации индексов посмотрите на ProfileEvents['MergeTreeDataWriterSkipIndicesCalculationMicroseconds'] в system.part_log.
2. Принудительное создание компактных частей (Compact parts) при вставке
Части во время вставки упаковываются лучше как Compact (один файл для всех столбцов), чем как Wide (как минимум два, а для сложных типов может быть много файлов на столбец), поскольку Compact имеет меньшие накладные расходы файловой системы на каждую часть — и мы все равно собираемся выбросить эти части при следующем слиянии.
Это принудительно переводит части 0-го уровня (созданные вставками) в формат Compact; следующее слияние создает части Compact или Wide 1-го уровня в обычном режиме, что определяется настройками merge_tree:
3. Примечание о воспроизводимости бенчмарка
Эталонный бенчмарк выполняет прием данных с помощью clickhouse-client --query 'INSERT … FORMAT Parquet', который читает, распаковывает и десериализует Parquet на стороне клиента и передает байты в собственном формате ClickHouse по сети. Наш INSERT … SELECT FROM s3(...) выполняет эту работу на сервере. Время сквозного приема данных поэтому напрямую несравнимо — в нашей настройке сервер выполняет больше работы на одну строку.
Сквозные результаты
Итоговая схема
Вот наша окончательная оптимизированная схема:
Мы используем ReplicatedMergeTree вместо MergeTree, так как целевой продакшн-кластер работает с реплицированными таблицами; приведенные выше выводы о хранении и ЦП остаются в силе. ResourceAttributes сворачивается в LowCardinality(String) — это приводит к потере доступа через attrs['key']. Если ваши запросы требуют этого, замените на Map(LowCardinality(String), LowCardinality(String)) и примите итоговый размер колонки около 2,7 ГиБ в обмен на нативный доступ по ключу.
Altinity.Cloud переопределяет значение по умолчанию для max_bytes_to_merge_at_max_space_in_pool со 150 ГиБ на 50 ГиБ, что позволяет ограничить время выполнения худших слияний.
Бенчмарк — Q1–Q9
Мы выполняем девять запросов из репозитория TextBench. Кэши сбрасываются перед холодными запусками (SYSTEM DROP MARK CACHE; SYSTEM DROP UNCOMPRESSED CACHE; SYSTEM DROP PAGE CACHE; … полный список смотрите в репозитории). Это не то же самое, что «холодный» бенчмарк кэша из оригинального блога, но близко к нему.
Каждая ячейка представляет собой медиану трех запусков; в таблице указана сумма медиан по запросам Q1–Q9.
Производительность
Вот показатели производительности:
Рисунок 14. Сводка бенчмарка производительности
Показатели для каждого запроса и файлы запросов находятся в форке.
Хранение
Рисунок 15. Сводка бенчмарка хранения
Резюме
Вот краткая сводка решенных нами проблем:
Проблема: колонки Map обрабатываются как непрозрачные
Исправление: Map(LC,LC) или преобразование в LowCardinality(String):
Рисунок 16. Результаты использования LowCardinality
Проблема: ZSTD применяется повсеместно
Исправление: удалить кодек для колонок размером менее ~1 ГиБ; использовать ZSTD(3) для тяжелых колонок:
Рисунок 17. Использование правильной гигиены кодеков
Проблема: optimize_row_order не используется
Исправление: включить и выполнить пересохранение через INSERT … SELECT:
Рисунок 18. Использование optimize_row_order и пересохранение
Проблема: стандартный конвейер вставки
Исправление: пропускать индексы при вставке, выполнять компактификацию при вставке
Рисунок 19. Использование оптимизированного конвейера вставки
Заключение
Мы начали со схемы из оригинальной статьи с бенчмарком от ClickHouse Inc. За 3 итерации неинвазивных оптимизаций, ни одна из которых не нарушала исходных правил бенчмарка, мы уменьшили размер таблицы в 2 раза и ускорили выполнение запросов. По правде говоря, эти шаги лишь задают базовый уровень того, что должно быть сделано для любой таблицы значительного объема. В бенчмарке заявляется реалистичность данных, но сама схема совершенно нереалистична для серьезных продакшн-систем. Тем не менее, это было полезно для демонстрации некоторых базовых шагов и методов, которые мы применяем при оптимизации схемы.
Уроки:
- Кодеки — палка о двух концах. Тяжелые кодеки оправдывают себя только тогда, когда объем сжатых данных оправдывает затраты ЦП на кодирование/декодирование. Безопасное правило: применяйте ZSTD (и особенно более высокие уровни) к самым большим колонкам таблицы. Маленькие или уже повторяющиеся колонки выжимаются ради минимального выигрыша на диске, в то время как каждое сканирование тратит ЦП на декомпрессию. Выбор уровня сжатия тоже не так прост — более высокий уровень не означает более медленное или лучшее сжатие. Тестируйте настройки сжатия для каждой колонки; не применяйте один и тот же кодек и уровень ко всем колонкам подряд.
- Изучайте свои данные. Самая большая колонка в исходной таблице, ResourceAttributes размером 16 ГиБ, содержала всего около 2000 уникальных значений полных карт. Довольно дорогая ошибка. И вы будете платить за нее многократно: хранилище, ЦП на запись + слияние, ЦП на каждое чтение и даже фильтрацию. Это также указывает на серьезную структурную слабость данной таблицы. Она не проектировалась с учетом реального использования в продакшне.
- ORDER BY — одно из важнейших решений, которые вы можете принять. Тот факт, что сам по себе optimize_row_order обеспечил уменьшение общего размера таблицы (и размера индекса) на 31%, является не победой, а индикатором. Индикатором того, что взаимосвязь / сходство / локальность данных не были должным образом использованы при проектировании схемы таблицы. Когда вы проектируете таблицы, одной из ваших главных целей должно быть извлечение как можно большей регулярности из ваших данных и построение схемы вокруг них, чтобы ClickHouse мог видеть и использовать их во время выполнения. Да, здесь мы были ограничены правилами бенчмарка в том, что мы можем сделать с ORDER BY, но даже самые мелкие доступные нам вещи принесли значительный прирост.
Приложение: то, что мы пробовали, но что не помогло
index_granularity против min/max_compress_block_size
Для таблиц с довольно тяжелыми строками — или особенно тяжелыми отдельными полями — может быть выгодно уменьшить index_granularity со стандартного значения 8192 примерно до 512. Значения ниже 512 возможны, но требуют тщательного бенчмаркинга: размер первичного индекса в памяти увеличивается, а стоимость сканирования может возрасти. Гранулярность пропускных индексов (skip-indexes) должна быть скорректирована пропорционально.
ClickHouse также поддерживает динамический расчет гранулярности, поэтому проверяйте, что именно вы получили из system.parts:
Эффективность сжатия зависит как от размера гранулы, так и от размера блока сжатия (min_compress_block_size / max_compress_block_size, см. контекст в ClickHouse#55201). Они настраиваются для каждой колонки:
Для отдельной таблицы в стиле логов при index_granularity = 512:
Рисунок 20. Настройка min/max_compress_block_size не помогла
Менее актуально для полей, хранящихся независимо (файлы для каждой колонки, подколонки кортежей, динамические пути JSON), поскольку ограничение блока сжатия применяется к каждой подколонке в отдельности.





