Проектирование схемы
Понимание эффективного проектирования схемы является ключом к оптимизации производительности 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 для помощи в поиске постов с наибольшим количеством комментариев (окончательная сортировка).
Для пользователей, заинтересованных в улучшениях сжатия, достигнутых за счет использования конкретных типов и соответствующих ключей сортировки, см. Сжатие в ClickHouse. Если пользователям необходимо дополнительно улучшить сжатие, мы также рекомендуем раздел Выбор правильного кодека сжатия столбцов.
Далее: Техники моделирования данных
До сих пор мы перенесли только одну таблицу. Хотя это позволило нам познакомиться с некоторыми основными концепциями ClickHouse, большинство схем, к сожалению, не так просты.
В других руководствах, перечисленных ниже, мы рассмотрим ряд техник для реструктуризации нашей более широкой схемы для оптимального запроса ClickHouse. В ходе этого процесса мы стремимся к тому, чтобы Posts оставался нашей центральной таблицей, через которую выполняется большинство аналитических запросов. Хотя другие таблицы все еще могут запрашиваться изолированно, мы предполагаем, что большая часть аналитики должна выполняться в контексте posts.
В этом разделе мы используем оптимизированные варианты наших других таблиц. Хотя мы предоставляем схемы для них, для краткости мы опускаем принятые решения. Они основаны на правилах, описанных ранее, и мы оставляем вывод решений читателю.
Следующие подходы направлены на минимизацию необходимости использования JOIN для оптимизации чтения и повышения производительности запросов. Хотя JOIN полностью поддерживаются в ClickHouse, мы рекомендуем использовать их умеренно (2-3 таблицы в запросе JOIN — это нормально) для достижения оптимальной производительности.
ClickHouse не имеет понятия внешних ключей. Это не запрещает соединения, но означает, что ссылочная целостность остается на усмотрение пользователя для управления на уровне приложения. В системах OLAP, таких как ClickHouse, целостность данных часто управляется на уровне приложения или во время процесса приема данных, а не принудительно применяется базой данных, где это создает значительные накладные расходы. Этот подход обеспечивает большую гибкость и более быструю вставку данных. Это соответствует фокусу ClickHouse на скорость и масштабируемость запросов чтения и вставки с очень большими наборами данных.
Чтобы минимизировать использование Join во время выполнения запросов, у пользователей есть несколько инструментов/подходов:
- Денормализация данных - Денормализация данных путем объединения таблиц и использования сложных типов для отношений не 1:1. Это часто включает перенос любых соединений с времени запроса на время вставки.
- Словари - Специфическая функция ClickHouse для обработки прямых соединений и поиска по ключу-значению.
- Инкрементальные материализованные представления - Функция ClickHouse для переноса стоимости вычисления с времени запроса на время вставки, включая возможность инкрементного вычисления агрегированных значений.
- Обновляемые материализованные представления - Подобно материализованным представлениям, используемым в других продуктах баз данных, это позволяет периодически вычислять результаты запроса и кэшировать результат.
Мы рассматриваем каждый из этих подходов в каждом руководстве, подчеркивая, когда каждый из них уместен, с примером, показывающим, как его можно применить для решения вопросов для набора данных Stack Overflow.