Проектирование схемы
Понимание эффективного проектирования схемы является ключом к оптимизации производительности ClickHouse и включает в себя выбор, который часто подразумевает компромиссы; оптимальный подход зависит как от выполняемых запросов, так и от факторов, таких как частота обновления данных, требования к задержке и объем данных. Этот гид предоставляет обзор лучших практик проектирования схем и методов моделирования данных для оптимизации производительности ClickHouse.
Набор данных Stack Overflow
Для примеров в этом гидe мы используем подмножество набора данных 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
. Обратите внимание, что ниже мы используем глобальный шаблон *.parquet
, чтобы прочитать все файлы в папке stackoverflow/parquet/posts
.
Функция s3 table позволяет запрашивать данные в S3 на месте из ClickHouse. Эта функция совместима со всеми форматами файлов, которые поддерживает ClickHouse.
Это предоставляет нам начальную необработанную схему. По умолчанию ClickHouse сопоставляет их с эквивалентными типами Nullable. Мы можем создать таблицу ClickHouse, используя эти типы, с помощью простой команды CREATE EMPTY AS SELECT
.
Несколько важных моментов:
Наша таблица постов пуста после выполнения этой команды. Данные не были загружены. Мы указали MergeTree как движок нашей таблицы. MergeTree - это наиболее распространенный движок таблиц ClickHouse, который вы, вероятно, будете использовать. Он является многофункциональным инструментом в вашем арсенале ClickHouse, способным обрабатывать PB данных и обслуживать большинство аналитических случаев. Существуют и другие движки таблиц для использования в таких случаях, как CDC, которые должны поддерживать эффективные обновления.
Клаусула ORDER BY ()
означает, что у нас нет индекса и, более конкретно, никакого порядка в наших данных. Подробнее об этом позже. Пока просто знайте, что все запросы потребуют линейного сканирования.
Чтобы подтвердить, что таблица была создана:
С нашей начальной схемой определенной, мы можем заполнить данные, используя INSERT INTO SELECT
, читая данные с помощью функции таблицы s3. Следующее загружает данные posts
примерно за 2 минуты на облачном экземпляре ClickHouse с 8 ядрами.
Вышеприведенный запрос загружает 60 млн строк. Хотя это немного для ClickHouse, пользователи с медленными интернет-соединениями могут захотеть загрузить подмножество данных. Это можно сделать, просто указав годы, которые они хотят загрузить, через глобальный шаблон, например
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
. Смотрите здесь о том, как глобальные шаблоны могут быть использованы для нацеливания на подмножества файлов.
Оптимизация типов
Одна из тайн производительности запросов ClickHouse заключается в сжатии.
Меньше данных на диске означает меньше I/O и, следовательно, более быстрые запросы и вставки. Нагрузка любого алгоритма сжатия по сравнению с CPU в большинстве случаев будет компенсирована сокращением I/O. Поэтому улучшение сжатия данных должно быть первым приоритетом при работе над обеспечением быстрой работы запросов 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. Этот словарь кодирует значения, уменьшая размер на диске. Рассмотрите это для колонок с менее чем 10k уникальными значениями.
- FixedString для специальных случаев - Строки, которые имеют фиксированную длину, могут быть закодированы с помощью типа FixedString, например, коды языка и валюты. Это эффективно, когда данные имеют длину ровно N байт. В остальных случаях это, вероятно, снизит эффективность, и предпочтительнее использовать LowCardinality.
- Enums для валидации данных - Тип Enum может быть использован для эффективного кодирования перечисляемых типов. Enums могут быть 8 или 16 бит, в зависимости от количества уникальных значений, которые они должны хранить. Рассмотрите использование этого, если вам нужна либо связанная валидация во время вставки (недекларированные значения будут отвергнуты), либо хотите выполнять запросы, которые используют естественный порядок в значениях Enum, например, представьте колонку обратной связи, содержащую ответы пользователей
Enum(':(' = 1, ':|' = 2, ':)' = 3)
.
Совет: Чтобы найти диапазон всех колонок и количество различных значений, пользователи могут использовать простой запрос
SELECT * APPLY min, * APPLY max, * APPLY uniq FROM table FORMAT Vertical
. Мы рекомендуем выполнить это для меньшего подмножества данных, так как это может быть затратным. Этот запрос требует, чтобы числовые значения были как минимум определены как таковые для получения точного результата, т.е. не как строка.
Применяя эти простые правила к нашей таблице постов, мы можем определить оптимальный тип для каждой колонки:
Колонка | Является числовой | Мин, Макс | Уникальные значения | 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 - неиспользуемое значение, можно использовать для Null | 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 для Null. Миллисекундная гранулярность не требуется, используйте DateTime | DateTime |
ClosedDate | Нет | 2008-09-04 20:56:44, 2024-04-06 18:49:25.393000000 | - | Да | Учтите, что значение по умолчанию 1970-01-01 для Null. Миллисекундная гранулярность не требуется, используйте DateTime | DateTime |
Вышеуказанное дает нам следующую схему:
Мы можем заполнить это с помощью простого INSERT INTO SELECT
, читая данные из нашей предыдущей таблицы и вставляя в эту:
Мы не сохраняем никаких null значений в нашей новой схеме. Вышеуказанная вставка неявно преобразует их в значения по умолчанию для соответствующих типов - 0 для целых чисел и пустое значение для строк. ClickHouse также автоматически преобразует любые числовые значения в их целевую точность. Основные (упорядочивающие) ключи в ClickHouse Пользователи, приходящие из OLTP баз данных, часто ищут эквивалентную концепцию в ClickHouse.
Выбор упорядочивающего ключа
На том уровне масштабирования, с которым часто работает ClickHouse, эффективность использования памяти и диска имеет первостепенное значение. Данные записываются в таблицы ClickHouse в частях, с правилами слияния частей в фоновом режиме. В ClickHouse каждая часть имеет свой собственный первичный индекс. Когда части сливаются, первичные индексы слитых частей также сливаются. Первичный индекс для части имеет одну запись индекса на группу строк - эта техника называется разреженным индексированием.

Выбранный ключ в ClickHouse будет определять не только индекс, но и порядок, в котором данные записываются на диск. По этой причине он может значительно повлиять на уровни сжатия, которые могут, в свою очередь, повлиять на производительность запросов. Упорядочивающий ключ, который приводит к записи значений большинства колонок в смежном порядке, позволит выбранному алгоритму сжатия (и кодекам) более эффективно сжимать данные.
Все колонки в таблице будут отсортированы на основе значений указанного упорядочивающего ключа, независимо от того, включены ли они в сам ключ. Например, если
CreationDate
используется как ключ, порядок значений во всех других колонках будет соответствовать порядку значений в колонкеCreationDate
. Можно указать несколько упорядочивающих ключей - они будут упорядочены с той же семантикой, что и клаусулаORDER BY
в запросеSELECT
.
Некоторые простые правила могут помочь выбрать упорядочивающий ключ. Следующие правила иногда могут конфликтовать, поэтому рассмотрите их в порядке важности. Пользователи могут определить несколько ключей из этого процесса, при этом 4-5 обычно достаточно:
- Выбирайте колонки, которые совпадают с вашими общими фильтрами. Если колонка часто используется в клаузах
WHERE
, приоритизируйте включение их в свой ключ по сравнению с теми, которые используются реже. - Предпочитайте колонки, которые помогают исключить большой процент всех строк при фильтрации, таким образом уменьшая объем данных, который необходимо прочитать.
- Предпочитайте колонки, которые, вероятно, будут сильно коррелировать с другими колонками в таблице. Это поможет обеспечить, чтобы эти значения также хранились смежно, улучшая сжатие.
- Операции
GROUP BY
иORDER BY
для колонок в упорядочивающем ключе могут быть сделаны более эффективными в использовании памяти.
При определении подмножества колонок для упорядочивающего ключа объявляйте колонки в конкретном порядке. Этот порядок может существенно повлиять как на эффективность фильтрации по вторичным ключевым колонкам в запросах, так и на соотношение сжатия для файлов данных таблицы. В общем, лучше всего упорядочить ключи в порядке возрастания кардинальности. Это должно быть сбалансировано с тем фактом, что фильтрация по колонкам, которые появляются позже в упорядочивающем ключе, будет менее эффективной, чем фильтрация по тем, которые появляются раньше в кортеже. Балансируйте эти явления и учитывайте свои паттерны доступа (и, что наиболее важно, тестируйте варианты).
Пример
Применяя вышеуказанные рекомендации к нашей таблице posts
, давайте предположим, что наши пользователи хотят выполнять аналитику, фильтруя по дате и типу поста, например:
"Какие вопросы имели наибольшее количество комментариев за последние 3 месяца".
Запрос для этого вопроса, используя нашу ранее созданную таблицу posts_v2
с оптимизированными типами, но без упорядочивающего ключа:
Запрос здесь очень быстрый, хотя все 60 млн строк были линейно просканированы - ClickHouse просто быстрый :) Вам придется доверять нам, что упорядочивающие ключи имеют значение на TB и PB масштабе!
Давайте выберем колонки PostTypeId
и CreationDate
в качестве наших упорядочивающих ключей.
Возможно, в нашем случае мы ожидаем, что пользователи всегда будут фильтровать по PostTypeId
. У него кардинальность 8, и это логический выбор для первого элемента в нашем упорядочивающем ключе. Признавая, что фильтрация по гранулярности даты, вероятно, будет достаточной (она все равно будет полезна для фильтров даты и времени), мы используем toDate(CreationDate)
в качестве второго компонента нашего ключа. Это также создаст меньший индекс, так как дату можно представить 16-битным числом, что ускорит фильтрацию. Наша финальная запись ключа - это CommentCount
, чтобы помочь найти посты с наибольшим количеством комментариев (финальная сортировка).
Для пользователей, интересующихся улучшениями сжатия, достигнутыми с использованием специфических типов и соответствующих упорядочивающих ключей, см. Сжатие в ClickHouse. Если пользователям необходимо дополнительно улучшить сжатие, мы также рекомендуем раздел Выбор правильного кодека сжатия колонок.
Далее: Методы моделирования данных
До сих пор мы мигрировали только одну таблицу. Хотя это позволило нам ввести некоторые основные концепции ClickHouse, большинство схем, к сожалению, не так просты.
В других руководствах, перечисленных ниже, мы рассмотрим ряд методов для структурирования нашей более широкой схемы для оптимального запроса в ClickHouse. На протяжении этого процесса мы стремимся к тому, чтобы Posts
оставалась нашей центральной таблицей, через которую выполняются большинство аналитических запросов. Хотя другие таблицы все еще можно запрашивать в отдельности, мы предполагаем, что большинство аналитических задач хотят выполняться в контексте posts
.
В этом разделе мы используем оптимизированные варианты наших других таблиц. Хотя мы предоставляем схемы для них, ради сокращения мы опускаем принятые решения. Эти решения основаны на правилах, описанных ранее, и мы оставляем вывод решений на усмотрение читателя.
Следующие подходы направлены на минимизацию необходимости использования JOINs для оптимизации чтения и повышения производительности запросов. Несмотря на то, что JOINs полностью поддерживаются в ClickHouse, мы рекомендуем использовать их умеренно (2-3 таблицы в JOIN запросе - это нормально) для достижения оптимальной производительности.
ClickHouse не имеет понятия внешних ключей. Это не запрещает соединения, но означает, что ссылочная целостность остается на уровне управления пользователем в приложении. В системах OLAP, таких как ClickHouse, целостность данных часто обеспечивается на уровне приложения или в процессе получения данных, а не принудительно со стороны самой базы данных, где это влечет значительные накладные расходы. Этот подход обеспечивает большую гибкость и более быстрое вставку данных. Это соответствует акценту ClickHouse на скорости и масштабируемости чтения и вставки запросов с очень большими наборами данных.
Чтобы минимизировать использование соединений во время выполнения запросов, у пользователей есть несколько инструментов/подходов:
- Денормализация данных - Денормализуйте данные, комбинируя таблицы и используя сложные типы для отношениях, не являющихся 1:1. Это часто включает в себя перенос любых соединений из времени запроса во время вставки.
- Словари - Специфическая для ClickHouse функция для обработки прямых соединений и поиска значений по ключу.
- Инкрементные материализованные представления - Функция ClickHouse для переноса стоимости вычислений из времени запроса в время вставки, включая возможность инкрементального вычисления агрегатных значений.
- Обновляемые материализованные представления - Подобно материализованным представлениям, используемым в других продуктах баз данных, это позволяет периодически вычислять результаты запроса и кэшировать результат.
Мы рассмотрим каждый из этих подходов в каждом гиде, подчеркивая, когда каждый из них уместен, с примером, показывающим, как его можно применить для решения вопросов по набору данных Stack Overflow.