Привет, Хабр!

В этой статье разберём фундамент ClickHouse — движок MergeTree. Посмотрим, как данные реально хранятся на диске, чем отличается «парт» от «партиции» и почему индекс в ClickHouse работает не так, как в привычных транзакционных базах вроде PostgreSQL или MySQL.

1. Движок таблицы (Table Engine)

Представьте, что база данных — это огромный склад. На этом складе есть разные типы стеллажей: одни для хрупких товаров, другие — для тяжелых, а третьи — холодильные камеры для скоропортящихся продуктов. Мы выбираем тип стеллажа в зависимости от того, что и как планируем хранить, чтобы доступ к товару был быстрым и удобным.

В ClickHouse роль таких «стеллажей» выполняют движки таблиц.

Движок таблицы (Table Engine) — это фундаментальный компонент, который определяет, как данные физически хранятся на диске, как они записываются и читаются, а также какие возможности поддерживаются (например, репликация, дедупликация и т. д.).

ClickHouse предлагает огромный выбор движков для разных задач и один из наиболее часто используемых — MergeTree.

2. MergeTree

Название этого семейства движков идеально описывает его внутреннюю работу, которая основана на двух ключевых концепциях: слияние (Merge) и дерево (Tree). 

MergeTree — это семейство движков таблиц, предназначенных для быстрой вставки огромных объемов данных и еще более быстрой их обработки в аналитических SELECT-запросах, особенно с агрегациями GROUP BY.

2.1 Merge (Слияние)

В основе быстрой вставки в ClickHouse лежит принцип неизменяемости данных. Когда мы вставляем в таблицу новые данные (даже если это одна строка), ClickHouse не изменяет существующие файлы на диске. Вместо этого он создаёт новый, небольшой, отсортированный по первичному ключу (о нем чуть ниже) и сжатый блок данных, который называется парт (part).

Part (парт) — это физический набор файлов на диске, в котором хранится фрагмент данных одной таблицы. Важно не путать его с партицией (Partition), которая определяет, как данные логически группируются в разделы на диске.

С каждой новой операцией INSERT в таблице появляется новый парт. Со временем их количество может вырасти до сотен, тысяч или даже десятков тысяч. Это создает проблему: чтобы выполнить запрос, ClickHouse придётся открывать и обрабатывать огромное количество мелких файлов, что приводит к значительным накладным расходам и падению производительности.

Чтобы решить эту проблему, в ClickHouse постоянно (и незаметно для пользователя) работает фоновый процесс слияния партов. Этот процесс автоматически находит множество мелких партов и объединяет их в один более крупный, в котором данные также хранятся в отсортированном виде.

Благодаря этому механизму данные в таблице всегда оптимизируются для чтения, что позволяет:

  • быстро находить нужные данные, используя индекс;

  • считывать меньше лишней информации, пропуская целые блоки;

  • эффективно работать с диском, читая один большой фрагмент вместо сотен маленьких.

2.2 Tree (Дерево)

Здесь и появляется вторая часть названия. Процесс слияния имеет иерархическую, древовидную структуру:

Процесс слияния партов
Процесс слияния партов
  1. Новые данные попадают в мелкие парты «нулевого уровня».

  2. Фоновый процесс постепенно объединяет их в более крупные парты «первого уровня».

  3. Затем те, в свою очередь, сливаются в парты «второго уровня», и так далее.

Как только из нескольких мелких партов создается один новый и большой, старые парты-источники помечаются как неактивные и через некоторое время автоматически удаляются. Это гарантирует, что место на диске освобождается, а структура таблицы постоянно оптимизируется для максимальной скорости чтения.

Таким образом, название MergeTree («Дерево Слияний») идеально описывает эту архитектуру: данные постоянно объединяются (Merge), а сам процесс слияний образует древовидную структуру (Tree).

3. Ключевые параметры MergeTree

Когда мы создаем таблицы с движком MergeTree, мы должны определить несколько ключевых параметров, от которых будет зависеть их производительность. Разберем их на примере таблицы для хранения логов посещений сайта:

CREATE TABLE website_visits (
    visit_date    Date,        -- Дата визита
    user_id       UInt32,      -- ID пользователя
    region_code   UInt32,      -- Код региона, города, населенного пункта и т.д.
    url           String,      -- URL страницы
    duration      UInt16       -- Длительность визита в секундах
)
ENGINE = MergeTree()
ORDER BY (visit_date, region_code, user_id)
SETTINGS index_granularity = 8192 -- Если используем значение
-- по умолчанию можно не указывать в запросе

3.1 Ключ сортировки (ORDER BY)

Представьте огромную библиотеку, где все книги свалены в одну кучу. Найти в ней нужную было бы практически невозможно. Но если книги расставлены на полках сначала по жанру, а внутри жанра по фамилии автора, найти нужную книгу не составит особого труда.

В ClickHouse ключ сортировки ORDER BY выполняет именно такую роль «умного организатора», но делает это на физическом уровне, прямо на диске. Его указание при создании MergeTree таблицы является обязательным.

Ключ сортировки (ORDER BY) — это одно или несколько полей, по которым ClickHouse физически упорядочивает данные внутри каждого парта в лек��икографическом порядке (как в словаре — сначала по первой букве/цифре, потом по второй и так далее). Это фундаментальный принцип хранения, который напрямую влияет на скорость выполнения запросов.

Когда мы пишем ORDER BY (visit_date, region_code, user_id), мы дословно говорим ClickHouse: «Сложи все данные на диск так, чтобы они сначала были упорядочены по visit_date, внутри одной даты — по region_code, и для одинаковых пар даты и региона — по user_id».

Схематично хранение отсортированных данных можно проиллюстрировать следующим образом (для удобства отображения дату представим в виде числового обозначения, например 2025-08-01 -> 1):

Хранение отсортированных данных
Хранение отсортированных данных

Кроме того, при создании таблицы ключ сортировки, указанный в ORDER BY, также становится первичным ключом PRIMARY KEY, если он не задан явно. В отличие от традиционных реляционных баз данных, где первичный ключ — это в первую очередь ограничение для обеспечения уникальности записи, в ClickHouse его главная задача — служить основой для разреженного индекса (о нем чуть ниже), позволяющего быстро находить нужные диапазоны данных.

Давайте рассмотрим его подробно.

3.2 Первичный ключ (PRIMARY KEY)

Как мы уже упомянули ранее, если мы не указываем PRIMARY KEY явно, то ключ сортировки из ORDER BY автоматически становится первичным ключом. Это стандартная и наиболее частая практика, поэтому ��вное указание PRIMARY KEY при создании таблицы требуется достаточно редко (для тонкой оптимизации производительности или при использовании других движков семейства MergeTree).  В большинстве случаев достаточно определить только ключ сортировки.

В случае явного указания, первичный ключ обязан быть префиксом (начальной частью) ключа сортировки, например, если ORDER BY (A, B, C), то PRIMARY KEY может быть (A) или (A, B), но не (B) или (A, C).

Важно понимать ключевое отличие от транзакционных СУБД: первичный ключ в ClickHouse не обеспечивает уникальность записей. Мы можем без проблем вставить несколько строк с абсолютно одинаковыми значениями в полях первичного ключа. Его задача — исключительно оптимизация чтения.

Первичный ключ (PRIMARY KEY) — это не ограничение уникальности, а инструмент для быстрой навигации по отсортированным данным. Он определяет, по каким столбцам будет построен разреженный индекс для ускорения поиска.

Представьте большую книгу, в которой нет оглавления. Чтобы найти нужную главу, нам придется пролистать ее с самого начала. А теперь представьте, что в начале книги есть оглавление, где указано: «Глава 5 начинается на странице 83». Мы сразу откроем 83-ю страницу.

Первичный ключ в ClickHouse работает по точно такому же принципу, создавая «оглавление» для данных на диске. Это оглавление и есть разреженный индекс.

Разреженный индекс (Sparse Index) — это индекс, который хранит «засечки» (marks) не для каждой строки, а только для первой строки каждого блока данных.

Он формируется следующим образом.  Все данные в парте, отсортированные по ключу ORDER BY, делятся на равные блоки, которые называются гранулами (это и есть та самая «глава» из нашего примера с книгой).

Гранула (Granule) — это минимальный неделимый блок данных, который ClickHouse считывает с диска. Какой бы запрос мы не написали, всегда будет считана минимум одна гранула. Размер гранулы определяется параметром index_granularity (по умолчанию — 8192 строки, но может быть изменен на другое значение).

Выбор размера гранулы — это поиск баланса. Слишком маленькие гранулы сделают поиск очень точным, но раздуют индекс («оглавление»). Слишком большие гранулы, наоборот, уменьшат индекс, но заставят ClickHouse «пролистывать» много лишних данных при чтении. Значение по умолчанию в 8192 строки — это проверенный компромисс, оптимальный для большинства задач.

Для первой строки каждой гранулы ClickHouse создает «засечку» (mark) в специальном индексном файле (primary.idx, он же и является оглавлением в аналогии с книгой), которая содержит значение первичного ключа для этой строки (например 1, А, 1 или visit_date = 1, region_code = А, user_id = 1). Она отмечает место, где начинается гранула.

Схематично метки и гранулы можно проиллюстрировать следующим образом:

Метки и гранулы
Метки и гранулы

Давайте рассмотрим как индекс работает при запросах.

3.2.1. Условие по первому полю первичного ключа

Предположим, мы хотим выполнить следующий запрос:

SELECT  *
FROM website_visits
WHERE visit_date = 1

Первое, что сделает ClickHouse, — это обратится к индексному файлу primary.idx. Поскольку условие WHERE задействует первое поле первичного ключа, ClickHouse применит эффективный бинарный поиск по «засечкам» индекса, чтобы мгновенно определить, какие гранулы данных ему нужно прочитать, а какие проигнорировать.

Когда в предложении WHERE указано первое поле первичного ключа или его префикс (например, visit_date или visit_date, region_code), ClickHouse выполняет быстрый бинарный поиск по файлу primary.idx, чтобы найти точный диапазон гранул, где могут находиться запрашиваемые данные.

Засечки в индексном файле отсортированы и выглядят так: (1,A,1), (1,C,1), (2,B,2)...

Как только ClickHouse в процессе поиска видит засечку, которая начинается со значения, большего чем 1 (в нашем случае это (2,B,2)), он понимает, что дальше искать бессмысленно. Так как все данные в таблице физически отсортированы по visit_date, это означает, что нужные нам записи (visit_date = 1) могут находиться только в тех гранулах, чьи метки начинаются на 1. Как только в метках появилась цифра 2, все единицы гарантированно остались позади.

Таким образом, для выполнения запроса с диска будут прочитаны данные из первой и второй гранул.

Метки и гранулы
Метки и гранулы

3.2.2. Условие по префиксу первичного ключа

Теперь давайте немного усложним запрос, добавив условие по второму полю из составного первичного ключа:

SELECT  *
FROM website_visits
WHERE visit_date = 1 AND region_code = 'D'

Логика работы ClickHouse будет состоять из двух последовательных шагов, что позволит ещё сильнее сократить объём читаемых данных.

Как и в предыдущем примере, ClickHouse с помощью бинарного поиска быстро найдет диапазон гранул, где visit_date = 1. Это сужает область поиска до первых двух гранул с засечками (1,A,1) и (1,C,1).

Теперь, в рамках найденного диапазона, ClickHouse начинает анализировать второе поле ключа — region_code. Для этого он смотрит не только на саму засечку, но и на следующую за ней, чтобы понять диапазон значений в грануле.

  • Он анализирует первую гранулу, глядя на её засечку (1,A,1) и на засечку следующей гранулы (1,C,1). ClickHouse понимает, что первая гранула содержит данные для ключей в диапазоне от (1, 'A') до (1, 'C') (не включая). Поскольку искомое нами значение 'D' лексикографически (по алфавиту) больше, чем 'C', в этой грануле его быть не может. Эта гранула будет пропущена.

  • Далее он анализирует вторую гранулу, глядя на её засечку (1,C,1) и следующую за ней (2,B,2). Это означает, что гранула содержит данные для ключей, начиная с (1, 'C') и вплоть до (2, 'B'). Наш искомый ключ (1, 'D') идеально попадает в этот диапазон. ClickHouse помечает эту гранулу для чтения.

В итоге с диска будет прочитана только вторая гранула:

Метки и гранулы
Метки и гранулы

Использование полного префикса первичного ключа в условии WHERE позволяет отсечь ещё больше ненужных данных и прочитать в два раза меньше информации по сравнению с первым запросом.

3.2.3. Условие по полю первичного ключа, не являющегося первым

Но что произойдет, если мы укажем в запросе поле, которое не является первым в первичном ключе:

SELECT  *
FROM website_visits
WHERE user_id = 1

В этом случае ClickHouse не сможет применить бинарный поиск. Причина в том, что данные на диске отсортированы в первую очередь по visit_date, а уже потом по user_id. Это полностью меняет подход к поиску.

ClickHouse выполнит полное сканирование индексного файла primary.idx. Он последовательно прочитает каждую засечку от начала до конца и проверит, может ли в соответствующей грануле находиться user_id = 1.

Так как данные не отсортированы по user_id глобально, ClickHouse не может делать никаких предположений. Запись с user_id = 1 может находиться в грануле, которая начинается с засечки (1, A, ...) и точно так же может оказаться в грануле с засечкой (5, A, ...).

В результате ClickHouse будет сканировать полностью весь файл с индексными метками, а так как в нашем примере user_id со значением 1 есть в каждой грануле он прочитает их все:

Метки и гранулы
Метки и гранулы

Эффективность работы первичного ключа в таком запросе минимальна.

Если условие WHERE не включает префикс первичного ключа, индекс не может быть эффективно использован, что приводит к увеличению объема данных, прочитанных с диска.

3.2.4. Выбор первичного ключа

Выбор первичного клю��а — это основа для производительных запросов. Ошибка на этом этапе проектирования таблицы — одна из самых частых и дорогостоящих, ведь от него напрямую зависит скорость всех будущих запросов.

Для аналитических запросов, которые фильтруют большие срезы данных, столбцы в первичном ключе рекомендуется располагать в порядке возрастания кардинальности — от самой низкой к самой высокой.

Кардинальность — это ключевая характеристика столбца, которая описывает количество его уникальных значений. Она может быть низкой, когда столбец содержит мало уникальных значений (например, пол, статус_заказа, день_недели) или высокой, когда столбец содержит много уникальных значений (например, user_id, transaction_id, timestamp с наносекундами), число которых может приближаться к общему количеству строк в таблице.

Наш пример ORDER BY (visit_date, region_code, user_id) отлично это демонстрирует:

  1. visit_date (самая низкая кардинальность): ~365 значений в году.

  2. region_code (средняя кардинальность): десятки тысяч значений.

  3. user_id (высокая кардинальность): миллионы значений.

Такой порядок позволит на каждом шаге отсекать максимально большие объемы данных при условии, что в запросах фильтрация осуществляется по префиксу первичного ключа или по всем его значениям.

Но что делать, если 90% наших запросов выглядят так: WHERE user_id = '...'. В таких случаях допускается использование высококардинальных полей в качестве первичного ключа таблицы. Для точечных запросов это намного быстрее. ClickHouse мгновенно найдет все гранулы, относящиеся к нужному user_id.

Таким образом, при выборе первичного ключа всегда руководствуйтесь тем, какие поля вы чаще всего будете использовать для фильтрации в WHERE. Располагайте их в самом начале (слева).

Материал подготовлен на основе уроков моего бесплатного курса ClickHouse с нуля.

3.3 Ключ партиционирования (PARTITION BY)

Представьте, что все данные склада лежат на одном огромном стеллаже, отсортированные по дате производства. Искать удобно, но что если нам нужно вывезти все товары, произведенные в январе прошлого года? Придется пройтись по всему стеллажу и выбрать нужное.

Партиционирование — это как разделить этот огромный стеллаж на отдельные, физически изолированные секции: «Секция-Январь», «Секция-Февраль» и так далее. Теперь, чтобы вывезти все январские товары, мы просто забираем всю секцию целиком.

Партиционирование (Partitioning) — это механизм движков семейства MergeTree, который группирует парты таблицы в организованные логические единицы (партиции) на основе заданного критерия. Каждая партиция физически представляет собой отдельную директорию на диске.

Это делает данные проще в управлении, запросах и оптимизации.

Партиционирование включается в момент создания таблицы с помощью выражения PARTITION BY. В нем можно использовать SQL-выражение на основе любого количества столбцов. Результат этого выражения и определит, в какую партицию попадет строка.

Давайте модифицируем нашу таблицу website_visits, добавив партиционирование по месяцам:

CREATE TABLE website_visits (
    visit_date    Date,        -- Дата визита
    user_id       UInt32,      -- ID пользователя
    region_code   UInt32,      -- Код региона, города, населенного пункта и т.д.
    url           String,      -- URL страницы
    duration      UInt16       -- Длительность визита в секундах
)
ENGINE = MergeTree()
ORDER BY (visit_date, region_code, user_id)
PARTITION BY toYYYYMM(visit_date)  -- Ключ партиционирования
SETTINGS index_granularity = 8192 

В этом примере toYYYYMM(visit_date) преобразует дату в число формата ГодМесяц (например, 202508), которое и становится именем партиции (директории).

3.3.1 Структура на диске

Когда мы вставляем в таблицу порцию данных, ClickHouse сначала смотрит на значение ключа партиционирования для каждой строки. Вместо того чтобы создать один новый парт для всей вставки, он создает по одному новому парту для каждого уникального значения ключа партиционирования в этой порции данных.

Например, если мы одной командой INSERT вставляем данные за январь и февраль, ClickHouse создаст как минимум два новых парта в двух разных директориях: 202501/ и 202502/.

Вместе с данными ClickHouse автоматически создает для каждой партиции MinMax-индексы. Это небольшие файлы для каждого столбца в выражении партиционирования, хранящие минимальное и максимальное значение этого столбца внутри данного парта. Именно они позволяют быстро «отсекать» ненужные партиции при чтении.

Также стоит запомнить ключевое правило. ClickHouse выполняет фоновые слияния партов только в пределах одной и той же партиции. Парты из разных партиций никогда не сливаются между собой:

Партиции
Партиции

Это имеет огромное последствие: если выбрать ключ партиционирования с высокой кардинальностью (например, user_id или timestamp с миллисекундами), вы получите тысячи или миллионы партиций, в каждой из которых будет лежать по одному мелкому парту. Они никогда не сольются, что приведет к деградации производительности и ошибке Too many parts.

Выбирайте ключ партиционирования с кардинальностью (количеством уникальных значений) в диапазоне от 1000 до 10000 значений.

3.3.2 Управление данными

Основное и главное предназначение партиционирования —  это управление данными. Поскольку каждая партиция является отдельной директорией на диске, управление данными на их уровне становится чрезвычайно эффективным.

Например, вместо того чтобы построчно удалять миллионы записей, ClickHouse может удалить целую партицию (директорию), что является практически мгновенной операцией. Сделать это можно либо «в ручном» формате, используя команду ALTER TABLE table_name DROP PARTITION partition_name, или настроить автоматизацию с помощью TTL.

TTL (Time To Live «время жизни») — это специальный механизм, который позволяет автоматически управлять жизненным циклом данных в таблице. Простыми словами, вы задаете правило, которое определяет, как долго должны «жить» данные, и что с ними сделать, когда их «время жизни» истечет.

Важно отметить, что TTL можно настроить и к таблице без партиционирования, но эффективнее TTL работает вместе с партиционированием. ClickHouse не проверяет каждую строку на соответствие правилу TTL. Вместо этого он проверяет метаданные целой партиции. Если вся партиция (например, все данные за определенный месяц) устарела, ClickHouse удаляет или перемещает её целиком как одну операцию, что происходит практически мгновенно.

Для настройки TTL необходимо указать дополнительный параметр при создании таблицы:

CREATE TABLE website_visits (
    visit_date    Date,        -- Дата визита
    user_id       UInt32,      -- ID пользователя
    region_code   UInt32,      -- Код региона, города, населенного пункта и т.д.
    url           String,      -- URL страницы
    duration      UInt16       -- Длительность визита в секундах
)
ENGINE = MergeTree()
ORDER BY (visit_date, region_code, user_id)
PARTITION BY toYYYYMM(visit_date)
-- Автоматически удалять данные старше 12 месяцев
TTL visit_date + INTERVAL 12 MONTH DELETE 
SETTINGS index_granularity = 8192 

Аналогично удалению можно также эффективно перемещать старые партиции, например на более медленные и дешевые диски в холодное хранилище (долгосрочное и недорогое хранение архивных данных).

Запрос на создание таблицы будет выглядеть следующим образом:

CREATE TABLE website_visits (
    visit_date    Date,        -- Дата визита
    user_id       UInt32,      -- ID пользователя
    region_code   UInt32,      -- Код региона, города, населенного пункта и т.д.
    url           String,      -- URL страницы
    duration      UInt16       -- Длительность визита в секундах
)
ENGINE = MergeTree()
ORDER BY (visit_date, region_code, user_id)
PARTITION BY toYYYYMM(visit_date)
-- Перемещать данные старше 2 лет на медленный диск
TTL visit_date + INTERVAL 2 YEAR TO VOLUME 'slow_disk' 
SETTINGS index_granularity = 8192 

3.3.3 Оптимизация запросов

Это второстепенная задача партиционирования. Если в запросе есть фильтрация по ключу партиционирования, ClickHouse может значительно ускорить его выполнение. Этот механизм называется отсечение партиций (partition pruning).

Рассмотрим следующий запрос:

SELECT max(duration)
FROM website_visits
WHERE visit_date >= 4 AND region_code = 'A'

ClickHouse, выполняя запрос, применит двухэтапную фильтрацию:

  1. Отсечение партиций. Сначала он посмотрит на условие visit_date >= 4 и, используя MinMax-индексы, мгновенно отбросит все партиции до 4. Он даже не будет пытаться читать из них какие-либо данные.

  2. Отсечение гранул. Затем, для оставшихся партиций (4 и позже), он применит первичный ключ и его разреженный индекс, чтобы найти и прочитать только те гранулы, где может находиться region_code = 'А'.

Стоит отметить, что использование партиционирования не всегда приводит к ускорению запросов. Более того, запросы, которые читают данные из всех или почти всех партиций, могут работать медленнее, чем на непартиционированной таблице с теми же данными.

Это связано с тем, что в партиционированной таблице обычно больше мелких партов, так как слияния ограничены границами партиций. В результате ClickHouse тратит больше времени на открытие и обработку множества мелких файлов вместо нескольких крупных.

Итог

Чтобы эффективно работать с ClickHouse, помните:

  • Merge: Данные пишутся мелкими кусками и сливаются в фоне.

  • Primary Key: Это разреженный индекс (оглавление), а не уникальность. Порядок столбцов важен.

  • Partitioning: Инструмент для TTL и грубой фильтрации, а не замена индексам.

Надеюсь, эта статья помогла вам лучше понять, что происходит «под капотом» MergeTree таблиц.

P.S. Если вы хотите систематизировать знания и получить прочную теоретическую базу для дальнейшего освоения ClickHouse на практике, буду рад видеть вас на моем бесплатном курсе ClickHouse с нуля который охватывает все самое необходимое для уверенного старта в работе с технологией.

Удачи в изучении!