ClickHouse и колоночные аналитические БД: движки, партиционирование, MergeTree
Есть один разговор, который повторяется в каждой компании примерно на третий год жизни продукта. Аналитик приходит с запросом: «покажи распределение времени до первой покупки по когортам за последние два года, с разбивкой по источнику трафика». Запрос уходит в PostgreSQL — и не возвращается. Точнее, возвращается через сорок минут, попутно раздув shared_buffers, вытеснив из кэша рабочие данные продакшена и заставив дежурного проснуться.
Диагноз здесь не «Postgres плохой». Диагноз — несовпадение формы данных и формы доступа. Строковое хранилище оптимизировано под «дай мне всю строку по ключу»: заказ №481923 целиком, за одно чтение страницы. Аналитический запрос хочет противоположного: «дай мне три колонки из ста, но по всем двум миллиардам строк». Строковая база вынуждена прочитать все сто колонок, потому что они физически перемешаны на одной странице.
Колоночные аналитические БД разворачивают раскладку на девяносто градусов, и из этого одного решения каскадом следует всё остальное: сжатие в 10–30 раз, векторизованное исполнение, разреженные индексы, фоновые слияния вместо обновлений на месте и полный отказ от точечных транзакций. Эта статья — про то, как именно это работает в ClickHouse, чем вы за это платите и где проходит граница применимости.
Если строковая механика уже подзабылась, полезно освежить https://courses.digitable.life/post/databases/06-indexes-and-query-plans/ — B-tree, кучи и планы выполнения там разобраны подробно, и контраст будет виднее.
Первые принципы: почему поворот раскладки меняет всё
Возьмём таблицу событий: 100 колонок, 2 млрд строк, средняя строка — 400 байт. Всего 800 ГБ. Запрос: SELECT country, count(), avg(duration) FROM events GROUP BY country.
Строковое хранилище. Данные лежат постранично, в каждой странице — целые строки. Чтобы прочитать country и duration, нужно прочитать все страницы, то есть все 800 ГБ. Даже с NVMe на 3 ГБ/с это 266 секунд ввода-вывода, и это в идеальном последовательном случае.
Колоночное хранилище. Каждая колонка — отдельный файл. Читаем два файла: country (2 млрд значений) и duration (2 млрд значений). Это уже 2 % данных. Но дальше начинается второй эффект, который часто недооценивают: в колонке лежат однородные значения, и сжатие на них работает несопоставимо лучше. country — это несколько сотен уникальных строк на 2 млрд позиций; после словарного кодирования (LowCardinality) это 2 млрд однобайтовых индексов, а после LZ4 — десятки мегабайт. duration — числа в узком диапазоне, T64 + LZ4 сжимает их раз в 8–15.
Итог: вместо 800 ГБ читается порядка 2–4 ГБ. Разница не в два раза, а в 200–400. Именно поэтому «ClickHouse в 100 раз быстрее» — это не маркетинг, а арифметика раскладки.
Третий эффект — векторизация. Когда значения одного типа лежат подряд, обработка идёт не построчно (виртуальный вызов на каждую строку, как в классическом итераторе Volcano), а блоками по 65 536 значений в плотном массиве. Это укладывается в кэш процессора, разворачивается компилятором в SIMD и убирает ветвления. Идея не новая — она сформулирована в MonetDB/X100 (Boncz, Zukowski, Nes, CIDR 2005) и в C-Store (Stonebraker et al., VLDB 2005); канонический обзор — «The Design and Implementation of Modern Column-Oriented Database Systems» (Abadi, Boncz, Harizopoulos et al., 2013).
Плата за это — симметричная и жёсткая. Чтобы обновить одну строку, надо тронуть 100 файлов. Чтобы вставить одну строку, надо создать 100 файлов. Поэтому колоночные БД не обновляют данные на месте и не принимают вставки по одной строке. Всё, что дальше — про то, как ClickHouse обходит это ограничение.
Ландшафт: кто есть кто в колоночном OLAP
Прежде чем нырять в ClickHouse, полезно понимать, что он занимает конкретную нишу, а не всю аналитику.
| Система | Модель развёртывания | Сильная сторона | Слабое место | Когда НЕ брать |
|---|---|---|---|---|
| ClickHouse | self-hosted / Cloud | сырая скорость сканирования, стоимость за терабайт, SQL-диалект с сотнями функций | JOIN-и, консистентность вставок, отсутствие транзакций | нужны сложные многотабличные JOIN-ы или ACID |
| DuckDB | встраиваемый процесс | ноль эксплуатации, отличные JOIN-ы, прямые запросы к Parquet | один узел, один писатель | данные больше памяти одной машины ×10 |
| Apache Druid | кластер, много ролей | суб-секундные запросы при высокой конкурентности, real-time из Kafka | тяжёлая эксплуатация (6+ типов узлов), слабый SQL | маленькая команда без выделенного DBA |
| Apache Pinot | кластер | самая низкая латентность для user-facing аналитики, upsert | негибкий ad-hoc, сложные индексы надо проектировать заранее | ad-hoc исследование данных |
| StarRocks / Doris | кластер | лучшие JOIN-ы среди MPP-open-source, реальный CBO | меньше сообщества, менее богатый диалект | нужен зрелый экосистемный охват |
| Snowflake / BigQuery | только SaaS | разделение хранения и вычислений, ноль эксплуатации, эластичность | цена за скан/кредиты, vendor lock-in, латентность старта | предсказуемая круглосуточная нагрузка (переплата) |
| Vertica / Greenplum | self-hosted | зрелая оптимизация, полноценный SQL | лицензии, старение экосистемы | greenfield-проекты |
Практическое правило: до одного-двух терабайт и одного узла — DuckDB или колоночные расширения к PostgreSQL. От единиц терабайт до сотен, с несколькими аналитиками и дашбордами — ClickHouse. Если нужна суб-100-мс латентность на тысячах одновременных пользовательских запросов — Pinot или Druid. Если аналитика редкая, всплесками, а инженеров нет — BigQuery.
MergeTree: сердце ClickHouse
Почти всё в ClickHouse — это MergeTree или его вариация. Понять MergeTree — значит понять систему.
Как физически лежит таблица
CREATE TABLE events
(
site_id UInt32,
ts DateTime CODEC(DoubleDelta, ZSTD(1)),
user_id UInt64,
url String CODEC(ZSTD(3)),
country LowCardinality(String),
device LowCardinality(String),
duration_ms UInt32 CODEC(T64, LZ4),
revenue Decimal(18, 4)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(ts) -- каталоги на диске
ORDER BY (site_id, ts, user_id) -- физическая сортировка + первичный индекс
PRIMARY KEY (site_id, ts) -- индекс короче ключа сортировки: экономим память
TTL ts + INTERVAL 18 MONTH DELETE
SETTINGS index_granularity = 8192;
Каждая вставка порождает парт — самодостаточный immutable каталог на диске: 202601_1_1_0 (партиция, min-блок, max-блок, уровень слияния). Внутри — по паре файлов <колонка>.bin (сжатые данные) и <колонка>.mrk3 (засечки) на каждую колонку, плюс primary.idx, count.txt, checksums.txt и columns.txt. Маленькие парты (< 10 МБ по умолчанию, min_bytes_for_wide_part) хранятся в компактном формате: один data.bin на все колонки — иначе накладные расходы на файлы съедают выигрыш.
Внутри парта строки физически отсортированы по ORDER BY. Это ключевое отличие от строковых баз: там сортировка — свойство индекса, здесь — свойство самих данных. Отсюда и качество сжатия (соседние значения похожи), и работа первичного индекса.
Разреженный первичный индекс и гранулы
В B-tree на каждую строку приходится запись индекса. В MergeTree — одна запись на 8192 строки (index_granularity). Такой индекс называется разреженным, и он целиком помещается в память даже для триллионных таблиц: 1 млрд строк → 122 070 записей → единицы мегабайт.
Механика запроса:
- По условию на префикс
ORDER BYбинарным поиском вprimary.idxнаходим диапазон гранул, которые могут содержать нужные строки. - По номеру гранулы в файле засечек
.mrk3берём пару «смещение сжатого блока, смещение внутри распакованного блока». - Читаем и распаковываем сжатый блок, вырезаем гранулу, отдаём дальше по конвейеру.
Прямое следствие: минимальная единица чтения — 8192 строки. Запрос WHERE user_id = 12345 (не префикс ключа) не станет быстрым от индекса — он прочитает всё. А запрос по префиксу ключа, возвращающий одну строку, всё равно распакует хотя бы один блок на колонку. Это не баг, это дизайн: ClickHouse оптимизирован под пропускную способность, а не под латентность точечного доступа.
Отсюда — важнейшее правило проектирования: порядок колонок в ORDER BY определяет всё. Правило большого пальца: сначала колонки с низкой кардинальностью, по которым чаще всего фильтруют; в конце — с высокой. ORDER BY (site_id, ts, user_id) даёт отличное отсечение по site_id и по ts внутри сайта. Обратный порядок (user_id, ts, site_id) при фильтре по сайту не отсечёт ничего.
PRIMARY KEY как отдельный, более короткий префикс ORDER BY — недооценённый приём: сортировка остаётся полной (лучше сжатие, работает дедупликация), а индекс в памяти меньше.
Партиционирование: самая частая и самая дорогая ошибка
PARTITION BY создаёт физические каталоги. Партиция даёт три вещи: отсечение целых каталогов на этапе анализа запроса, DROP PARTITION как мгновенное удаление данных, и границу, через которую слияния никогда не идут.
Последнее — источник большинства инцидентов. Каждая партиция накапливает свои парты независимо, и общее число партов в таблице растёт линейно по числу партиций.
| Гранулярность | Партиций за 3 года | Когда уместно |
|---|---|---|
toYYYYMM(ts) |
36 | по умолчанию для почти всего |
toYYYYMMDD(ts) |
1095 | > 100 ГБ в сутки, ежедневный DROP PARTITION |
toStartOfHour(ts) |
26 280 | практически никогда |
(toYYYYMM(ts), tenant_id) |
36 × N тенантов | опасно: при 1000 тенантов — 36 000 партиций |
| без партиционирования | 1 | таблицы-справочники, небольшие факты |
Симптом плохого партиционирования — ошибка Too many parts (3000). Merges are processing significantly slower than inserts. Пороги: parts_to_delay_insert (в свежих версиях 1000) заставляет вставку ждать, parts_to_throw_insert (3000) — отбивает её. В версиях до 22.x эти значения были 150 и 300, поэтому старые советы из интернета звучат тревожнее, чем реальность.
Ещё одна ловушка: max_partitions_per_insert_block (по умолчанию 100). Если вы вставляете блок, где события размазаны по 400 датам, — вставка упадёт, даже если данных мало.
Практическое правило: цельтесь в 10–100 ГБ на партицию и в несколько сотен активных партов на таблицу. Проверять так:
SELECT
table,
count() AS parts,
uniqExact(partition) AS partitions,
formatReadableSize(sum(bytes_on_disk)) AS size,
round(count() / uniqExact(partition), 1) AS parts_per_partition
FROM system.parts
WHERE active AND database = 'analytics'
GROUP BY table
ORDER BY parts DESC;
Вставка и фоновые слияния: LSM без LSM
MergeTree — это по духу LSM-дерево (см. https://courses.digitable.life/post/databases/12-cassandra-and-wide-column/), только без memtable: вставка сразу пишет отсортированный парт на диск, а фоновые потоки сливают мелкие парты в крупные.
блок строк"] --> AI{"async_insert = 1?"} AI -->|"да"| BUF["Буфер на сервере
ждём busy_timeout_ms
или max_data_size"] AI -->|"нет"| SORT BUF --> SORT["Сортировка блока по ORDER BY"] SORT --> DED{"Дедупликация
по хешу блока?"} DED -->|"хеш уже видели"| SKIP["Блок отброшен
идемпотентный повтор"] DED -->|"новый"| PART["Записан новый парт
уровень 0, состояние Temporary"] PART --> ACT["fsync + переименование
→ Active"] ACT --> CHK{"активных партов
в партиции"} CHK -->|"> parts_to_delay_insert"| DELAY["Вставка засыпает
на до 1 сек"] CHK -->|"> parts_to_throw_insert"| THROW["Ошибка Too many parts"] CHK -->|"норма"| OK["Ответ клиенту"] ACT -.-> POOL["Фоновый пул слияний"] POOL --> SEL["Выбор партов:
близкие по размеру,
одна партиция,
суммарно < 150 ГБ"] SEL --> MRG["Слияние: сортированное
объединение потоков колонок
+ логика движка"] MRG --> NEW["Новый парт уровня N+1"] NEW --> OLD["Старые парты → Outdated
удаляются через 8 минут"]
Из этой схемы — весь операционный свод правил вставки:
- Вставляйте большими батчами. Ориентир — от 10 000 до 1 000 000 строк за раз, не чаще раза в секунду на таблицу. Вставка по одной строке в цикле убивает ClickHouse надёжнее любого тяжёлого запроса.
- Если батчить на стороне приложения нельзя (много мелких продюсеров) — включайте
async_insert = 1. Сервер сам соберёт буфер. Сwait_for_async_insert = 1клиент дождётся флаша (медленнее, но вы узнаете об ошибке); с0— вставка становится «fire and forget» со всеми последствиями. - Вставка идемпотентна по хешу блока для
Replicated*-движков: повтор того же блока после сетевой ошибки не удвоит данные (окно —replicated_deduplication_window, по умолчанию 100 последних блоков). Это единственная транзакционная гарантия, на которую можно опираться, и она — единственная причина, по которой pipeline из Kafka вообще может быть корректным.
Слияния — не бесплатны. Слияние 150 ГБ — это перечитывание и перезапись 150 ГБ, то есть двойная амплификация записи, конкурирующая с запросами за ввод-вывод и CPU. background_pool_size (по умолчанию 16) ограничивает параллелизм. На дисках с медленной записью первым делом упирается именно сюда.
Жизненный цикл парта
Ключевой вывод из этой диаграммы: обновления в ClickHouse — не операции, а фоновые перестроения. ALTER TABLE ... UPDATE возвращает управление мгновенно, но реальная работа идёт часами и видна в system.mutations. Застрявшая мутация (is_done = 0, растущий latest_fail_reason) — классический инцидент, который блокирует последующие ALTER-ы на той же таблице.
DELETE FROM ... WHERE (lightweight delete, начиная с 22.8) дешевле: он лишь помечает строки в скрытой колонке _row_exists, а физическое удаление откладывается до ближайшего слияния. Но каждый последующий SELECT теперь дополнительно читает эту маску.
Семейство движков: логика, встроенная в слияние
Гениальность MergeTree в том, что слияние — это не просто конкатенация, а точка расширения. Разные движки при слиянии применяют разную логику к строкам с одинаковым ключом сортировки.
| Движок | Что делает при слиянии | Типичное применение | Главная ловушка |
|---|---|---|---|
MergeTree |
ничего, просто сливает | сырые события, логи | — |
ReplacingMergeTree(ver) |
оставляет строку с максимальным ver |
снапшоты состояния, CDC из OLTP | дедупликация не гарантирована до слияния |
SummingMergeTree(cols) |
суммирует числовые колонки | предагрегированные счётчики | суммирует только по ORDER BY, теряет детализацию навсегда |
AggregatingMergeTree |
объединяет состояния агрегатов | материализованные представления с uniq, quantile |
требует *State/*Merge-функций, легко ошибиться |
CollapsingMergeTree(sign) |
схлопывает пары +1/-1 |
изменяемые сущности без версии | при неверном порядке строк схлопывание не срабатывает |
VersionedCollapsingMergeTree |
то же, но с версией | то же при вставках вне порядка | сложнее в отладке |
GraphiteMergeTree |
прореживает метрики по возрасту | хранилище Graphite | узкая ниша |
Самая частая ошибка новичка — считать ReplacingMergeTree заменой UPSERT. Это не так:
CREATE TABLE user_state
(
user_id UInt64,
updated_at DateTime,
plan LowCardinality(String),
is_deleted UInt8
)
ENGINE = ReplacingMergeTree(updated_at, is_deleted)
ORDER BY user_id;
-- НЕВЕРНО: до слияния вернёт обе версии строки
SELECT * FROM user_state WHERE user_id = 42;
-- ВЕРНО, но дорого: FINAL доливает слияние на лету
SELECT * FROM user_state FINAL WHERE user_id = 42;
-- ВЕРНО и дёшево: дедупликация выражена в запросе
SELECT user_id, argMax(plan, updated_at) AS plan
FROM user_state
WHERE user_id = 42
GROUP BY user_id
HAVING argMax(is_deleted, updated_at) = 0;
FINAL в свежих версиях распараллелен (do_not_merge_across_partitions_select_final = 1 помогает ещё сильнее), но на широких таблицах он всё равно обходится в 2–10 раз дороже обычного скана. Промышленный подход — либо argMax в запросе, либо настройка final = 1 на уровне профиля для витрин, где корректность важнее скорости.
Материализованные представления: это триггер, а не представление
Самое сильное недопонимание в ClickHouse. MATERIALIZED VIEW здесь — триггер AFTER INSERT, который выполняет свой SELECT над только что вставленным блоком и кладёт результат в целевую таблицу. Он не видит уже лежащих данных и не пересчитывается сам.
-- Целевая таблица: состояния агрегатов, а не готовые числа
CREATE TABLE events_hourly
(
site_id UInt32,
hour DateTime,
country LowCardinality(String),
hits AggregateFunction(count),
uniques AggregateFunction(uniq, UInt64),
p95_ms AggregateFunction(quantileTDigest(0.95), UInt32)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(hour)
ORDER BY (site_id, hour, country);
CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
SELECT
site_id,
toStartOfHour(ts) AS hour,
country,
countState() AS hits,
uniqState(user_id) AS uniques,
quantileTDigestState(0.95)(duration_ms) AS p95_ms
FROM events
GROUP BY site_id, hour, country;
-- Чтение: состояния «доагрегируются» *Merge-функциями
SELECT
site_id,
countMerge(hits) AS hits,
uniqMerge(uniques) AS uniques,
quantileTDigestMerge(0.95)(p95_ms) AS p95_ms
FROM events_hourly
WHERE hour >= now() - INTERVAL 7 DAY
GROUP BY site_id
ORDER BY hits DESC;
Почему AggregateFunction, а не просто SUM? Потому что uniq и квантили не аддитивны: нельзя сложить два числа уникальных пользователей и получить верный ответ. uniqState хранит сериализованный HyperLogLog, quantileTDigestState — t-digest; их можно корректно объединять при слиянии партов. Это тот же приём, что и в стриминговых системах, и он подробнее разобран в треке https://courses.digitable.life/post/data-engineering/00-overview/.
Практические грабли материализованных представлений:
- Ошибка в MV ломает вставку в исходную таблицу. Если MV не может записать результат,
INSERTпадает целиком. Спасаетmaterialized_views_ignore_errors = 1— но тогда вы молча теряете агрегаты. - Несколько MV на одну таблицу выполняются последовательно в рамках одной вставки. Пять тяжёлых MV — это пятикратная стоимость каждого INSERT.
- Исторические данные надо заливать вручную:
INSERT INTO events_hourly SELECT ... FROM events WHERE ts < '...'— MV прошлое не увидит. - Альтернатива для простых случаев — проекции (
ALTER TABLE ... ADD PROJECTION): другой порядок сортировки или предагрегация внутри тех же партов, оптимизатор выбирает их автоматически. Проще, но менее гибко и удваивает объём хранения.
Индексы пропуска данных и кодеки сжатия
Skip-индексы
Первичный индекс отсекает по префиксу ORDER BY. Для всего остального есть индексы пропуска данных: они хранят агрегат по каждым N гранулам и позволяют пропустить блок целиком, если условие заведомо ложно.
ALTER TABLE events ADD INDEX idx_rev revenue TYPE minmax GRANULARITY 4;
ALTER TABLE events ADD INDEX idx_dev device TYPE set(100) GRANULARITY 2;
ALTER TABLE events ADD INDEX idx_url url TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 1;
ALTER TABLE events MATERIALIZE INDEX idx_url; -- применить к уже существующим партам
| Тип | Что хранит | Эффективен, когда | Бесполезен, когда |
|---|---|---|---|
minmax |
min/max на блок гранул | значение коррелирует с порядком сортировки | значения размазаны равномерно |
set(N) |
до N уникальных значений | низкая кардинальность локально в блоке | > N уникальных в блоке — индекс отключается |
bloom_filter |
фильтр Блума по значениям | равенство по высококардинальной колонке | диапазонные условия |
ngrambf_v1 |
фильтр по n-граммам | LIKE '%подстрока%' |
короткие или очень частые подстроки |
tokenbf_v1 |
фильтр по токенам | поиск слов в тексте, hasToken |
поиск по части слова |
Главное честное замечание: skip-индексы работают только при корреляции данных с физическим порядком. Если значения device равномерно перемешаны, то в каждой грануле встречаются все устройства, и set-индекс не отсечёт ничего — вы платите за его построение и хранение впустую. Всегда проверяйте эффект через EXPLAIN indexes = 1.
Кодеки
ClickHouse применяет кодек до общего сжатия (LZ4 по умолчанию, ZSTD опционально).
| Кодек | Для чего | Реальный эффект (типичные данные) |
|---|---|---|
DoubleDelta |
монотонные временные метки | 8 байт → 0.2–1 байт на значение |
Gorilla |
плавающие метрики с малой дисперсией | 8 байт → 1–2 байта |
Delta |
медленно растущие счётчики, ID | ×2–4 поверх LZ4 |
T64 |
целые в узком диапазоне | ×2–5 поверх LZ4 |
LowCardinality(T) |
строки с < 10 000 уникальных | ×5–50, плюс ускорение GROUP BY |
ZSTD(1..3) |
текст, JSON, URL | на 20–40 % лучше LZ4, но в 2–4 раза медленнее на распаковке |
Проверять эффект надо не по мануалу, а по фактам:
SELECT
name,
type,
formatReadableSize(sum(data_compressed_bytes)) AS compressed,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed,
round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio,
any(compression_codec) AS codec
FROM system.parts_columns
WHERE active AND table = 'events'
GROUP BY name, type
ORDER BY sum(data_compressed_bytes) DESC;
Типичная картина после такого аудита: одна колонка String с сырым JSON занимает 60 % таблицы. Замена её на распарсенные типизированные колонки или на ZSTD(3) даёт больше, чем любая настройка железа. Здоровый суммарный коэффициент сжатия для событийных данных — 8–20; если у вас 2–3, схема почти наверняка построена плохо (String вместо LowCardinality, Float64 вместо Decimal/UInt32, DateTime64(9) там, где хватает секунд).
Планы выполнения: как читать и что смотреть
EXPLAIN indexes = 1
SELECT country, count()
FROM events
WHERE ts >= '2026-01-01' AND site_id = 31
GROUP BY country;
Вывод содержит блок Indexes, и именно он важнее всего остального:
Indexes:
MinMax
Keys: ts
Parts: 214/1284 <- отсечение партиций сработало
Granules: 2441406/7629394
PrimaryKey
Keys: site_id, ts
Parts: 214/214
Granules: 3200/2441406 <- первичный ключ отсёк ~99.87 %
Skip
Name: idx_url
Granules: 190/3200
Что здесь смотреть:
- Если
PrimaryKeyне появился вовсе — условие не ложится на префиксORDER BY. Это главная причина медленных запросов. - Если
Granulesпочти не уменьшаются на шагеPrimaryKey— ключ выбран неудачно для этой нагрузки. - Если
Partsвелико (тысячи) — проблема с партиционированием или слияниями, а не с запросом.
Дальше — фактические замеры. EXPLAIN PIPELINE показывает степень параллелизма операторов, а system.query_log — правду о выполненном запросе:
SELECT
query_duration_ms,
formatReadableQuantity(read_rows) AS rows,
formatReadableSize(read_bytes) AS bytes,
formatReadableSize(memory_usage) AS mem,
ProfileEvents['SelectedParts'] AS parts,
ProfileEvents['SelectedRanges'] AS ranges,
ProfileEvents['OSReadBytes'] AS disk_read,
normalized_query_hash
FROM system.query_log
WHERE type = 'QueryFinish'
AND event_time > now() - INTERVAL 1 HOUR
ORDER BY query_duration_ms DESC
LIMIT 10;
Диагностическая эвристика: разделите read_rows на общее число строк в таблице. Если получилось больше 5 % для запроса с фильтром — отсечение не работает, и лечить надо схему, а не запрос.
JOIN-ы: главная слабость и как с ней жить
ClickHouse исторически строился под одну огромную денормализованную таблицу. JOIN появился позже и до сих пор остаётся самой слабой частью.
Что нужно знать про JOIN в ClickHouse:
- Правая таблица целиком строится в памяти для алгоритма
hash(по умолчанию). Джойн с таблицей на 200 ГБ справа — гарантированныйMemory limit exceeded. - Порядок таблиц имеет значение. Оптимизатор не всегда переставит их за вас: справа должна быть меньшая. Это отличие от зрелых CBO в PostgreSQL или Oracle (https://courses.digitable.life/post/databases/04-ms-sql-and-oracle/).
- Выбор алгоритма — явный:
SETTINGS join_algorithm = 'grace_hash'(умеет проливаться на диск),'partial_merge'(медленнее, но памяти мало),'full_sorting_merge'(если обе стороны уже отсортированы),'parallel_hash'(быстрее на многоядерных),'auto'. - В распределённой схеме обычный
IN/JOINвыполнится на каждом шарде со своими локальными данными — что почти всегда неверно. НуженGLOBAL IN/GLOBAL JOIN, который сначала соберёт правую часть на инициаторе и разошлёт её.
Идиоматичное решение — словари вместо джойнов на справочники:
CREATE DICTIONARY dict_campaign
(
campaign_id UInt32,
name String,
channel String
)
PRIMARY KEY campaign_id
SOURCE(POSTGRESQL(host 'pg' port 5432 db 'crm' table 'campaigns' user 'ro' password '...'))
LIFETIME(MIN 300 MAX 600)
LAYOUT(HASHED());
SELECT
dictGet('dict_campaign', 'channel', campaign_id) AS channel,
sum(revenue)
FROM events
WHERE ts >= today() - 7
GROUP BY channel;
dictGet — это хеш-лукап в памяти, O(1) на строку, без построения хеш-таблицы на каждый запрос и без сетевого обмена. Словарь автоматически обновляется из источника (PostgreSQL, MySQL, HTTP, файл, другая таблица ClickHouse). Для справочников до десятков миллионов записей это на порядок лучше JOIN-а.
Распределённый ClickHouse: шарды, реплики, Keeper
(Distributed-таблица) participant S1 as Шард 1 (реплика A) participant S2 as Шард 2 (реплика B) participant S3 as Шард 3 (реплика A) CL->>IN: SELECT country, uniq(user_id) ... GROUP BY country Note over IN: Переписывает запрос:
частичная агрегация на шардах par параллельно по шардам IN->>S1: SELECT country, uniqState(user_id) GROUP BY country IN->>S2: SELECT country, uniqState(user_id) GROUP BY country IN->>S3: SELECT country, uniqState(user_id) GROUP BY country end S1-->>IN: частичные состояния (килобайты) S2-->>IN: частичные состояния S3-->>IN: частичные состояния Note over IN: uniqMerge по состояниям
финальная сортировка и LIMIT IN-->>CL: результат Note over IN,S3: Если бы это был JOIN без GLOBAL —
каждый шард джойнил бы только СВОИ данные
и результат был бы молча неверным
Устройство кластера:
- Шард — независимый набор данных. Реплика — полная копия шарда. Репликация делается движками
Replicated*MergeTreeи координируется через ClickHouse Keeper (совместимый с ZooKeeper по протоколу, но на C++ и на Raft — заменил ZooKeeper начиная с 21.x). - Репликация асинхронная и multi-master: писать можно в любую реплику. Реплики обмениваются журналом операций через Keeper и тянут парты друг у друга по HTTP.
Distributed-таблица — не хранилище, а маршрутизатор поверх локальных таблиц с ключом шардирования.
CREATE TABLE events_local ON CLUSTER prod
( /* ... те же колонки ... */ )
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events', '{replica}')
PARTITION BY toYYYYMM(ts)
ORDER BY (site_id, ts, user_id);
CREATE TABLE events ON CLUSTER prod AS events_local
ENGINE = Distributed(prod, currentDatabase(), events_local, cityHash64(site_id));
Честно о гарантиях: консистентность здесь eventual. Вставили в реплику A — реплика B ответит старыми данными, пока не догонит. Лечится настройками insert_quorum = 2 (вставка ждёт подтверждения от кворума реплик) и select_sequential_consistency = 1 (чтение ждёт, пока реплика догонит кворум), но обе бьют по производительности и не превращают ClickHouse в CP-систему в смысле https://courses.digitable.life/post/databases/09-nosql-landscape/. Транзакций между шардами нет вовсе.
В ClickHouse Cloud появился SharedMergeTree: парты живут в объектном хранилище (см. https://courses.digitable.life/post/databases/16-object-storage/), а узлы становятся stateless-вычислителями с общим кэшем. Это устраняет ребалансировку при масштабировании — цена в том, что латентность холодного чтения из S3 измеряется десятками миллисекунд, и всё держится на кэше на локальных NVMe.
Эксплуатация: инциденты, которые случаются со всеми
| Симптом | Настоящая причина | Что делать |
|---|---|---|
Too many parts |
мелкие частые вставки или слишком дробное партиционирование | батчинг / async_insert, укрупнить PARTITION BY, поднять background_pool_size |
Memory limit (total) exceeded |
GROUP BY высокой кардинальности или JOIN |
max_bytes_before_external_group_by, join_algorithm='grace_hash', сузить выборку |
| Запрос был быстрым, стал медленным | вырос объём партиции, или сломалось отсечение после изменения фильтра | EXPLAIN indexes = 1, сверить с ORDER BY |
| Мутация «висит» | ошибка в мутации либо огромный объём переписывания | system.mutations, KILL MUTATION, разбить на партиции |
| Реплика отстала на часы | не успевает тянуть парты, узкая сеть или диск | system.replication_queue, system.replicas.absolute_delay |
| Диск заполнен на 100 %, данных мало | накопились detached-парты и старые Outdated |
system.detached_parts, очистить вручную |
| Дашборд показывает дубли | ReplacingMergeTree без FINAL/argMax |
переписать запрос, не «подождать слияния» |
Минимальный набор наблюдаемости, который надо завести в первый же день:
-- отставание реплик
SELECT database, table, absolute_delay, queue_size, inserts_in_queue, merges_in_queue
FROM system.replicas WHERE absolute_delay > 60;
-- активные слияния и мутации
SELECT database, table, elapsed, progress, num_parts,
formatReadableSize(total_size_bytes_compressed) AS size
FROM system.merges ORDER BY elapsed DESC;
-- застрявшие мутации
SELECT database, table, mutation_id, command, parts_to_do, latest_fail_reason
FROM system.mutations WHERE is_done = 0;
-- топ таблиц по месту
SELECT database, table, formatReadableSize(sum(bytes_on_disk)) AS disk,
sum(rows) AS rows, count() AS parts
FROM system.parts WHERE active GROUP BY database, table ORDER BY sum(bytes_on_disk) DESC LIMIT 20;
Про многоуровневое хранение стоит знать заранее: TTL ts + INTERVAL 3 MONTH TO VOLUME 'cold' переносит старые парты на дешёвые диски или в S3, не меняя запросов. Это самый простой способ сократить стоимость хранения в разы — при условии, что горячие запросы действительно живут в последних неделях.
Стоимость: за что вы на самом деле платите
Сравнение по одной и той же задаче — 20 ТБ сжатых данных, около 30 ТБ сканирований в месяц, круглосуточная нагрузка дашбордов (порядок цен на 2025–2026 гг., проверяйте актуальные прайсы):
| Вариант | Модель оплаты | Порядок затрат в месяц | Скрытые издержки |
|---|---|---|---|
| ClickHouse self-hosted, 3 узла (32 vCPU, 128 ГБ, 8 ТБ NVMe) | аренда VM + диски | 2 500 $ – 4 000 $ | 0.3–0.5 FTE инженера, дежурства, обновления |
| ClickHouse Cloud | вычисления + хранение отдельно | 3 000 $ – 8 000 $ | автоскейлинг может выстрелить на всплеске |
| BigQuery on-demand | ~6.25 $ за ТБ скана | 190 $ + хранение — если запросы аккуратные | один SELECT * по всей таблице = сотни долларов |
| BigQuery/Snowflake на резервах | слоты / кредиты | 2 000 $ – 10 000 $ | простаивающий склад всё равно тарифицируется |
| Snowflake, склад M 24/7 | ~4 кредита/час | 8 000 $ – 12 000 $ | автосуспенд обязателен, иначе счёт удваивается |
Ключевое различие моделей: у ClickHouse затраты почти фиксированы и предсказуемы, у облачных складов — пропорциональны использованию. Отсюда практическое правило: при постоянной круглосуточной нагрузке self-hosted ClickHouse дешевле в 3–5 раз; при редкой всплесковой аналитике (три отчёта в неделю) он дороже, потому что железо простаивает, а BigQuery в такой ситуации почти бесплатен.
И не забывайте про самую крупную статью, которой нет в прайс-листе: инженер, который умеет чинить Too many parts в три часа ночи, стоит дороже, чем разница между вариантами.
Когда ClickHouse брать НЕ надо
Это самая полезная часть статьи. ClickHouse — плохой выбор, если:
- Вам нужны транзакции и точечные обновления. Нет ACID, нет откатов, нет уникальных ограничений, нет внешних ключей. Профиль пользователя, корзина, баланс — это PostgreSQL (https://courses.digitable.life/post/databases/02-postgresql/).
- Профиль нагрузки — тысячи мелких запросов в секунду по первичному ключу. Каждый запрос распаковывает гранулу; ClickHouse держит десятки–сотни одновременных запросов, а не десятки тысяч. Для такого — Redis (https://courses.digitable.life/post/databases/11-redis/) или обычная OLTP-база.
- Ядро задачи — сложные многотабличные JOIN-ы нормализованной схемы. Либо денормализуйте, либо берите StarRocks/Doris/Snowflake, либо оставайтесь в PostgreSQL.
- Данных меньше пары терабайт и один аналитик. DuckDB или колоночные расширения к PostgreSQL решат задачу без кластера, Keeper, реплик и дежурств.
- Требуется строгая согласованность между узлами. Репликация асинхронная; распределённых транзакций нет. Для этого существует класс NewSQL (https://courses.digitable.life/post/databases/18-newsql-and-distributed/).
- Нет ни одного человека, готового разбираться в MergeTree. ClickHouse честно возвращает результат за 100 мс на правильной схеме и за 100 секунд на неправильной. Разница — целиком в проектировании, а не в настройках.
Мини-итог
- Колоночное хранение выигрывает не в разы, а на два порядка, и не благодаря магии, а из-за трёх сложенных эффектов: читаются только нужные колонки, однородные данные сжимаются в 10–30 раз, обработка идёт векторами по 65 536 значений.
- MergeTree = отсортированные immutable парты + разреженный индекс (одна запись на 8192 строки) + фоновые слияния. Это LSM по духу: быстрая вставка, отложенное упорядочивание, амплификация записи как плата.
ORDER BY— важнейшее решение в схеме,PARTITION BY— второе по важности и первое по числу инцидентов. Целевые ориентиры: 10–100 ГБ на партицию, сотни активных партов на таблицу.- Вставляйте батчами от 10 тысяч строк или включайте
async_insert. Идемпотентность по хешу блока вReplicated*-движках — единственная транзакционная гарантия, которая у вас есть. - Движки семейства (
Replacing,Summing,Aggregating,Collapsing) встраивают логику прямо в слияние — но ни один из них не гарантирует результат до слияния. В запросе всегда нуженFINALилиargMax. MATERIALIZED VIEW— это триггер на вставку. Он не видит прошлого, выполняется синхронно сINSERTи падает вместе с ним.- Диагностика начинается с
EXPLAIN indexes = 1иsystem.query_log: если прочитано больше 5 % таблицы при наличии фильтра — виновата схема, а не запрос. - JOIN — слабое место: правая таблица в памяти,
GLOBALв распределённом контуре, словари вместо джойнов на справочники. - Не берите ClickHouse под OLTP, точечные апдейты, тысячи мелких конкурентных запросов, строгую согласованность или объёмы, с которыми справится DuckDB.
Источники
- ClickHouse Documentation — особенно разделы MergeTree, Primary Indexes и Data Skipping Indexes.
- Schulze, Schreiber, Yatsishin, Dahimene, Milovidov, «ClickHouse — Lightning Fast Analytics for Everyone», PVLDB Vol. 17, 2024 — архитектурная статья от команды разработчиков.
- Stonebraker, Abadi, Batkin et al., «C-Store: A Column-oriented DBMS», VLDB 2005.
- Boncz, Zukowski, Nes, «MonetDB/X100: Hyper-Pipelining Query Execution», CIDR 2005 — исходная работа по векторизованному исполнению.
- Abadi, Boncz, Harizopoulos, Idreos, Madden, «The Design and Implementation of Modern Column-Oriented Database Systems», 2013 — исчерпывающий обзор области.
- Melnik et al., «Dremel: Interactive Analysis of Web-Scale Datasets», VLDB 2010 — основа BigQuery.
- Dageville et al., «The Snowflake Elastic Data Warehouse», SIGMOD 2016 — разделение хранения и вычислений.
- Raasveldt, Mühleisen, «DuckDB: an Embeddable Analytical Database», SIGMOD 2019.
- ClickBench — открытый бенчмарк аналитических БД с воспроизводимой методикой; читайте вместе с оговорками авторов.
- Altinity Knowledge Base — практические разборы инцидентов эксплуатации ClickHouse.
- Martin Kleppmann, «Designing Data-Intensive Applications», глава 3, раздел «Column-Oriented Storage».
Что дальше
Мы разобрали хранилище, оптимизированное под «просканировать очень много и агрегировать». Но есть два родственных класса задач, где у данных особая структура и обобщённый OLAP-движок проигрывает специализированному: метрики с плотной осью времени и полнотекстовый поиск с ранжированием. Дальше — Временные ряды и поиск: TimescaleDB, InfluxDB, Elasticsearch, OpenSearch.