Модуль 4. ClickHouse: столбцовый движок изнутри
Цель модуля: понять ClickHouse на уровне «почему быстро» и «почему
ломается»: parts и мержи, выбор движка MergeTree-семейства, почему ORDER BY
— главный индекс, а мышление «B-tree на каждую колонку» ведёт к full scan.
В финале — 100 млн событий, витрина через MV и батч-вставка на clickhouse-go.
Перед началом: Модуль 1 (OLTP vs OLAP), уверенный SQL, базовый Go. Инструменты: docker-compose стека курса (профиль
ch),clickhouse-client, Go 1.22+,github.com/ClickHouse/clickhouse-go/v2. Трудоёмкость: ~10–12 ч (теория 5–6 ч · практика 4 ч · квиз 1 ч).
🔥 Инцидент: дашборд, который читает два миллиарда строк
Аналитику OrderFlow перевезли в ClickHouse: 2 млрд событий заказов и платежей, column-oriented, обещали «в сотни раз быстрее». И первое разочарование: отчёт «выручка за день» отрабатывает 90 секунд — хуже, чем было на PostgreSQL. Аналитики уже шутят, что переезд был оптимизацией наоборот.
Сначала грешили на железо — мол, мало памяти и диска. Но EXPLAIN indexes = 1
показал другое: PrimaryKey Granules отсекает копейки, движок перебирает все
парты и вычитывает колонки, которые в отчёте вообще не нужны. Причина — таблицу
создали с ORDER BY (order_id): по привычке «в ключ — то, по чему ищем точку».
Но все отчёты фильтруют по event_time, и ключ им не помогает; точечные поиски
по одному заказу, ради которых его выбирали, всё равно обслуживает PostgreSQL.
После смены ключа на (event_time, order_id) тот же отчёт возвращается меньше
чем за секунду: данные на диске уже лежат в нужном порядке. В ClickHouse индекс
не «добавляют» к данным, а решают, в каком порядке их хранить, — и это решение
принимают под конкретные запросы.
Модуль разбирает движок изнутри: почему column-oriented читает в разы меньше,
как parts и мержи формируют данные, как ORDER BY и партиции отсекают гранулы,
и когда выручают skip-индексы.
📖 4.1. Архитектура: column-oriented, SIMD, parts, merges
Почему агрегат быстрее в 100 раз
PostgreSQL хранит строки построчно: для sum(amount) по миллиарду строк он
прочитает все колонки всех строк. ClickHouse хранит каждую колонку
отдельным файлом, сжатым под её тип данных, — и для sum(amount) читает
только amount.bin. Три множителя разницы:
- Читается только нужная колонка — запрос трогает 2–5 колонок из 50, IO падает в десятки раз ещё до сжатия.
- Сжатие под тип данных: однородная колонка жмётся спец-кодеками в разы лучше «всего подряд» (4.5).
- Векторизованное исполнение + SIMD: данные обрабатываются блоками (~65 тыс. строк), операции — над массивами: несколько значений на инструкцию, плотный кэш, параллелизм по ядрам.
Обратная сторона: точечный UPDATE, поиск «заказа 42», транзакции — работа PostgreSQL. ClickHouse спроектирован под insert-only + тяжёлые агрегаты; на OLTP-профиле он беспомощен — не недоработка, а осознанный размен.
Parts: каждая вставка — новая часть
- INSERT создаёт part (часть данных): блок сортируется по ключу, колонки
сжимаются в свои файлы — на диске директория
all_202606_1_1_0(партиция, диапазон блоков, уровень мержа). - Фоновые мержи склеивают мелкие части в крупные (аналог LSM-tree), применяя логику движка — дедупликацию ReplacingMergeTree, суммирование SummingMergeTree и т.д. (4.2).
- Слишком много частей — плохо: метаданные каждой части читает каждый
SELECT. Отсюда классическая ошибка
Too many parts (300)и правило продакшена: редкие крупные вставки — батчами от десятков тысяч строк, ~1 вставка в секунду на таблицу. Диагностика деградации —system.parts(число частей по партициям) иsystem.merges(очередь мержей).
Мутации (ALTER TABLE ... UPDATE/DELETE) — не исключение: они помечают
части к переписыванию фоновыми запросами и конкурируют за IO с мержами.
В пайплайнах курса изменения — новые строки (4.2), ретеншен — партиции
(4.3, 4.5); эксплуатация мутаций — Модуль 8.
📖 4.2. MergeTree-семейство: когда какое
Все движки семейства — MergeTree + правило, применяемое при мерже:
sqlCREATE TABLE events ( event_id UInt64, event_time DateTime, user_id UInt64, event_type LowCardinality(String), amount Decimal(12, 2) ) ENGINE = MergeTree PARTITION BY toYYYYMM(event_time) ORDER BY (event_type, event_time, user_id);
Правило чтения всего семейства: уплотнение происходит в фоновых мержах, то есть частично — «схлопнутых» данных вы гарантированно не увидите никогда. Запрос обязан уметь доделать работу сам; частичный мерж — норма, а не баг.
ReplacingMergeTree: «последняя версия строки»
Выбор для CDC-сценария (Модуль 6): строки с одинаковым ключом сортировки схлопываются при мерже, остаётся строка с максимальной версией:
sqlCREATE TABLE users_ch ( user_id UInt64, email String, updated_at DateTime ) ENGINE = ReplacingMergeTree(updated_at) ORDER BY user_id;
⚠️ Главная ловушка курса. ReplacingMergeTree не удаляет дубли сразу:
вставили строку дважды — на диске лежат обе, пока фоновый мерж их не встретит,
а мерж может не случаться неделями. Чтение — с дедупликацией: FINAL (просто,
но докатывает мерж на лету — заметно медленнее на больших данных) или шаблон
через argMax:
sqlSELECT * FROM users_ch FINAL WHERE user_id = 42; SELECT user_id, argMax(email, updated_at) AS email, max(updated_at) AS updated_at FROM users_ch WHERE user_id = 42 GROUP BY user_id;
«Дубль» определяется ключом сортировки: изменили ORDER BY — изменилось и то,
что считается дублем.
SummingMergeTree: дешёвые счётчики
Числовые колонки не из ключа суммируются при мерже — частично. SELECT *
вернёт и свёрнутые, и исходные строки, запрос обязан досчитать sum(...) с
GROUP BY. Сумма «безопасна» (ассоциативна), а avg так считать нельзя —
для средних и перцентилей следующий движок:
sqlCREATE TABLE clicks_sum ( date Date, site String, views UInt32, clicks UInt32 ) ENGINE = SummingMergeTree ORDER BY (date, site);
AggregatingMergeTree: состояния агрегатов
Колонки хранят промежуточные состояния агрегатных функций; мерж объединяет
состояния, чтение финализирует комбинатором -Merge. Фундамент витрин курса:
sqlCREATE TABLE visits_agg ( minute DateTime, country LowCardinality(String), users AggregateFunction(uniq, UInt64), revenue SimpleAggregateFunction(sum, Decimal64(2)) ) ENGINE = AggregatingMergeTree ORDER BY (country, minute);
Пишем uniqState(user_id), читаем uniqMerge(users);
SimpleAggregateFunction(sum, ...) хранит само значение (удобно в MV — 4.6).
Развитие темы — Модуль 7 (MV-агрегаты, combinator-функции).
Collapsing и VersionedCollapsing: UPDATE как пара +1/-1
CollapsingMergeTree(sign) требует колонку-знак Int8: изменение строки —
две вставки, старая со sign = -1 и новая со sign = +1; при мерже пары
с одинаковым ключом схлопываются. Проблема: события, пришедшие не по
порядку (-1 раньше +1), парой не встретятся и обе строки останутся.
VersionedCollapsingMergeTree(sign, version) учитывает версию — порядок
доставки больше не критичен:
sqlCREATE TABLE orders_collapsed ( order_id UInt64, status String, amount Decimal(12, 2), sign Int8, version UInt64 ) ENGINE = VersionedCollapsingMergeTree(sign, version) ORDER BY order_id;
Чтение — тоже с досчётом: sum(sign) ... HAVING sum(sign) > 0.
Шпаргалка выбора
| Движок | Правило при мерже | Когда брать | Как читать |
|---|---|---|---|
| MergeTree | ничего не схлопывает | сырые события, логи | напрямую |
| ReplacingMergeTree(ver) | оставляет max ver по ключу | копия OLTP-таблицы из CDC | FINAL / argMax |
| SummingMergeTree | суммирует числовые колонки | простые счётчики | досчёт GROUP BY |
| AggregatingMergeTree | объединяет состояния агрегатов | витрины uniq/quantile/avg | -Merge комбинаторы |
| CollapsingMergeTree | схлопывает пары +1/-1 | UPDATE-поток в порядке | sum(sign) HAVING |
| VersionedCollapsing | то же + версии | UPDATE-поток без порядка | sum(sign) HAVING |
📖 4.3. Ключ сортировки и партиции: ORDER BY как главный индекс
ORDER BY — это и есть индекс
В PostgreSQL индекс — дополнительная структура (B-tree) поверх кучи строк.
В MergeTree индекс встроен в хранение: данные физически отсортированы по
ключу, поверх — разреженный индекс: одна запись на гранулу из
index_granularity строк (по умолчанию 8192). Индекс крошечный и живёт
в памяти, по mark-файлам указывает на смещения в сжатых колонках. Отсюда:
- Фильтр по префиксу ключа отсекает гранулы:
WHERE event_type = ... AND event_time >= ...при ключе(event_type, event_time, user_id)прочитает единицы гранул вместо тысяч. Порядок колонок = какие фильтры работают (ORDER BY (event_time, tenant_id)— обратный профиль). - Функции над ключом должны быть монотонными:
toYYYYMM(event_time) = 202606работает;cityHash64(user_id) = 123— индекс мёртв, полный скан. - Других индексов нет: фильтр по колонке вне ключа читает все гранулы (если нет skip-индекса — 4.4). «Добавить индекс на user_id» нельзя — можно включить его в ключ.
Как выбирать ключ под запросы
Колонки равенств из типовых фильтров — первыми (tenant_id, event_type);
колонка диапазона (почти всегда время) — последней: range-отсечение внутри
префикса работает с двух сторон; высококардинальный идентификатор — в конец
или не в ключ: он «размазывает» данные, и остальные префиксы перестают
отсекать. Оба варианта ORDER BY (tenant_id, event_type, event_time) и
ORDER BY (event_time, tenant_id) правильные — под разные запросы.
Ключ меняется только пересозданием таблицы (CREATE new → INSERT SELECT → RENAME) или projection (узкий инструмент) — выбирайте по топу реальных
запросов, а не по привычке.
Партиции: unit ретеншена, не тюнинг запросов
PARTITION BY режет таблицу на директории и даёт: partition pruning (фильтр
по колонке партиционирования исключает директории целиком) и дешёвое
управление жизнью — DROP PARTITION '202605', TTL целыми партициями (4.5),
MOVE PARTITION между таблицами. Чего не даёт — ускорения «само по себе»:
каждая вставка создаёт часть в своей партиции, тысяча партиций — тысяча
источников мелких частей, «too many parts» и просевшие мержи. Рабочие
варианты: toYYYYMM(event_time) для больших таблиц, toDate(event_time)
или без партиционирования для средних; мельче дня — осознанная экзотика.
📖 4.4. Skip-индексы: когда B-tree-мышление даёт full scan
Фильтр по колонке вне ключа сортировки читает все гранулы — для большой таблицы с избирательным фильтром это расточительство. Спасение — skip-индексы: по каждой грануле считается сводка, и если фильтр заведомо не матчится — гранула пропускается целиком, не распаковываясь.
sqlALTER TABLE events ADD INDEX idx_user user_id TYPE bloom_filter(0.01) GRANULARITY 1; ALTER TABLE events ADD INDEX idx_amount amount TYPE minmax GRANULARITY 1; -- применить к существующим данным (это мутация — переписывание частей): ALTER TABLE events MATERIALIZE INDEX idx_user SETTINGS mutations_sync = 1;
Эффективность видна в плане: EXPLAIN indexes = 1 покажет
PrimaryKey Granules: 100/1024 (отсёк ключом) и ниже idx_user Granules: 3/100 (отсёк skip-индексом) — читаются 3 гранулы, не 1024.
| Тип | Что проверяет | Подходит для |
|---|---|---|
minmax | диапазон значений гранулы | числа/даты: >=, BETWEEN |
set(N) | множество значений гранулы | = по низкой кардинальности |
bloom_filter(p) | «значения здесь точно нет» | =/IN по высокой кардинальности: id, email |
tokenbf_v1 / ngrambf_v1 | блум по токенам/n-граммам | LIKE '%...%', hasToken по тексту |
Trade-offs честно: bloom_filter даёт ложные срабатывания (порог p — цена
между пропуском и чтением гранул); GRANULARITY мельче — точнее отсечение,
но больше метаданных; если фильтр матчит большинство гранул, skip-индекс не
поможет — честнее включить колонку в ключ. И главное: skip-индекс — не
порядок данных, а «не читать заведомо пустое»; это совсем другой примитив,
чем B-tree.
📖 4.5. Кодеки и TTL: сжатие под данные, жизнь данных по расписанию
Кодек указывается per-колонке и работает поверх движка хранения:
sqlCREATE TABLE metrics ( ts DateTime CODEC(DoubleDelta, ZSTD(1)), host LowCardinality(String), cpu Float32 CODEC(Gorilla, LZ4), counter UInt64 CODEC(T64, ZSTD(3)) ) ENGINE = MergeTree ORDER BY (host, ts);
| Кодек | На чём выигрывает | Типовые колонки |
|---|---|---|
DoubleDelta | малые равномерные дельты | метки времени, счётчики |
Gorilla | XOR соседних float почти нулевой | метрики CPU/RAM, датчики |
T64 | узкий разброс целых в блоке | int-колонки |
ZSTD(n) / LZ4 | универсальное сжатие поверх спец-кодека | всё; ZSTD сильнее, LZ4 быстрее |
Практика без магии: время — DoubleDelta, ZSTD(1), float-метрики — Gorilla,
остальное — мерить на своих данных (system.columns, размер частей до/после):
выигрыш бывает и 2, и 10 раз. Кодеки почти бесплатны на записи — настолько,
насколько запись батчевая, а она у вас батчевая (4.9).
TTL: жизнь строк и колонок
sql-- ретеншен: через 90 дней удалить ALTER TABLE events MODIFY TTL event_time + INTERVAL 90 DAY DELETE; -- tiered storage: через 7 дней пережать на дешёвый том, через 90 — удалить ALTER TABLE events MODIFY TTL event_time + INTERVAL 7 DAY TO DISK "cold_ssd", event_time + INTERVAL 90 DAY DELETE;
Tiered storage настраивается политикой хранения (named volumes ssd/hdd/
S3-диск) и переносит остывшие части на дешёвое хранилище. Два практических
правила: SETTINGS ttl_only_drop_parts = 1 — удалять только целые части,
когда истёк срок всех их строк (мгновенный unlink вместо перезаписи;
лучше всего работает, когда TTL-гранулярность не мельче партиции); изменение
TTL не трогает старые данные само — применить к записанному поможет
ALTER TABLE ... MATERIALIZE TTL (снова мутация).
📖 4.6. Материализованные представления: инкрементальность и ловушки
MV — это триггер на INSERT, а не «обновляемый view»
Главная семантическая ловушка для знатока PostgreSQL: ClickHouse MV не пересчитывает запрос по расписанию и не следит за данными. Это триггер: при каждой вставке в исходную таблицу SELECT из MV выполняется над вставленным блоком, а результат дописывается в целевую таблицу.
sqlCREATE TABLE events_minute ( minute DateTime, event_type LowCardinality(String), events SimpleAggregateFunction(sum, UInt64), revenue SimpleAggregateFunction(sum, Decimal64(2)), users AggregateFunction(uniq, UInt64) ) ENGINE = AggregatingMergeTree ORDER BY (event_type, minute); CREATE MATERIALIZED VIEW events_minute_mv TO events_minute AS SELECT toStartOfMinute(event_time) AS minute, event_type, count() AS events, sum(amount) AS revenue, uniqState(user_id) AS users FROM events GROUP BY minute, event_type; -- чтение: состояния агрегатов финализируем -Merge SELECT minute, sum(events) AS events, sum(revenue) AS revenue, uniqMerge(users) AS users FROM events_minute WHERE minute >= now() - INTERVAL 1 DAY GROUP BY minute ORDER BY minute;
Агрегат считается инкрементально: запрос по миллиарду сырых событий заменён запросом по тысячам минутных строк, витрина обновляется с лагом вставки, без «ночных пересчётов».
Ловушки MV (каждая — чей-то продакшен-инцидент)
- MV видит только новые вставки. Бэкфилл истории — руками:
INSERT INTO events_minute SELECT ... FROM events GROUP BY ...(тот же SELECT, что в MV). - Старый синтаксис с
POPULATE— гонка: данные, вставленные во время populate, теряются. Не используйте;TO target+ бэкфилл честнее. - MV не следит за изменениями: новые версии строк (ReplacingMergeTree) в агрегат не «доначислятся» — это решается в Модулях 6–7 (версии, Collapsing, пересборка витрины из raw-слоя).
- MV выполняется там, куда легла вставка: на кластере — per-шард; топология и дедупликация учитываются заранее (Модуль 5).
- Выключили MV на час — в витрине дырка. Монитори́м пайплайн: сверка
sum()витрины и raw-слоя (Модуль 6.5).
📖 4.7. Словари и JOIN'ы
Словари: dimension-lookup без джойна
Словарь — «справочник, поднятый в память» с автообновлением по LIFETIME. Источники: таблица ClickHouse, PostgreSQL, HTTP, файл:
sqlCREATE DICTIONARY dim_users ( user_id UInt64, email String, country LowCardinality(String) ) PRIMARY KEY user_id SOURCE(CLICKHOUSE(TABLE 'users_ch')) LIFETIME(MIN 300 MAX 600) LAYOUT(HASHED());
| Layout | Ключи | Когда |
|---|---|---|
FLAT | UInt64, массив в памяти | до ~500K ключей, быстрее всех |
HASHED | UInt64, хэш-таблица в RAM | миллионы ключей |
COMPLEX_KEY_HASHED | составной/строковый | ключ не UInt64 |
CACHE(size) | любой, LRU-кэш + промах в источник | гигантские справочники |
LIFETIME(MIN 300 MAX 600) — перечитывание раз в 5–10 минут (интервал
размывает шторм перезагрузок). Использование — функцией, без JOIN:
sqlSELECT dictGet('dim_users', 'country', user_id) AS country, uniq(user_id) AS users, sum(amount) AS revenue FROM events WHERE event_type = 'purchase' GROUP BY country;
Для «обогащения события справочником» словарь обычно быстрее и предсказуемее JOIN: фиксированная память, нет риска удушить запрос правой таблицей.
JOIN'ы: правая таблица живёт в памяти
Классический JOIN в ClickHouse — hash join: правая таблица целиком
собирается в памяти, левая стримится. Правой должна быть меньшая таблица;
миллиардная правая — не «медленно», а падение по max_memory_usage.
В распределённом запросе (Модуль 5) обычный JOIN отправит правую таблицу
на каждый шард (N копий по сети); GLOBAL JOIN выполнит правую часть
один раз на инициаторе и разошлёт шардам готовый результат — дешевле по сети,
дороже по памяти инициатора.
ASOF JOIN — джойн «на момент времени»: к каждой строке левой таблицы подбирается последняя строка правой с тем же ключом и временем не позже:
sqlSELECT t.symbol, t.ts, t.qty, p.price FROM trades t ASOF LEFT JOIN prices p ON t.symbol = p.symbol AND t.ts >= p.ts;
Вердикт курса: в горячем пути аналитики джойнов стараемся не рождать — денормализация витрин (Модуль 7), справочники — в словари, «последнее значение на момент» — ASOF. JOIN остаётся инструментом ад-хок разборов.
📖 4.8. Движковые таблицы Kafka: consumer из коробки
ClickHouse умеет сам быть Kafka-консьюмером: таблица с движком Kafka —
подписка на топик, MV переливает данные в целевую MergeTree-таблицу:
sqlCREATE TABLE events_kafka ( order_id String, user_id UInt64, amount Decimal(12, 2), event_time DateTime ) ENGINE = Kafka SETTINGS kafka_broker_list = 'kafka:9092', kafka_topic_list = 'orders.events', kafka_group_name = 'ch_events', kafka_format = 'JSONEachRow', kafka_num_consumers = 4; CREATE MATERIALIZED VIEW events_mv TO events AS SELECT order_id, user_id, amount, event_time FROM events_kafka;
Оффсет фиксируется после успешной вставки — семантика at-least-once: падение между вставкой и коммитом возвращает блок повторно.
| Kafka-движок + MV | Go-синк (Модуль 6) | |
|---|---|---|
| Инфраструктура | ноль: внутри CH | отдельный сервис: деплой, мониторинг |
| Битые сообщения | kafka_skip_broken_messages (пропустить = потерять) | свой DLQ, ретраи, алерты |
| Наблюдаемость | логи CH, скудные метрики | Prometheus, лаг группы, свои счётчики |
| Трансформации | только SQL в MV | любой Go-код: обогащение, роутинг, тесты |
| Backfill/реструктуризация | болезненные | оффсеты, replay, dry-run под контролем |
Вердикт: Kafka-движок прекрасен для прототипа и «дашбордов на вчера», когда
поток простой, а потеря одного сообщения не инцидент. Продакшен-CDC строим на
Go-синке — не потому что «свой код лучше», а из-за честных ретраев, DLQ,
метрик и тестов; полное сравнение — Модуль 6 на живой цепочке Debezium.
Рядом живут движки RabbitMQ, URL, PostgreSQL/MySQL (федерация — для
точечных запросов, не пайплайнов).
📖 4.9. Клиенты из Go: clickhouse-go v2
Подключение и пул
Стандартный выбор — clickhouse-go v2 (нативный протокол :9000). На нём же
построен низкоуровневый ch-go — протокольная библиотека для экстремальной
производительности и внутренностей самого ClickHouse; прикладным командам
обычно не нужен. Пул соединений встроенный:
gofunc openCH() (driver.Conn, error) { return clickhouse.Open(&clickhouse.Options{ Addr: []string{"localhost:9000"}, DialTimeout: 5 * time.Second, ConnMaxLifetime: time.Hour, MaxOpenConns: 8, // пул: соединения переиспользуются MaxIdleConns: 4, Compression: &clickhouse.Compression{Method: clickhouse.CompressionLZ4}, Auth: clickhouse.Auth{Database: "default", Username: "default"}, }) }
database/sql-драйвер есть (sql.Open("clickhouse", dsn)), но для вставок
нативный API с батчами заметно производительнее.
Батч-вставка: единственный правильный способ писать
Помним 4.1: каждая вставка — новая часть. Построчный INSERT из Go — прямой
путь к «too many parts»:
gofunc insertEvents(ctx context.Context, conn driver.Conn, events []Event) error { batch, err := conn.PrepareBatch(ctx, "INSERT INTO events (event_id, event_time, user_id, event_type, amount)") if err != nil { return err } for _, e := range events { if err := batch.Append(e.ID, e.Time, e.UserID, e.Type, e.Amount); err != nil { _ = batch.Abort() return err } } return batch.Send() // один батч = одна part на сервере }
Правила продакшена:
- Батч от 10K до 1M строк, слив по размеру или таймеру — иначе при «тихом» потоке события лежат в памяти вечно.
- Ретрай — исходным батчем. На одиночном MergeTree повтор останется дублем; настоящая идемпотентность вставок появляется в кластере — Модуль 5.
- Мелких клиентов, которых нельзя научить батчить, спасает серверный
async_insert— место и цена этого инструмента в Модуле 5.4.
💻 Практика
-
100 млн событий за минуты. Создайте
eventsиз 4.2. Источник — движок-генераторGenerateRandom(не хранит данные, только выдаёт случайные строки):sqlCREATE TABLE events_gen (event_id UInt64, event_type String, amount Decimal(12, 2)) ENGINE = GenerateRandom(42, 8, 2);Залейте 100 млн строк, разложив по последним 90 дням, и зафиксируйте время и размер (
system.parts):sqlINSERT INTO events SELECT number AS event_id, now() - toIntervalSecond(number % 7776000) AS event_time, cityHash64(toString(number)) % 1000000 AS user_id, ['view', 'cart', 'purchase', 'refund'][1 + number % 4] AS event_type, toDecimal32(1 + number % 50000, 2) AS amount FROM numbers(100000000); -
Go-генератор. Повторите загрузку программой: батчи по 50 000 строк через
PrepareBatch/Append/Send(4.9). Сравните скорость и память с SQL-вариантом; затем батч 1 000 строк и построчный INSERT — почувствуйте деградацию черезsystem.parts. -
Витрина через MV. Постройте
events_minute+ MV из 4.6, сделайте бэкфилл по 100 млн событий, сравните время дашборд-запроса по raw и по витрине. Сверьтеsum(events)витрины иcount()raw за день — расхождение найдёте, если забыли бэкфилл. -
Сжатие и skip-индексы. Замерьте размер колонки
event_timeсCODEC(DoubleDelta, ZSTD(1))и без (system.columns, размер частей). Добавьтеbloom_filterпоuser_id, выполнитеMATERIALIZE INDEXи покажите черезEXPLAIN indexes = 1, сколько гранул отсекается.
🧩 Quiz "Проверь себя"
Разбор ключевых вопросов
- Дубли в ReplacingMergeTree (Q3–Q4). Replacing — не «уникальный индекс»,
а правило фоновых мержей: пока части с дублями не встретились в одном мерже,
обе строки видны. Гарантия на чтение —
FINALилиargMaxпо версии;OPTIMIZE TABLE ... FINAL— тяжёлая ручная операция, не решение для запросов. - Схлопывающие движки читаются с досчётом (Q2, Q7). Мерж не обязан был
случиться:
sum()поверх Summing,argMax/FINALповерх Replacing,sum(sign) HAVINGповерх Collapsing. ORDER BY при этом — разреженный индекс на 8192-строчные гранулы, живущий в памяти; всё, что не в ключе, индексом не является. - GLOBAL JOIN и MV (Q12–Q14). Обычный распределённый JOIN = правая таблица × число шардов по сети; GLOBAL считает её один раз, но в памяти инициатора — настоящие решения: денормализация и словари. MV — триггер на блок вставки: отсюда бэкфилл руками, дыры при выключенном MV и нулевая реакция на изменения строк; витрину постоянно сверяем с raw-слоем (Модуль 6).
✅ Итоги модуля
- Объяснить, из чего складывается 100x: колонки, кодеки, векторизация, параллелизм — и почему ClickHouse не OLTP.
- Описать lifecycle части: INSERT → part → мержи; диагностировать «too many parts» через system.parts/system.merges.
- Выбрать движок MergeTree-семейства и правильно читать данные каждого (FINAL/argMax, -Merge, sum(sign)).
- Спроектировать ключ сортировки под фильтры и осознанно выбрать партиционирование (день/месяц).
- Добавить skip-индекс и доказать пользу через EXPLAIN indexes = 1.
- Подобрать кодеки и настроить TTL: ретеншен и tiered storage с ttl_only_drop_parts.
- Построить витрину на AggregatingMergeTree + MV, сделать бэкфилл, перечислить ловушки MV.
- Обогащать данные словарями и ASOF JOIN; писать из Go батчами через clickhouse-go v2.
Шпаргалка модуля
| Сценарий | Решение |
|---|---|
| Сырые события, логи | MergeTree, батчевая вставка |
| Копия OLTP-таблицы (CDC) | ReplacingMergeTree(version), читать argMax/FINAL |
| Простые счётчики | SummingMergeTree + досчёт GROUP BY |
| Витрина uniq/quantile/avg | AggregatingMergeTree + MV + -Merge |
| UPDATE-поток извне | VersionedCollapsingMergeTree(sign, version) |
| Фильтр по колонке вне ключа | skip-индекс: bloom_filter (равенство), minmax (диапазон) |
| Монотонные/float-колонки | CODEC: DoubleDelta / Gorilla + ZSTD |
| Ретеншен и холодные данные | TTL ... DELETE / TO DISK + ttl_only_drop_parts |
| Инкрементальный агрегат | MV TO target (триггер на блок) + ручной бэкфилл |
| Обогащение справочником | CREATE DICTIONARY (hashed/complex_key) + dictGet |
| Последнее значение на момент | ASOF JOIN ... AND t.ts >= p.ts |
| Мелкие клиенты-вставляторы | клиентский батчинг; иначе async_insert (Модуль 5) |
Запахи-индикаторы: построчный INSERT → батч 10K–1M строк; Too many parts →
реже и крупнее вставлять, смотреть system.merges; FINAL в каждом запросе →
argMax-шаблон; витрина «врёт» → забыли бэкфилл или MV был выключен.
🔗 Мостик к Модулю 5
Мы научились писать и читать быстро — но всё это был один узел, и вставка, которая «иногда даёт дубль», терпима на одиночной таблице и фатальна в кластере. Модуль 5 добавляет два измерения: ReplicatedMergeTree + ClickHouse Keeper (репликация, кворум и — сюрприз — настоящая идемпотентность вставок через дедупликацию блоков) и Distributed-таблицы (шардирование по хэшу, local vs distributed, топология 2×2). Соберём кластер 2×2 в docker compose, вставим через Distributed и уроним реплику под нагрузкой.