Движки таблиц 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, остальные разбираются в тренажёре.
- Как в 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.
- Зачем в MergeTree задают PARTITION BY (например, по месяцу)?A)Чтобы заменить собой сортировку данных: PARTITION BY делает секцию ORDER BY ненужнойB)Чтобы автоматически распределить партиции таблицы по разным физическим серверам большого кластера ClickHouseC)Чтобы включить построчные транзакции и блокировки внутри каждой отдельной партиции таблицыD)Разбить данные на партиции для отсечения по фильтру и операций над целыми кусками (DROP PARTITION)
показать ответ и разбор
+D)Разбить данные на партиции для отсечения по фильтру и операций над целыми кусками (DROP PARTITION)// разбор: PARTITION BY (часто toYYYYMM(date)) делит таблицу на партиции — независимые наборы кусков. Запрос с фильтром по ключу партиционирования читает только нужные партиции (partition pruning), а DROP/DETACH PARTITION мгновенно управляет данными без тяжёлого DELETE. Не путать с ORDER BY (сортировка внутри партиции) и не дробить слишком мелко — тысячи партиций вредят.
- Как ведёт себя ReplacingMergeTree при дубликатах по ключу сортировки?A)Схлопывает дубли лишь при фоновом слиянии; до него читатель видит все версии — если не FINALB)Мгновенно и синхронно отклоняет вставку новой строки, если ключ сортировки уже существует в таблицеC)Хранит одну строку на ключ сразу же в момент вставки данныхD)Не удаляет дубликаты: строки с одинаковым ключом сортировки копятся в таблице
показать ответ и разбор
+A)Схлопывает дубли лишь при фоновом слиянии; до него читатель видит все версии — если не FINAL// разбор: ReplacingMergeTree оставляет одну строку на ключ (последнюю по версии), но делает это только во время фонового слияния кусков — момент недетерминирован. До слияния SELECT видит все версии, поэтому для гарантированно схлопнутого результата запрашивают с модификатором FINAL (дороже) или агрегируют вручную (argMax по версии). Проверено: до FINAL count=2, с FINAL — последняя версия.
- Для чего нужен AggregatingMergeTree со столбцами AggregateFunction и комбинаторами -State/-Merge?A)Чтобы хранить исходные сырые строки без изменений и агрегировать их лишь в момент запроса вручнуюB)Предагрегировать данные: при слиянии складываются состояния агрегатов, при чтении их домердживаютC)Чтобы заменить собой обычные MergeTree-таблицы во многих сценарияхD)Это движок для хранения строковых логов без агрегации данных
показать ответ и разбор
+B)Предагрегировать данные: при слиянии складываются состояния агрегатов, при чтении их домердживают// разбор: AggregatingMergeTree хранит промежуточные состояния агрегатов (AggregateFunction(uniq, ...)), записанные через -State. При слиянии одинаковых ключей состояния объединяются, при чтении их доводят до значения через -Merge. Это основа предрасчёта (обычно поверх материализованного представления): вместо миллиардов сырых строк — компактные состояния. SummingMergeTree — простой частный случай для суммируемых колонок.
- Чем ReplicatedMergeTree отличается от Distributed-таблицы?A)Это два полных синонима одного механизма: оба одновременно и реплицируют, и шардируют данныеB)Replicated хранит данные в оперативной памяти, а Distributed — на диске одного узлаC)Replicated даёт реплики (копии для надёжности), Distributed — шардирование (прокси без своих данных)D)Distributed физически хранит все данные всего кластера у себя, а Replicated не хранит ничего
показать ответ и разбор
+C)Replicated даёт реплики (копии для надёжности), Distributed — шардирование (прокси без своих данных)// разбор: Это ортогональные механизмы. ReplicatedMergeTree реплицирует куски между узлами (координация в ZooKeeper/Keeper) ради отказоустойчивости и чтения с любой реплики. Distributed — «зонтичная» таблица без своих данных: рассылает запрос по шардам и собирает ответ, вставки распределяет по ключу шардирования. Обычно комбинируют: Distributed поверх Replicated на каждом шарде — и масштаб, и надёжность.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.