ClickHouse в продакшне

По граблям

|
ClickHouse в продакшне

Tesla запихнула в ClickHouse больше квадриллиона строк. Cloudflare перемалывает 11 миллионов строк в секунду. Netflix ежедневно льёт туда около пяти петабайт логов.

Цифры не врут — компаниям нет смысла приукрашивать.

Мы сами используем ClickHouse для обработки данных крупнейшей организации в Европе в своей сфере. По дороге наступили на многие грабли, которые этот гайд поможет вам обойти.

Вот в чём беда с документацией ClickHouse: у каждой фичи — своя страница. Движки, материализованные представления, проекции, словари — всё расписано по отдельности. Но документация молчит о том, как эти решения влияют друг на друга. А они влияют, и ещё как.

Накосячите с ORDER BY — и материализованные представления агрегируются по неправильному ключу. Накосячите с материализованными представлениями — и никакая оптимизация запросов уже не спасёт. Выберете не ту модель репликации — и будете чинить ZooKeeper вместо того, чтобы фигачить продукт. Решения накапливаются, как снежный ком.

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

Архитектура, которая делает всё возможным

Строковая

ts
user
event
amt
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
Поиск event чтение всех 4 стобцов , 3 напрасно

Колоночная

ts
user
event
amt
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
Поиск event чтение 1 столбца

В 2009 году у Яндекса была проблема с аналитикой. Их веб-платформа обрабатывала 200 миллионов событий в день. К 2016 году — уже 25 миллиардов. Проблема была не в хранении, а в том, чтобы гонять интерактивную аналитику по сырым, неагрегированным данным в таком объёме.

Когда они переехали на то, что стало ClickHouse, среднее время загрузки отчёта упало с 26 секунд до 0,8 секунды. Те же запросы, те же данные, в 32 раза быстрее. Не благодаря лучшему «железу» — благодаря другой модели хранения.

Всё дело в двух решениях.

Колоночное хранение

Строчные базы данных держат данные так, как вы ожидаете: каждая строка лежит на диске целиком.

[timestamp1, user_id1, event1], [timestamp2, user_id2, event2]...

Читаешь одну колонку — читаешь каждую строку целиком. Для запроса вроде SELECT count() WHERE event_type = 'purchase' по таблице с 200 колонок строчная база прочитает все 200 колонок, чтобы отдать вам ту единственную, которая нужна.

ClickHouse хранит каждую колонку отдельно:

[timestamp1, timestamp2...], [user_id1, user_id2...], [event1, event2...]

Тот же запрос читает одну колонку из 200. Объём ввода-вывода уменьшается пропорционально количеству ненужных колонок. А их, простите, 199 штук.

Бонус — сжатие. Колонка с таймстемпами — это просто таймстемпы. Колонка с источниками трафика по большей части состоит из неоппределенного, яндекс и гугла. Однотипные, структурированные данные сжимаются намного лучше, чем строка с борщом из разных типов. Когда-то я срочно переезжал из BigQuery в ClickHouse, и очень удивился сжатию таблиц с данными аналитических событий до 40 раз!

MergeTree: парты, гранулы и разреженный индекс

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

Внутри каждой парты данные делятся на гранулы — по умолчанию 8 192 строки. Разреженный индекс сопоставляет диапазон ключа ORDER BY каждой гранулы с её позицией на диске. Этот индекс достаточно мал, чтобы поместиться в оперативку.

Когда приходит запрос с WHERE по колонкам из ORDER BY, ClickHouse читает разреженный индекс, определяет, какие гранулы могут содержать подходящие строки, и пропускает остальные. Запрос с фильтром WHERE counter_id = 123456 по таблице с 15 000 гранул может прочитать всего 12. Остальные 14 988 даже не почешутся.

Вот почему ORDER BY — самое важное решение в схеме, которое вы примете.

Векторизованное исполнение

ClickHouse обрабатывает данные пачками по 65 536 строк, применяя операции ко всей пачке через SIMD-инструкции (Single Instruction, Multiple Data — одна инструкция, много данных). Процессор выполняет одну и ту же операцию сразу над целой пачкой значений, а не перебирает их по одному. Это на порядок эффективнее. Выигрыш измерим: бенчмарки стабильно показывают, что ClickHouse сканирует 100+ ГБ в секунду на современном «железе».

Где ClickHouse может споткнуться

ClickHouse заточен под аналитические сканы по большим датасетам, у этого подхода есть и негативные стороны:

Высокочастотные маленькие обновления

У ClickHouse нет традиционного UPDATE на месте — любая мутация — это перезапись. Собирайте изменения в пачки и вставляйте оптом, а ReplacingMergeTree (или CollapsingMergeTree) разберутся при слиянии.

`GROUP BY` с высокой кардинальностью и большим результатом

Агрегировать 100 миллионов уникальных ID пользователей в памяти — дорого где угодно. ClickHouse не является исключением.

OLTP-паттерны

Если вам нужно читать и писать по одной строке с высокой конкурентностью — используйте строчную базу, не мучайте ClickHouse. SELECT * здесь собирает полную строку из N чтений колонок. Но если ключ в ORDER BY, первичный индекс сужает сканирование, и проблема снимается.

Большие `JOIN` с высокой кардинальностью

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

Моделирование данных

Выбор движка

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

Тип данных Движок Что делает слияние Когда использовать
События и логи, только добавление MergeTree Склеивает парты Сырые события, неизменяемые записи
Строки обновляются (CDC) ReplacingMergeTree Дедупликация по ключу ORDER BY, оставляет последнюю версию Upsert'ы, измерения, CDC, данные рекламных площадок
Предварительно агрегированные суммы и счётчики SummingMergeTree Суммирует числовые колонки по совпадающим ключам Простые метрики, HTTP-аналитика
Предварительно агрегированное что угодно AggregatingMergeTree Объединяет частичные состояния агрегации Срезы, дашборды в реальном времени
Изменения состояний из стримов CollapsingMergeTree Отменяет пары +1/−1 Заказы, трекинг позиций

На практике: 99% продакшен-таблиц используют MergeTree, ReplacingMergeTree или AggregatingMergeTree.

ORDER BY: решение, которое рулит всем

ORDER BY определяет физический порядок на диске. На нём строится разреженный индекс, к нему применяется логика слияния конкретного движка.

Производительность

Данные на диске отсортированы по ORDER BY. Правило: низкая кардинальность — первой, высокая — последней.
CloudQuery опубликовали разбор своего инцидента: они поставили таймстемп в начале сортировки – клиентские запросы сканировали в 10 раз больше данных, чем нужно. Тогда они передвинули customer_id перед таймстемпом, и проблема исчезла.

-- Было: timestamp в начале, плохо для запросов по пользователямORDER BY (timestamp, customer_id) -- Стало: customer_id в начале, 10кратный приростORDER BY (customer_id, timestamp)

В документации ClickHouse есть пример, где перестановка двух колонок изменила количество прочитанных строк с 7,92 миллиона до 20 320 — разница в 390 раз.

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

Корректность

В ReplacingMergeTree ключ ORDER BY определяет, какие строки считаются дубликатами. В AggregatingMergeTree — это ключ агрегации.

PRIMARY KEY vs ORDER BY

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

ORDER BY (counter_id, event_type, user_id, event_id)PRIMARY KEY (counter_id, event_type)-- Разреженный индекс использует только первые две колонки-- Дедупликация и сортировка — все четыре

Используйте EXPLAIN indexes = 1, чтобы увидеть, как работает ваш индекс.

EXPLAIN indexes = 1SELECT count() FROM events WHERE counter_id = 1 AND event_type = 'purchase';-- Вывод: Granules: 42/15000-- Чтение 0.3% данных

Если видите Granules: 15000/15000, значит происходит полный скан, и нужно починить ORDER BY перед тем, как смотреть что-то ещё.

Проектирование партиций

PARTITION BY — это грубый фильтр перед тем, как запускается разреженный индекс.

Практическое правило
Держите партиции в диапазоне 1–300 ГБ. Слишком мало партиций — нет выигрыша от отсечения. Слишком много — миллионы крошечных файлов и давление на слияния. Для большинства таблиц: PARTITION BY toYYYYMM(timestamp).

Оптимизация записи

Материализованные представления — это INSERT-триггеры

Забудьте всё, что вы знали о материализованных представлениях в PostgreSQL. Материализованное представление (MV) в ClickHouse — это INSERT-триггер. Каждый раз, когда строки попадают в исходную таблицу, SELECT из MV выполняется только на этих входящих строках и пишет результат в целевую таблицу.

Никакого кэша. Никакого обновления по расписанию. Никакого планировщика. MV работает на пути записи. SELECT count(*) FROM source внутри MV вернёт размер пачки, а не размер таблицы. Агрегации будут применяться тоже только к конкретной пачке вставки. Чтобы обработать их все, нужно целиться в конечную таблицу.

Паттерн `-State` / `-Merge`

AggregatingMergeTree — самая популярная цель для MV при предагрегации, требует комбинаторов -State и -Merge.

  • Стандартные агрегатные функции (sum(), uniq()) возвращают финальные значения.
  • Варианты с -State (sumState(), uniqState()) возвращают бинарные промежуточные состояния (сериализованные аккумуляторы агрегации), которые можно объединить с состояниями из других пачек позже.
-- Целевая таблица хранит промежуточные состоянияCREATE TABLE hourly_stats (    endpoint LowCardinality(String),    hour DateTime,    request_count AggregateFunction(sum, UInt64),    unique_users AggregateFunction(uniq, UInt64)) ENGINE = AggregatingMergeTree()ORDER BY (endpoint, hour); -- MV пишет состояния, а не финальные значенияCREATE MATERIALIZED VIEW hourly_stats_mv TO hourly_stats ASSELECT    endpoint,    toStartOfHour(timestamp) AS hour,    sumState(toUInt64(1))    AS request_count,    uniqState(user_id)       AS unique_usersFROM access_logGROUP BY endpoint, hour; -- Запросы используют -Merge для получения финальных значенийSELECT    endpoint,    sumMerge(request_count),    uniqMerge(unique_users)FROM hourly_statsGROUP BY endpoint;

Во время фоновых слияний AggregatingMergeTree объединяет частичные состояния из разных пачек INSERT в более полные состояния. В конечном счёте все пачки для одного ключа (endpoint, hour) полностью сливаются. Функции -Merge финализируют вычисления уже на этапе запроса.

flowchart LR

  A["insert: 400"]
  B["insert: 600"]
  C["insert: 250"]

  D["Фоновое слияние"]

  E["Объединение состояний"]

  F["sumMerge()\n1250"]

  A-->D
  B-->D
  C-->D

  D-->E

  E-->F

SimpleAggregateFunction

Для агрегаций, которые можно вычислять инкрементально без комбинирования состояний (min, max, sum, any), SimpleAggregateFunction хранит финальное значение напрямую, а не сериализованное состояние. Дешевле хранить, быстрее читать, никакого синтаксиса -Merge.

-- SimpleAggregateFunction: хранит реальные значенияmin_latency  SimpleAggregateFunction(min, Float64),total_bytes  SimpleAggregateFunction(sum, UInt64) -- AggregateFunction: хранит бинарные состоянияunique_users AggregateFunction(uniq, UInt64)       -- HyperLogLog blobp99_latency  AggregateFunction(quantile(0.99), Float64)  -- quantile sketch

Амплификация записи

Каждое материализованное представление на исходной таблице добавляет одну операцию записи на каждый INSERT. При высоких скоростях вставки цепочки MV становятся узким местом. Заменяйте дорогие JOIN внутри MV на dictGet, котором расскажу дальше в статье.

Управление состоянием в append-only движке

ClickHouse append-only на уровне хранения. Нет UPDATE, который поменяет строку на месте, нет DELETE, который выполнится сейчас же. Любая мутация — это INSERT, а движок потом сам разбирается, что оставить. Получается, что управление состоянием вы продумываете заранее, когда описываете схему таблицы.

И выбор на самом деле тут небольшой: вы или решаете все проблемы при записи, или при чтении (FINAL или argMax).

ReplacingMergeTree

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

CREATE TABLE events (    id        String,    tenant_id UInt32,    status    LowCardinality(String),    updated_at DateTime64(6),    _version   UInt64,    _is_deleted UInt8 DEFAULT 0) ENGINE = ReplacingMergeTree(_version, _is_deleted)ORDER BY (tenant_id, id);
После вставки (v1)
abc
pending
v1
После обновления (v2)
abc
pending
v1
abc
complete
v1
SELECT без FINAL вернёт оба значения, count() будет неверным.
После слияния
abc
complete
v2
Всё закончилось. SELECT безопасен
  • ver на практике обязателен. Используйте LSN источника, таймстемп с микросекундной точностью или инкрементальный счётчик.
  • _is_deleted для CDC-удалений: добавлено в ClickHouse 23.2. Вставьте новую версию с _is_deleted = 1 и бóльшим _version.
  • FINAL: настройка do_not_merge_across​_partitions_select_final = 1 ограничивает дедупликацию сравнениями внутри одной партиции и может снизить накладные расходы в 7 раз на хорошо спроектированных таблицах.
-- Вариант 1: FINAL (проще)SELECT status FROM events FINALWHERE counter_id = 1 AND id = 'abc'; -- Вариант 2: argMax (сложнее)SELECT argMax(status, _version)FROM eventsWHERE counter_id = 1 AND id = 'abc';

CollapsingMergeTree

Работает как бухгалтерия. Строка с Sign = 1 — приход. Строка с Sign = -1 — расход. При слиянии расходы гасят приходы.

Чтобы обновить строку: вставьте отмену старого состояния (Sign = -1) и новое состояние (Sign = 1) в одном и том же INSERT. Чтению не нужен FINAL, но ваше приложение должно знать предыдущее состояние, чтобы испустить строку отмены.

Ловушка с MV и дедупликацией

Материализованные представления срабатывают на INSERT до того, как запустится слияние. Если у вас MV агрегирует данные из ReplacingMergeTree, он считает дубликаты.

Решения:

  1. Используйте ReplacingMergeTree на целевой таблице.
  2. Используйте argMaxState в MV.
  3. Читайте только из сырого источника (не из таблицы, которая будет дедуплицироваться), а дедупликацию делайте ниже по потоку.

Оптимизация чтения

Куда вкладывать усилия (в порядке возрастания эффекта):

  1. Проектирование ORDER BY и PRIMARY KEY (до 390×)
  2. Отсечение партиций (PARTITION BY)
  3. PREWHERE (автоматически + вручную, до 20× снижение ввода-вывода)
  4. Проекции и data-skipping индексы
  5. Словари вместо JOIN (~20×)
  6. Переписывание запросов и настройки (субъективно, этот пункт может стоять выше)

PREWHERE

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

ClickHouse делает PREWHERE сам в большинстве случаев, но если вы уверен в своих силах, то можете указать его вручную.

SELECT user_id, revenueFROM eventsPREWHERE status = 'error'WHERE toDate(timestamp) = '2026-06-10';

Проекции

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

CREATE TABLE web_analytics (    event_time DateTime,    user_id    UInt64,    country    LowCardinality(String),    duration   UInt32,    PROJECTION by_country (        SELECT * ORDER BY country    )) ENGINE = MergeTree()ORDER BY (user_id, event_time);

Когда запрос фильтрует по country, планировщик запросов автоматически направляет его на проекцию.

Когда могут быть полезны?

  • альтернативный порядок сортировки
  • проекции с агрегациями
  • тот же порядок сортировки, но меньшее количество колонок.

Словари вместо JOIN

JOIN в ClickHouse требует перестроить как минимум одну сторону в памяти в виде хеш-таблицы. Эта операция может быть очень дорогостоящей. Словари же загружают справочную таблицу в RAM на сервере и могут обновлять по расписанию. Поиск происходит через dictGet — это O(1) обращение к хеш-таблице в памяти.

CREATE DICTIONARY product_metadata (    sku       UInt64,    category  LowCardinality(String),    base_price Decimal(10,2)) PRIMARY KEY skuSOURCE(POSTGRESQL(    host 'pg.internal' port 5432    db 'inventory' table 'products'    user 'reader' password 'secret'    invalidate_query 'SELECT max(updated_at) FROM products'))LIFETIME(MIN 60 MAX 300)LAYOUT(HASHED()); -- Использование: O(1) поиск в памяти на каждую строкуSELECT sku, dictGet('product_metadata', 'category', sku) AS categoryFROM order_items;

На нагрузке по обогащению 10 миллиардов строк: 11 секунд с dictGet против 225 секунд с эквивалентным JOIN (в 20 раз быстрее).

Раскладки словарей.

Раскладка (layout) — это структура данных, в которой словарь хранится в RAM.

  • FLAT — плоский массив с индексацией по ключу. Поиск за субнаносекунды. Только для плотных последовательных UInt64-ключей.
  • HASHED — стандартная хеш-таблица. Работает с любым типом ключа и любой кардинальностью. Универсальный выбор по умолчанию.
  • IP_TRIE — для поиска по CIDR-диапазонам, обогащения IP-метаданными.
  • RANGE_HASHED — для измерений с привязкой ко времени, где важны окна валидности.

Обогащение на этапе вставки.

Если измерение стабильно, и вы читаете его постоянно, заплатите за обогащение один раз на INSERT и уберите его из запросов полностью. MV на сыром источнике вызывает dictGet во время вставки и пишет обогащённое значение в целевую таблицу. Джойны на этапе запроса исчезают.

Заключение

ClickHouse прощает дилетантство ровно до первого терабайта. Дальше — боль и гуглёжка в три ночи.

Хорошая новость: почти всё лечится на этапе проектирования. Три вещи, которые стоит запомнить:

  1. ORDER BY — это судьба. Он рулит чтением, дедупликацией и агрегацией. Потратьте час — сэкономите недели.
  2. MV — это INSERT-триггеры, не кэш. Забудьте PostgreSQL, иначе дубликаты обеспечены.
  3. Запись и чтение — сообщающиеся сосуды. Хотите быстрых чтений — платите на записи. Хотите простой записи — платите FINAL на чтении. Бесплатного сыра не бывает.

А если у вас уже продакшен — не переживайте. Грабли собраны, почти всё чинится без перекладывания данных.