сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · ClickHouse

Движки таблиц ClickHouse

Зачем это спрашивают

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

Типовые формулировки: «почему PRIMARY KEY не убрал дубли?», «чем ReplacingMergeTree отличается от MergeTree?», «как здесь делать UPDATE?».

// Одна смена интуиции закрывает половину вопросов темы: ClickHouse - мир дописывания. Правки и уникальность ему чужеродны, а всё, что их изображает, случается «когда-нибудь потом».

Куски и мержи: как это устроено физически

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

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

Ключевое, что надо унести: мерж - фоновая работа без расписания. Он может прийти через секунду, через час, а может не прийти вовсе, если куски уже крупные и склеивать их невыгодно. Всё, что движок обещает сделать «при мерже», не имеет срока.

// PARTITION BY - крупная нарезка поверх кусков, обычно по месяцу или дню. Она даёт две вещи: запрос за январь не трогает файлы февраля, и удаление старых данных делается мгновенным DROP PARTITION вместо тяжёлого DELETE. Сотни партиций - норма, десятки тысяч - деградация: потолок по умолчанию 100 000 кусков на таблицу, и вставка после него встанет.

кусок (part)
отсортированная пачка строк на диске; результат одной вставки или склейки нескольких кусков
мерж
фоновая склейка кусков в более крупный; происходит без гарантии времени

Разрежённый индекс: почему ключ не про уникальность

ORDER BY в MergeTree - это сразу две вещи: физический порядок строк внутри куска и первичный индекс. Отдельного PRIMARY KEY обычно не пишут, а если пишут, он должен быть префиксом ORDER BY.

Индекс тут разрежённый. Он хранит не запись про каждую строку, а одну засечку на каждые 8192 строки - это значение index_granularity по умолчанию. То есть в индексе написано «в этой пачке из 8192 строк ключ начинается с такого-то значения», и всё. Найти конкретную строку он не умеет, он умеет пропускать пачки целиком.

Из этого следует главное: проверить уникальность при вставке индекс физически не может - для этого пришлось бы читать сами данные на каждой записи, а весь ClickHouse построен на том, чтобы вставка была дешёвой. Поэтому две строки с одинаковым ключом спокойно ложатся рядом и обе читаются.

// Пачка из 8192 строк называется гранулой, и дальше это слово будет встречаться постоянно: скорость запроса в ClickHouse - это в первую очередь то, сколько гранул он сумел не читать.

CREATE TABLE t (id UInt32, v String) ENGINE = MergeTree ORDER BY id;
INSERT INTO t VALUES (1, 'a');
INSERT INTO t VALUES (1, 'b');

SELECT count() FROM t;   -- 2, обе строки на месте
-- проверено на ClickHouse 24.8: ключ не ограничивает уникальность
гранула
пачка из index_granularity строк (по умолчанию 8192), минимальная единица чтения

Дедуп и суммы происходят при мержах

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

Проверим руками. Вставляем две версии одной строки двумя отдельными вставками - получаем два куска. SELECT count() возвращает 2: дедупликации не было. Тот же запрос с FINAL возвращает 1 и последнюю версию, потому что FINAL сливает куски прямо во время чтения. argMax по колонке версии тоже возвращает последнюю. А после принудительного OPTIMIZE TABLE ... FINAL в таблице остаётся одна строка уже физически.

Вывод для практики: чтение обязано быть терпимым к дублям. Два рабочих способа - FINAL (просто, но дорого: слияние на лету на каждом запросе) и argMax по версии внутри GROUP BY (дёшево, но нужно переписать запрос). SummingMergeTree и AggregatingMergeTree ведут себя так же: две вставки по одному ключу до мержа лежат двумя строками, и SUM с GROUP BY на чтении всё равно обязателен.

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

CREATE TABLE r (id UInt32, v String, ver UInt32)
ENGINE = ReplacingMergeTree(ver) ORDER BY id;
INSERT INTO r VALUES (1, 'a', 1);
INSERT INTO r VALUES (1, 'b', 2);

SELECT count() FROM r;              -- 2  дублей ещё никто не убрал
SELECT count() FROM r FINAL;        -- 1  слияние на лету при чтении
SELECT argMax(v, ver) FROM r;       -- 'b' последняя версия

OPTIMIZE TABLE r FINAL;
SELECT count() FROM r;              -- 1  теперь и физически одна

Мир дописывания: UPDATE здесь - мутация

Привычного UPDATE в ClickHouse нет. Есть ALTER TABLE ... UPDATE - асинхронная мутация, которая перезаписывает куски целиком. Правка одной строки в куске на сто миллионов строк означает чтение и запись всех ста миллионов.

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

Идиома вместо обновления: писать новую версию строки и брать последнюю на чтении - тем самым ReplacingMergeTree или argMax по версии. Изменяемость получается логической, а физически всё остаётся дописыванием.

// Distributed - отдельный движок, который данных не хранит вовсе. Это фасад: он принимает запрос, рассылает его по шардам и собирает ответ. Локальные таблицы на шардах при этом обычные MergeTree.

мутация
ALTER UPDATE/DELETE: асинхронная перезапись целых кусков, а не правка строки

Как отвечать: «Почему в таблице дубли, если есть PRIMARY KEY?»

Потому что первичный ключ в ClickHouse - это разрежённый индекс для пропуска гранул при чтении, а не ограничение уникальности. Он хранит одну засечку на 8192 строки, поэтому проверить уникальность на вставке просто нечем, да и незачем: вся архитектура построена вокруг дешёвого дописывания. Если дедупликация нужна, берут ReplacingMergeTree, но и он убирает дубли при фоновых мержах, а у мержа нет срока - он может прийти через час, а может не прийти. Поэтому чтение я делаю терпимым к дублям: либо FINAL, если можно платить слиянием на лету, либо argMax по колонке версии внутри GROUP BY - это дешевле. И отдельно проговорю, что дедупликация одинаковых блоков вставки в Replicated-таблицах - другой механизм, он про повторную доставку из очереди, а не про ключ.

Ответ ломает привычную интуицию, объясняет причину через устройство индекса и даёт оба рабочих паттерна чтения.

На чём валят

  • Ждать уникальности от первичного ключа: он разрежённый, одна засечка на 8192 строки, и проверять ему нечем.
  • Читать ReplacingMergeTree без FINAL или argMax - дубли уедут в отчёт.
  • Считать, что после вставки в SummingMergeTree суммы уже свёрнуты: до мержа там столько строк, сколько вставили.
  • PARTITION BY user_id - взрыв числа кусков и остановка вставки на общем потолке.
  • Использовать ClickHouse как OLTP (online transaction processing): точечные UPDATE мутациями переписывают куски целиком.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 14, остальные разбираются в тренажёре.

  1. #ch_engines1 / 5
    Как в ClickHouse обновить или удалить отдельные строки?
    A)Обычными построчными командами UPDATE и DELETE, как в PostgreSQL, они выполняются мгновенно и точечно
    B)Никак: данные в ClickHouse неизменяемы и не могут быть изменены или удалены
    C)Через мутации ALTER TABLE ... UPDATE/DELETE — тяжёлые асинхронные операции, переписывающие куски
    D)Пересоздав всю таблицу заново с нуля при изменении хотя бы одной строки
    показать ответ и разбор
    +C)Через мутации ALTER TABLE ... UPDATE/DELETE — тяжёлые асинхронные операции, переписывающие куски

    // разбор: Классического построчного UPDATE/DELETE в CH нет: правки идут мутациями ALTER TABLE ... UPDATE/DELETE, которые асинхронно переписывают затронутые куски целиком — дорого и не для частых точечных правок. Для «последней версии» строк берут ReplacingMergeTree, для накопительных счётчиков — Summing/AggregatingMergeTree. Архитектура append-heavy, а не OLTP.

  2. #ch_engines2 / 5
    Зачем в MergeTree задают PARTITION BY (например, по месяцу)?
    A)Чтобы заменить собой сортировку данных: PARTITION BY делает секцию ORDER BY ненужной
    B)Чтобы автоматически распределить партиции таблицы по разным физическим серверам большого кластера ClickHouse
    C)Чтобы включить построчные транзакции и блокировки внутри каждой отдельной партиции таблицы
    D)Разбить данные на партиции для отсечения по фильтру и операций над целыми кусками (DROP PARTITION)
    показать ответ и разбор
    +D)Разбить данные на партиции для отсечения по фильтру и операций над целыми кусками (DROP PARTITION)

    // разбор: PARTITION BY (часто toYYYYMM(date)) делит таблицу на партиции — независимые наборы кусков. Запрос с фильтром по ключу партиционирования читает только нужные партиции (partition pruning), а DROP/DETACH PARTITION мгновенно управляет данными без тяжёлого DELETE. Не путать с ORDER BY (сортировка внутри партиции) и не дробить слишком мелко — тысячи партиций вредят.

  3. #ch_engines3 / 5
    Как ведёт себя ReplacingMergeTree при дубликатах по ключу сортировки?
    A)Схлопывает дубли лишь при фоновом слиянии; до него читатель видит все версии — если не FINAL
    B)Мгновенно и синхронно отклоняет вставку новой строки, если ключ сортировки уже существует в таблице
    C)Хранит одну строку на ключ сразу же в момент вставки данных
    D)Не удаляет дубликаты: строки с одинаковым ключом сортировки копятся в таблице
    показать ответ и разбор
    +A)Схлопывает дубли лишь при фоновом слиянии; до него читатель видит все версии — если не FINAL

    // разбор: ReplacingMergeTree оставляет одну строку на ключ (последнюю по версии), но делает это только во время фонового слияния кусков — момент недетерминирован. До слияния SELECT видит все версии, поэтому для гарантированно схлопнутого результата запрашивают с модификатором FINAL (дороже) или агрегируют вручную (argMax по версии). Проверено: до FINAL count=2, с FINAL — последняя версия.

  4. #ch_engines4 / 5
    Для чего нужен AggregatingMergeTree со столбцами AggregateFunction и комбинаторами -State/-Merge?
    A)Чтобы хранить исходные сырые строки без изменений и агрегировать их лишь в момент запроса вручную
    B)Предагрегировать данные: при слиянии складываются состояния агрегатов, при чтении их домердживают
    C)Чтобы заменить собой обычные MergeTree-таблицы во многих сценариях
    D)Это движок для хранения строковых логов без агрегации данных
    показать ответ и разбор
    +B)Предагрегировать данные: при слиянии складываются состояния агрегатов, при чтении их домердживают

    // разбор: AggregatingMergeTree хранит промежуточные состояния агрегатов (AggregateFunction(uniq, ...)), записанные через -State. При слиянии одинаковых ключей состояния объединяются, при чтении их доводят до значения через -Merge. Это основа предрасчёта (обычно поверх материализованного представления): вместо миллиардов сырых строк — компактные состояния. SummingMergeTree — простой частный случай для суммируемых колонок.

  5. #ch_engines5 / 5
    Чем ReplicatedMergeTree отличается от Distributed-таблицы?
    A)Это два полных синонима одного механизма: оба одновременно и реплицируют, и шардируют данные
    B)Replicated хранит данные в оперативной памяти, а Distributed — на диске одного узла
    C)Replicated даёт реплики (копии для надёжности), Distributed — шардирование (прокси без своих данных)
    D)Distributed физически хранит все данные всего кластера у себя, а Replicated не хранит ничего
    показать ответ и разбор
    +C)Replicated даёт реплики (копии для надёжности), Distributed — шардирование (прокси без своих данных)

    // разбор: Это ортогональные механизмы. ReplicatedMergeTree реплицирует куски между узлами (координация в ZooKeeper/Keeper) ради отказоустойчивости и чтения с любой реплики. Distributed — «зонтичная» таблица без своих данных: рассылает запрос по шардам и собирает ответ, вставки распределяет по ключу шардирования. Обычно комбинируют: Distributed поверх Replicated на каждом шарде — и масштаб, и надёжность.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.