Проектирование схемы
Понимание эффективного проектирования схемы является ключом к оптимизации производительности ClickHouse и включает в себя выбор, который часто связан с компромиссами, при этом оптимальный подход зависит от обслуживаемых запросов, а также от таких факторов, как частота обновления данных, требования к задержке и объем данных. Это руководство предоставляет обзор лучших практик проектирования схем и техник моделирования данных для оптимизации производительности ClickHouse.
Набор данных Stack Overflow
Для примеров в этом руководстве мы используем подмножество набора данных Stack Overflow. Он содержит все посты, голоса, пользователей, комментарии и значки, которые появились на Stack Overflow с 2008 года по апрель 2024 года. Эти данные доступны в формате Parquet по схемам, представленным ниже, в корзине S3 s3://datasets-documentation/stackoverflow/parquet/:
Первичные ключи и связи, указанные здесь, не применяются через ограничения (Parquet — это формат файла, а не таблицы) и только указывают, как данные связаны и какие уникальные ключи они имеют.

Набор данных Stack Overflow содержит ряд связанных таблиц. В любой задаче моделирования данных мы рекомендуем пользователям сначала сосредоточиться на загрузке основной таблицы. Это необязательно самая большая таблица, а скорее та, для которой вы ожидаете получать больше всего аналитических запросов. Это позволит вам ознакомиться с основными концепциями и типами ClickHouse, что особенно важно, если вы пришли из преимущественно OLTP-среды. Эта таблица может потребовать перемоделирования по мере добавления дополнительных таблиц для полного использования функций ClickHouse и достижения оптимальной производительности.
Приведённая выше схема намеренно не является оптимальной для целей данного руководства.
Создание начальной схемы
Поскольку таблица posts будет целью большинства аналитических запросов, мы сосредоточимся на создании схемы для этой таблицы. Эти данные доступны в публичной корзине S3 s3://datasets-documentation/stackoverflow/parquet/posts/*.parquet с файлом для каждого года.
Загрузка данных из S3 в формате Parquet представляет собой наиболее распространенный и предпочтительный способ загрузки данных в ClickHouse. ClickHouse оптимизирован для обработки Parquet и потенциально может читать и вставлять десятки миллионов строк из S3 в секунду.
ClickHouse предоставляет возможность автоматического определения схемы для автоматической идентификации типов набора данных. Это поддерживается для всех форматов данных, включая Parquet. Мы можем использовать эту функцию для определения типов ClickHouse для данных через табличную функцию s3 и команду DESCRIBE. Обратите внимание, что ниже мы используем glob-шаблон *.parquet для чтения всех файлов в папке stackoverflow/parquet/posts.
Табличная функция s3 позволяет запрашивать данные в S3 непосредственно из ClickHouse. Эта функция совместима со всеми форматами файлов, которые поддерживает ClickHouse.
Это формирует для нас начальную, не оптимизированную схему. По умолчанию ClickHouse отображает их в соответствующие типы Nullable. Мы можем создать таблицу ClickHouse, используя эти типы, с помощью простой команды CREATE EMPTY AS SELECT.
Несколько важных моментов:
Наша таблица posts пуста после выполнения этой команды. Никакие данные не были загружены. Мы указали MergeTree в качестве движка таблицы. MergeTree — это наиболее распространенный движок таблиц ClickHouse, который вы, вероятно, будете использовать. Это универсальный инструмент в вашем арсенале ClickHouse, способный обрабатывать петабайты данных и обслуживать большинство аналитических случаев использования. Другие движки таблиц существуют для таких случаев использования, как CDC, которым необходима поддержка эффективных обновлений.
Предложение ORDER BY () означает, что у нас нет индекса и, более конкретно, нет порядка в наших данных. Подробнее об этом позже. Пока просто знайте, что для всех запросов потребуется линейное сканирование.
Чтобы подтвердить создание таблицы:
После определения начальной схемы мы можем заполнить данные с помощью INSERT INTO SELECT, считывая данные с помощью табличной функции s3. Следующая загрузка данных posts занимает около 2 минут на 8-ядерном экземпляре ClickHouse Cloud.
Приведённый выше запрос загружает 60 миллионов строк. Хотя это небольшой объём для ClickHouse, пользователи с более медленным интернет-соединением могут предпочесть загрузить подмножество данных. Это можно сделать, просто указав годы, которые нужно загрузить, с помощью glob-шаблона, например
https://datasets-documentation.s3.eu-west-3.amazonaws.com/stackoverflow/parquet/posts/2008.parquetилиhttps://datasets-documentation.s3.eu-west-3.amazonaws.com/stackoverflow/parquet/posts/{2008, 2009}.parquet. См. здесь, как glob-шаблоны можно использовать для выбора подмножеств файлов.
Оптимизация типов
Один из секретов производительности запросов ClickHouse — это сжатие.
Меньше данных на диске означает меньше операций ввода-вывода и, следовательно, более быстрые запросы и вставки. Накладные расходы любого алгоритма сжатия в отношении CPU в большинстве случаев будут перевешены сокращением операций ввода-вывода. Поэтому улучшение сжатия данных должно быть первым фокусом при работе над обеспечением быстрых запросов ClickHouse.
Чтобы понять, почему ClickHouse так хорошо сжимает данные, мы рекомендуем эту статью. Вкратце, как столбцовая база данных, значения будут записываться в порядке столбцов. Если эти значения отсортированы, одинаковые значения будут находиться рядом друг с другом. Алгоритмы сжатия используют непрерывные шаблоны данных. Кроме того, ClickHouse имеет кодеки и детализированные типы данных, которые позволяют пользователям дополнительно настраивать методы сжатия.
На сжатие в ClickHouse будут влиять 3 основных фактора: ключ сортировки, типы данных и любые используемые кодеки. Все это настраивается через схему.
Наибольшее первоначальное улучшение сжатия и производительности запросов можно получить с помощью простого процесса оптимизации типов. Можно применить несколько простых правил для оптимизации схемы:
- Используйте строгие типы - Наша начальная схема использовала строки для многих столбцов, которые явно являются числовыми. Использование правильных типов обеспечит ожидаемую семантику при фильтрации и агрегировании. То же самое относится к типам дат, которые были правильно предоставлены в файлах Parquet.
- Избегайте Nullable столбцов - По умолчанию приведенные выше столбцы предполагались Null. Тип Nullable позволяет запросам определять разницу между пустым значением и значением Null. Это создает отдельный столбец типа UInt8. Этот дополнительный столбец должен обрабатываться каждый раз, когда пользователь работает со столбцом nullable. Это приводит к дополнительному использованию пространства хранения и почти всегда негативно влияет на производительность запросов. Используйте Nullable только в том случае, если есть разница между пустым значением по умолчанию для типа и значением Null. Например, значение 0 для пустых значений в столбце
ViewCount, вероятно, будет достаточным для большинства запросов и не повлияет на результаты. Если пустые значения должны обрабатываться по-другому, их также часто можно исключить из запросов с помощью фильтра. - Используйте минимальную точность для числовых типов - ClickHouse имеет ряд числовых типов, предназначенных для различных числовых диапазонов и точности. Всегда стремитесь минимизировать количество битов, используемых для представления столбца. Помимо целых чисел разного размера, например Int16, ClickHouse предлагает беззнаковые варианты, минимальное значение которых равно 0. Они могут позволить использовать меньше битов для столбца, например, UInt16 имеет максимальное значение 65535, что в два раза больше, чем у Int16. Предпочитайте эти типы более крупным знаковым вариантам, если это возможно.
- Минимальная точность для типов дат - ClickHouse поддерживает ряд типов дат и дат-времени. Date и Date32 могут использоваться для хранения чистых дат, причем последний поддерживает больший диапазон дат за счет большего количества битов. DateTime и DateTime64 обеспечивают поддержку дат-времени. DateTime ограничен детализацией до секунды и использует 32 бита. DateTime64, как следует из названия, использует 64 бита, но обеспечивает поддержку с точностью до наносекунды. Как всегда, выбирайте более грубую версию, приемлемую для запросов, минимизируя количество необходимых битов.
- Используйте LowCardinality - Столбцы с числами, строками, Date или DateTime с небольшим количеством уникальных значений потенциально могут быть закодированы с использованием типа LowCardinality. Это кодирует значения в словарь, уменьшая размер на диске. Рассмотрите это для столбцов с менее чем 10 тысячами уникальных значений.
- FixedString для особых случаев - Строки с фиксированной длиной могут быть закодированы типом FixedString, например, коды языков и валют. Это эффективно, когда данные имеют длину ровно N байтов. Во всех остальных случаях это, вероятно, снизит эффективность, и предпочтительнее использовать LowCardinality.
- Enum для проверки данных - Тип Enum можно использовать для эффективного кодирования перечисляемых типов. Enum может быть 8 или 16 бит в зависимости от количества уникальных значений, которые они должны хранить. Рассмотрите возможность использования этого, если вам нужна либо связанная проверка во время вставки (необъявленные значения будут отклонены), либо вы хотите выполнять запросы, использующие естественный порядок значений Enum, например, представьте столбец обратной связи, содержащий ответы пользователей
Enum(':(' = 1, ':|' = 2, ':)' = 3).
Совет: Чтобы найти диапазон всех столбцов и количество различных значений, пользователи могут использовать простой запрос
SELECT * APPLY min, * APPLY max, * APPLY uniq FROM table FORMAT Vertical. Мы рекомендуем выполнять это на меньшем подмножестве данных, так как это может быть дорогостоящим. Этот запрос требует, чтобы числовые значения были хотя бы определены как таковые для получения точного результата, то есть не как String.
Применяя эти простые правила к нашей таблице posts, мы можем определить оптимальный тип для каждого столбца:
| Столбец | Числовой | Мин, Макс | Уникальные значения | Nulls | Комментарий | Оптимизированный тип |
|---|---|---|---|---|---|---|
PostTypeId | Да | 1, 8 | 8 | Нет | Enum('Question' = 1, 'Answer' = 2, 'Wiki' = 3, 'TagWikiExcerpt' = 4, 'TagWiki' = 5, 'ModeratorNomination' = 6, 'WikiPlaceholder' = 7, 'PrivilegeWiki' = 8) | |
AcceptedAnswerId | Да | 0, 78285170 | 12282094 | Да | Различать Null со значением 0 | UInt32 |
CreationDate | Нет | 2008-07-31 21:42:52.667000000, 2024-03-31 23:59:17.697000000 | - | Нет | Детализация миллисекунд не требуется, использовать DateTime | DateTime |
Score | Да | -217, 34970 | 3236 | Нет | Int32 | |
ViewCount | Да | 2, 13962748 | 170867 | Нет | UInt32 | |
Body | Нет | - | - | Нет | String | |
OwnerUserId | Да | -1, 4056915 | 6256237 | Да | Int32 | |
OwnerDisplayName | Нет | - | 181251 | Да | Считать Null пустой строкой | String |
LastEditorUserId | Да | -1, 9999993 | 1104694 | Да | 0 — неиспользуемое значение, может использоваться для Nulls | Int32 |
LastEditorDisplayName | Нет | - | 70952 | Да | Считать Null пустой строкой. Тестировали LowCardinality без преимуществ | String |
LastEditDate | Нет | 2008-08-01 13:24:35.051000000, 2024-04-06 21:01:22.697000000 | - | Нет | Детализация миллисекунд не требуется, использовать DateTime | DateTime |
LastActivityDate | Нет | 2008-08-01 12:19:17.417000000, 2024-04-06 21:01:22.697000000 | - | Нет | Детализация миллисекунд не требуется, использовать DateTime | DateTime |
Title | Нет | - | - | Нет | Считать Null пустой строкой | String |
Tags | Нет | - | - | Нет | Считать Null пустой строкой | String |
AnswerCount | Да | 0, 518 | 216 | Нет | Считать Null и 0 одинаковыми | UInt16 |
CommentCount | Да | 0, 135 | 100 | Нет | Считать Null и 0 одинаковыми | UInt8 |
FavoriteCount | Да | 0, 225 | 6 | Да | Считать Null и 0 одинаковыми | UInt8 |
ContentLicense | Нет | - | 3 | Нет | LowCardinality превосходит FixedString | LowCardinality(String) |
ParentId | Нет | - | 20696028 | Да | Считать Null пустой строкой | String |
CommunityOwnedDate | Нет | 2008-08-12 04:59:35.017000000, 2024-04-01 05:36:41.380000000 | - | Да | Считать значением по умолчанию 1970-01-01 для Nulls. Детализация миллисекунд не требуется, использовать DateTime | DateTime |
ClosedDate | Нет | 2008-09-04 20:56:44, 2024-04-06 18:49:25.393000000 | - | Да | Считать значением по умолчанию 1970-01-01 для Nulls. Детализация миллисекунд не требуется, использовать DateTime | DateTime |
Приведенное выше дает нам следующую схему:
Мы можем заполнить эту таблицу простым INSERT INTO SELECT, прочитав данные из нашей предыдущей таблицы и вставив их в неё:
Мы не храним значения NULL в нашей новой схеме. Приведенная выше операция вставки неявно преобразует их в значения по умолчанию для соответствующих типов — 0 для целых чисел и пустое значение для строк. ClickHouse также автоматически преобразует все числовые значения к их целевой точности. Первичные (упорядочивающие) ключи в ClickHouse Пользователи, переходящие с OLTP-баз данных, часто ищут аналогичную концепцию в ClickHouse.
Выбор ключа сортировки
В масштабе, в котором часто используется ClickHouse, эффективность памяти и диска имеет первостепенное значение. Данные записываются в таблицы ClickHouse фрагментами, известными как части (parts), с правилами, применяемыми для слияния частей в фоновом режиме. В ClickHouse каждая часть имеет свой собственный первичный индекс. Когда части объединяются, первичные индексы объединенной части также объединяются. Первичный индекс для части имеет одну запись индекса на группу строк — этот метод называется разреженным индексированием.

Выбранный ключ в ClickHouse будет определять не только индекс, но и порядок, в котором данные записываются на диск. Из-за этого он может значительно влиять на уровни сжатия, что, в свою очередь, может влиять на производительность запросов. Ключ сортировки, который заставляет значения большинства столбцов записываться в смежном порядке, позволит выбранному алгоритму сжатия (и кодекам) сжимать данные более эффективно.
Все столбцы в таблице будут отсортированы на основе значения указанного ключа сортировки, независимо от того, включены ли они в сам ключ. Например, если
CreationDateиспользуется в качестве ключа, порядок значений во всех других столбцах будет соответствовать порядку значений в столбцеCreationDate. Можно указать несколько ключей сортировки — это будет упорядочено с той же семантикой, что и предложениеORDER BYв запросеSELECT.
Можно применить некоторые простые правила, чтобы помочь выбрать ключ сортировки. Следующие правила иногда могут конфликтовать, поэтому рассматривайте их по порядку. Пользователи могут определить несколько ключей из этого процесса, при этом обычно достаточно 4-5:
- Выберите столбцы, которые соответствуют вашим общим фильтрам. Если столбец часто используется в предложениях
WHERE, отдайте приоритет включению их в ваш ключ над теми, которые используются реже. - Предпочитайте столбцы, которые помогают исключить большой процент от общего количества строк при фильтрации, тем самым уменьшая объем данных, которые необходимо прочитать.
- Предпочитайте столбцы, которые, вероятно, сильно коррелируют с другими столбцами в таблице. Это поможет обеспечить смежное хранение этих значений, улучшая сжатие.
- Операции
GROUP BYиORDER BYдля столбцов в ключе сортировки могут быть более эффективными с точки зрения памяти.
При определении подмножества столбцов для ключа сортировки объявите столбцы в определенном порядке. Этот порядок может существенно повлиять как на эффективность фильтрации по столбцам вторичного ключа в запросах, так и на коэффициент сжатия файлов данных таблицы. В общем, лучше всего упорядочивать ключи в порядке возрастания кардинальности. Это следует сбалансировать с тем фактом, что фильтрация по столбцам, которые появляются позже в ключе сортировки, будет менее эффективной, чем фильтрация по тем, которые появляются раньше в кортеже. Сбалансируйте эти поведения и рассмотрите свои шаблоны доступа (и, что наиболее важно, тестируйте варианты).
Пример
Применяя приведенные выше рекомендации к нашей таблице posts, предположим, что наши пользователи хотят выполнять анализ с фильтрацией по дате и типу поста, например:
«У каких вопросов было больше всего комментариев за последние 3 месяца».
Запрос для этого с использованием нашей предыдущей таблицы posts_v2 с оптимизированными типами, но без ключа сортировки:
Запрос выполняется очень быстро, хотя все 60 млн строк были линейно просканированы — ClickHouse просто очень быстрый :) Поверьте, на тера- и петабайтных масштабах ключи сортировки действительно себя оправдывают!
Давайте выберем столбцы PostTypeId и CreationDate в качестве ключей сортировки.
Возможно, в нашем случае мы ожидаем, что пользователи всегда будут фильтровать по PostTypeId. У него кардинальность 8, и он представляет собой логический выбор для первой записи в нашем ключе сортировки. Признавая, что фильтрации с детализацией до даты, вероятно, будет достаточно (это все равно принесет пользу фильтрам datetime), мы используем toDate(CreationDate) в качестве 2-го компонента нашего ключа. Это также создаст меньший индекс, так как дата может быть представлена 16 битами, ускоряя фильтрацию. Наша последняя запись ключа — это CommentCount для помощи в поиске постов с наибольшим количеством комментариев (окончательная сортировка).
Для пользователей, которые интересуются улучшением сжатия, достигаемым за счёт использования определённых типов и подходящих ключей сортировки, см. раздел Compression in ClickHouse. Если вам нужно ещё больше повысить степень сжатия, мы также рекомендуем раздел Choosing the right column compression codec.
Далее: техники моделирования данных
До этого момента мы мигрировали только одну таблицу. Хотя это позволило нам познакомиться с некоторыми базовыми концепциями ClickHouse, большинство схем, к сожалению, не настолько просты.
В других руководствах, перечисленных ниже, мы рассмотрим ряд техник реструктуризации нашей общей схемы для оптимального выполнения запросов в ClickHouse. На протяжении этого процесса мы стремимся к тому, чтобы Posts оставалась нашей центральной таблицей, через которую выполняется большинство аналитических запросов. Хотя к другим таблицам по‑прежнему можно обращаться изолированно, мы предполагаем, что основная часть аналитики будет выполняться в контексте Posts.
В этом разделе мы используем оптимизированные варианты наших других таблиц. Хотя мы предоставляем их схемы, ради краткости мы опускаем объяснение принятых решений. Они основаны на правилах, описанных ранее, и мы оставляем вывод этих решений читателю.
Все описанные ниже подходы нацелены на минимизацию необходимости использования JOIN для оптимизации чтения и повышения производительности запросов. Хотя JOIN полностью поддерживается в ClickHouse, мы рекомендуем использовать его умеренно (JOIN‑запрос с 2–3 таблицами приемлем), чтобы добиться оптимальной производительности.
В ClickHouse отсутствует понятие внешних ключей. Это не запрещает JOIN, но означает, что поддержание ссылочной целостности перекладывается на пользователя, который должен управлять ею на уровне приложения. В OLAP‑системах, таких как ClickHouse, целостность данных часто обеспечивается на уровне приложения или в процессе ингестии данных, а не принудительно на уровне самой базы данных, где это создает значительные накладные расходы. Такой подход обеспечивает большую гибкость и более быструю вставку данных. Это соответствует ориентации ClickHouse на скорость и масштабируемость выполнения запросов на чтение и вставку для очень больших наборов данных.
Чтобы минимизировать использование JOIN на этапе выполнения запросов, у пользователей есть несколько инструментов/подходов:
- Денормализация данных — Денормализуйте данные, объединяя таблицы и используя сложные типы для связей, отличающихся от 1:1. Это часто включает перенос любых JOIN с момента выполнения запроса на момент вставки.
- Dictionaries — Специфичная для ClickHouse возможность для обработки прямых JOIN и поиска по ключу.
- Incremental Materialized Views — Возможность ClickHouse, позволяющая перенести стоимость вычислений с момента выполнения запроса на момент вставки, включая возможность инкрементального вычисления агрегированных значений.
- Refreshable Materialized Views — Аналог materialized view, используемых в других системах управления базами данных; позволяет периодически выполнять запрос и кэшировать его результат.
Мы рассматриваем каждый из этих подходов в отдельных руководствах, показывая, когда какой из них уместен, на примере того, как его можно применить для решения задач на наборе данных Stack Overflow.