Модуль 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. Три множителя разницы:

  1. Читается только нужная колонка — запрос трогает 2–5 колонок из 50, IO падает в десятки раз ещё до сжатия.
  2. Сжатие под тип данных: однородная колонка жмётся спец-кодеками в разы лучше «всего подряд» (4.5).
  3. Векторизованное исполнение + 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 + правило, применяемое при мерже:

sql
CREATE 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): строки с одинаковым ключом сортировки схлопываются при мерже, остаётся строка с максимальной версией:

sql
CREATE TABLE users_ch
(
    user_id    UInt64,
    email      String,
    updated_at DateTime
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY user_id;

⚠️ Главная ловушка курса. ReplacingMergeTree не удаляет дубли сразу: вставили строку дважды — на диске лежат обе, пока фоновый мерж их не встретит, а мерж может не случаться неделями. Чтение — с дедупликацией: FINAL (просто, но докатывает мерж на лету — заметно медленнее на больших данных) или шаблон через argMax:

sql
SELECT * 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 так считать нельзя — для средних и перцентилей следующий движок:

sql
CREATE TABLE clicks_sum
(
    date   Date,
    site   String,
    views  UInt32,
    clicks UInt32
)
ENGINE = SummingMergeTree
ORDER BY (date, site);

AggregatingMergeTree: состояния агрегатов

Колонки хранят промежуточные состояния агрегатных функций; мерж объединяет состояния, чтение финализирует комбинатором -Merge. Фундамент витрин курса:

sql
CREATE 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) учитывает версию — порядок доставки больше не критичен:

sql
CREATE 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-таблицы из CDCFINAL / argMax
SummingMergeTreeсуммирует числовые колонкипростые счётчикидосчёт GROUP BY
AggregatingMergeTreeобъединяет состояния агрегатоввитрины uniq/quantile/avg-Merge комбинаторы
CollapsingMergeTreeсхлопывает пары +1/-1UPDATE-поток в порядке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-индексы: по каждой грануле считается сводка, и если фильтр заведомо не матчится — гранула пропускается целиком, не распаковываясь.

sql
ALTER 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-колонке и работает поверх движка хранения:

sql
CREATE 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малые равномерные дельтыметки времени, счётчики
GorillaXOR соседних 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 выполняется над вставленным блоком, а результат дописывается в целевую таблицу.

sql
CREATE 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 (каждая — чей-то продакшен-инцидент)

  1. MV видит только новые вставки. Бэкфилл истории — руками: INSERT INTO events_minute SELECT ... FROM events GROUP BY ... (тот же SELECT, что в MV).
  2. Старый синтаксис с POPULATE — гонка: данные, вставленные во время populate, теряются. Не используйте; TO target + бэкфилл честнее.
  3. MV не следит за изменениями: новые версии строк (ReplacingMergeTree) в агрегат не «доначислятся» — это решается в Модулях 6–7 (версии, Collapsing, пересборка витрины из raw-слоя).
  4. MV выполняется там, куда легла вставка: на кластере — per-шард; топология и дедупликация учитываются заранее (Модуль 5).
  5. Выключили MV на час — в витрине дырка. Монитори́м пайплайн: сверка sum() витрины и raw-слоя (Модуль 6.5).

📖 4.7. Словари и JOIN'ы

Словари: dimension-lookup без джойна

Словарь — «справочник, поднятый в память» с автообновлением по LIFETIME. Источники: таблица ClickHouse, PostgreSQL, HTTP, файл:

sql
CREATE 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КлючиКогда
FLATUInt64, массив в памятидо ~500K ключей, быстрее всех
HASHEDUInt64, хэш-таблица в RAMмиллионы ключей
COMPLEX_KEY_HASHEDсоставной/строковыйключ не UInt64
CACHE(size)любой, LRU-кэш + промах в источникгигантские справочники

LIFETIME(MIN 300 MAX 600) — перечитывание раз в 5–10 минут (интервал размывает шторм перезагрузок). Использование — функцией, без JOIN:

sql
SELECT 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 — джойн «на момент времени»: к каждой строке левой таблицы подбирается последняя строка правой с тем же ключом и временем не позже:

sql
SELECT 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-таблицу:

sql
CREATE 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-движок + MVGo-синк (Модуль 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; прикладным командам обычно не нужен. Пул соединений встроенный:

go
func 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»:

go
func 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.

💻 Практика

  1. 100 млн событий за минуты. Создайте events из 4.2. Источник — движок-генератор GenerateRandom (не хранит данные, только выдаёт случайные строки):

    sql
    CREATE TABLE events_gen
    (event_id UInt64, event_type String, amount Decimal(12, 2))
    ENGINE = GenerateRandom(42, 8, 2);

    Залейте 100 млн строк, разложив по последним 90 дням, и зафиксируйте время и размер (system.parts):

    sql
    INSERT 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);
  2. Go-генератор. Повторите загрузку программой: батчи по 50 000 строк через PrepareBatch/Append/Send (4.9). Сравните скорость и память с SQL-вариантом; затем батч 1 000 строк и построчный INSERT — почувствуйте деградацию через system.parts.

  3. Витрина через MV. Постройте events_minute + MV из 4.6, сделайте бэкфилл по 100 млн событий, сравните время дашборд-запроса по raw и по витрине. Сверьте sum(events) витрины и count() raw за день — расхождение найдёте, если забыли бэкфилл.

  4. Сжатие и skip-индексы. Замерьте размер колонки event_time с CODEC(DoubleDelta, ZSTD(1)) и без (system.columns, размер частей). Добавьте bloom_filter по user_id, выполните MATERIALIZE INDEX и покажите через EXPLAIN indexes = 1, сколько гранул отсекается.

🧩 Quiz "Проверь себя"

1. Какие приёмы дают производительную вставку из Go на clickhouse-go v2? — выберите все подходящие
2. В запросе к Distributed-таблице используется обычный JOIN с большой правой таблицей. Почему рекомендуют GLOBAL JOIN?
3. Какие утверждения о материализованных представлениях ClickHouse верны? — выберите все подходящие
4. Чем является ORDER BY в движке MergeTree?
5. По колонке email нужно много точечных фильтров = '...', в ключ сортировки она не входит. Как избежать чтения всех гранул?
6. Таблица на SummingMergeTree. Вы выполнили SELECT сразу после вставки строк, до каких-либо мержей. Что вернёт запрос?
7. Что произойдёт при INSERT в таблицу events, на которую смотрит материализованное представление (MV TO events_minute)?
8. Что плохого в партиционировании большой таблицы по минутам: PARTITION BY toStartOfMinute(ts)?
9. Что происходит на диске при каждом INSERT в таблицу MergeTree?
10. Как правильно читать ReplacingMergeTree на больших данных, чтобы не видеть дубли?
11. Что означает правило TTL event_time + INTERVAL 7 DAY TO DISK "cold_ssd" в паре с политикой хранения?
12. CDC-события по одной строке приходят в порядке: INSERT, UPDATE, INSERT — но иногда доставка переворачивает порядок двух событий. Какой движок корректно свернёт историю?
13. Вы вставили дубли в ReplacingMergeTree и подождали час, но SELECT count() всё ещё показывает дубли. Почему?
14. К каждой сделке нужно приджойнить последнюю цену тикера, не позднее времени сделки. Какой инструмент для этого есть в ClickHouse?
15. Для какой колонки уместен кодек DoubleDelta?

Разбор ключевых вопросов

  • Дубли в 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/avgAggregatingMergeTree + 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 и уроним реплику под нагрузкой.