Базы данных ClickHouse и колоночные аналитические БД: движки, партиционирование, MergeTree
0%

ClickHouse и колоночные аналитические БД: движки, партиционирование, MergeTree

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 обходит это ограничение.

Анатомия парта MergeTree

Ландшафт: кто есть кто в колоночном 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 записей → единицы мегабайт.

Механика запроса:

  1. По условию на префикс ORDER BY бинарным поиском в primary.idx находим диапазон гранул, которые могут содержать нужные строки.
  2. По номеру гранулы в файле засечек .mrk3 берём пару «смещение сжатого блока, смещение внутри распакованного блока».
  3. Читаем и распаковываем сжатый блок, вырезаем гранулу, отдаём дальше по конвейеру.

Прямое следствие: минимальная единица чтения — 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 — недооценённый приём: сортировка остаётся полной (лучше сжатие, работает дедупликация), а индекс в памяти меньше.

Воронка отсечения данных в ClickHouse

Партиционирование: самая частая и самая дорогая ошибка

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: вставка сразу пишет отсортированный парт на диск, а фоновые потоки сливают мелкие парты в крупные.

Из этой схемы — весь операционный свод правил вставки:

  • Вставляйте большими батчами. Ориентир — от 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:

  1. Правая таблица целиком строится в памяти для алгоритма hash (по умолчанию). Джойн с таблицей на 200 ГБ справа — гарантированный Memory limit exceeded.
  2. Порядок таблиц имеет значение. Оптимизатор не всегда переставит их за вас: справа должна быть меньшая. Это отличие от зрелых CBO в PostgreSQL или Oracle (https://courses.digitable.life/post/databases/04-ms-sql-and-oracle/).
  3. Выбор алгоритма — явный: SETTINGS join_algorithm = 'grace_hash' (умеет проливаться на диск), 'partial_merge' (медленнее, но памяти мало), 'full_sorting_merge' (если обе стороны уже отсортированы), 'parallel_hash' (быстрее на многоядерных), 'auto'.
  4. В распределённой схеме обычный 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

Устройство кластера:

  • Шард — независимый набор данных. Реплика — полная копия шарда. Репликация делается движками 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.

Источники

Что дальше

Мы разобрали хранилище, оптимизированное под «просканировать очень много и агрегировать». Но есть два родственных класса задач, где у данных особая структура и обобщённый OLAP-движок проигрывает специализированному: метрики с плотной осью времени и полнотекстовый поиск с ранжированием. Дальше — Временные ряды и поиск: TimescaleDB, InfluxDB, Elasticsearch, OpenSearch.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов