Модуль 5. ClickHouse: кластер и распределённые таблицы
Цель модуля: вынести ClickHouse за пределы одного узла без потери
здравого смысла: что координирует ClickHouse Keeper и почему репликация здесь
табличная; как писать и читать через ReplicatedMergeTree + Distributed;
как выбрать топологию — включая честное «вам хватит одного шарда с репликой»;
как победить «too many parts» батчингом и async_insert. Финал — кластер 2×2
в docker compose и отказ реплики под нагрузкой.
Перед началом: Модуль 4 (MergeTree-семейство, parts и мержи, батч-вставка). Инструменты: docker compose,
clickhouse-client,clickhouse-gov2. Трудоёмкость: ~8–10 ч (теория 4 ч · лабораторная 4 ч · квиз 0.5 ч).
🔥 Инцидент: аналитика лежала шесть часов
Ночь на четверг: в 02:40 умирает единственный узел ClickHouse без реплик — вся
аналитика OrderFlow на нём. Диск отдаёт ошибки, сервис уходит в read-only и не
поднимается. Утром дашборды выручки и операторов встречают людей ошибкой
Connection refused, в поддержке — четыре десятка заявок «где цифры за вчера».
Восстановление из бэкапа заняло шесть часов: сам рестор — около часа,
остальное — поиск нужного дампа, согласования и дозаливка потерянных данных. Всё
это время отчётность жила на ручных выгрузках из PostgreSQL: psql, CSV,
Excel.
Соседний PostgreSQL ту же ночь пережил спокойно: мастер упал, Patroni переключил нагрузку на реплику, простой — меньше минуты. Разница простая: у PG реплика была частью архитектуры с первого дня, а ClickHouse мы поднимали как «одну базу на одном сервере» и всерьёз об отказоустойчивости не думали.
Первая реакция — ускорить восстановление: автоматизировать рестор, докупить диск, повесить алерт на падение узла. Это сократило бы RTO, но не убрало причину: аналитика всё ещё одна, и следующая ночь повторит сценарий. Не хватало понимания, что именно реплицируется в ClickHouse и кто этим управляет.
Дальше предстоит разобрать ReplicatedMergeTree и ClickHouse Keeper, distributed-таблицы и выбор топологии — вплоть до сборки кластера 2×2, который переживает отказ реплики без шестичасовых пауз.
📖 5.1. ReplicatedMergeTree и ClickHouse Keeper
Репликация — свойство таблицы, не сервера
Первое, что ломает привычку после PostgreSQL: реплицируются таблицы, а не сервер целиком. Один узел может держать сто Replicated-таблиц и десять обычных MergeTree, которые никуда не реплицируются. Это гибкость, но и дисциплина: «эта таблица вообще реплицируется?» — легитимный вопрос на ревью схемы.
ClickHouse Keeper: координация, а не данные
Keeper — центральная координация кластера: Raft-координатор на C++ (с 21.x — production-замена Apache ZooKeeper, совместимый по клиентскому протоколу). Главное: Keeper данные таблиц не хранит — только метаданные и координацию:
- пути и метаданные реплицируемых таблиц (
/clickhouse/tables/{shard}/...); - выборы лидера для планирования мержей и мутаций;
- списки частей реплик — чтобы каждая знала, кого дофетчить;
- хэши вставленных блоков — основа идемпотентности вставок (ниже);
- очередь
ON CLUSTERDDL.
Эксплуатация: минимум 3 узла Keeper в проде (Raft-кворум = N/2+1, три переживают падение одного), разнесённые по зонам. Потеря кворума Keeper — не потеря данных, а стоп координации: вставки в Replicated-таблицы, мержи и DDL встают; чтение живых локальных частей продолжается. Осознанный размен: метаданные консистентны, данные не зависят от памяти Keeper.
ReplicatedMergeTree: как это выглядит
Движку нужны два аргумента — путь таблицы в Keeper и имя реплики; значения
подставляются из макросов узла (<macros><shard>01</shard> <replica>replica1</replica></macros> — свои на каждом узле, полный файл в 5.5):
sqlCREATE TABLE events_local ON CLUSTER company_cluster ( event_id UInt64, event_time DateTime, user_id UInt64, event_type LowCardinality(String), amount Decimal(12, 2) ) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events_local', '{replica}') PARTITION BY toYYYYMM(event_time) ORDER BY (event_type, event_time, user_id);
Два аргумента движка — путь таблицы в Keeper и имя реплики; макросы на каждом узле свои, поэтому один и тот же DDL исполняется на всех узлах, а копии таблиц находят друг друга через общий путь. Механика вставки: часть пишется локально сразу, затем асинхронно реплицируется — другие реплики фетчат часть, сверяясь со списками частей в Keeper. Два следствия:
- Репликация асинхронная: реплика может отставать на секунды/минуты;
SYSTEM SYNC REPLICAдождётся дофетча (для тестов, не для горячего пути). Подтверждение «большинство записало» —SETTINGS insert_quorum = 2(+insert_quorum_timeout): INSERT ждёт записи блока двумя репликами шарда. - Чтение без координации: локальный SELECT работает, даже когда Keeper недоступен.
Идемпотентность вставок: deduplication по хэшу блока
Сюрприз после брокерского курса, где «at-least-once = рутинные дубли»:
ReplicatedMergeTree умеет игнорировать повторную вставку того же блока.
На каждую вставку считается хэш блока (содержимое + структура) и кладётся в
Keeper; если точно такой же блок уже вставлялся в эту партицию — вставка
молча пропускается (insert_deduplicate = 1 по умолчанию). Именно поэтому
«повтор консьюмера» и «ретрай батча из Go» безопасны на уровне таблицы,
а не только приложения.
Честные границы механизма:
- Окно конечное: хранятся хэши последних ~100 блоков партиции. Повтор, разнесённый с оригиналом тысячами других вставок, дедуп не поймает — для CDC-потоков всё равно добавляем слой версий (Модуль 6.3).
- Блок должен совпасть байтово: те же строки в том же порядке. Ретрай с пересортированными строками — новый хэш и полноценный дубль; синк из Модуля 6 ретраит именно исходный батч.
- Мелкие вставки дедуплицируются тоже (каждая вставка — свой блок), но мелкие вставки сами по себе антипаттерн (5.4).
📖 5.2. Distributed-таблицы: маршрутизация вместо хранения
Local vs Distributed
Distributed — виртуальная таблица: данных не хранит, знает кластер,
local-таблицу и правило раскладки:
sqlCREATE TABLE shop.events ON CLUSTER company_cluster AS shop.events_local ENGINE = Distributed(company_cluster, shop, events_local, cityHash64(user_id)); -- кластер БД local sharding key
Клиент ходит на Distributed: INSERT раскладывает по шардам, SELECT собирает
со всех. В local-таблицу напрямую пишут только инструменты, которые сами
знают шарды (синки с шард-роутингом, обслуживание через clusterAllReplicas).
Sharding key: хэш, «диапазон», rand()
| Sharding key | Поведение | Когда |
|---|---|---|
rand() | равномерно, без локальности | «просто размажь нагрузку» |
cityHash64(user_id) | равномерно + события юзера на одном шарде | дефолт для событийных таблиц |
intDiv(user_id, 1000000) | детерминированная раскладка «диапазонами» | грубая эмуляция range-шардирования |
tenant_id | изоляция тенантов | риск перекоса: гигантский тенант = горячий шард |
Нативного range sharding с автоперекладкой, как в Citus, здесь нет — «диапазоны» эмулируются выражением. Выбирая ключ, думайте о двух вещах: равномерность (перекос убивает смысл кластера) и локальность (фильтры и группировки по ключу шардирования выполняются внутри шардов; джойн по ключу шардирования — локальный).
Пути вставки и запроса
- INSERT в Distributed по умолчанию асинхронный: данные кладутся в
файловую очередь узла, принявшего вставку, и рассылаются в фоне. Это второе
место, где «много мелких вставок» ломает продакшен (очередь и лаг растут):
мониторьте
system.distribution_queue; для синхронной доставки —SETTINGS insert_distributed_sync = 1. - SELECT по Distributed: инициатор рассылает подзапрос на все шарды и
сливает результаты. JOIN'ы — как в 4.7: обычный JOIN отправит правую
таблицу на каждый шард,
GLOBAL JOINпосчитает её один раз. - Админский обход —
clusterAllReplicas('company_cluster', 'shop.events_local', query): видит все реплики, а не по одной на шард.
📖 5.3. Топологии: 2×2 — не ответ «по умолчанию»
Кластер = шарды (разные данные) × реплики (копии одного шарда). Кворум Keeper (3 узла) нужен любой топологии с Replicated-таблицами и от числа шардов не зависит.
На схеме: Distributed-таблица — фасад (INSERT маршрутизирует по ключу
шардирования, SELECT собирает scatter-gather); внутри шарда реплики
синхронизируют парты через Keeper. Отказ одной реплики шард переживает;
отказ двух узлов одного шарда — уже нет.
| Топология | Переживает | Смысл |
|---|---|---|
| 1 шард × 2 реплики | падение 1 узла | HA без горизонтального масштаба — разумный старт |
| 2 шарда × 2 реплики | по 1 узлу в каждом шарде | данные/нагрузка > одного узла + HA |
| 3 шарда × 1 реплика | ничего | бенчмарки; в проде так не ходят |
| 2 шарда × 1 реплика | ничего, но дёшево | маргинальный компромисс, осознанный риск |
Когда хватает 1 шарда с репликой: честный ответ
Инженерный рефлекс «кластер = масштабируемость» в ClickHouse дорог. Один
современный узел держит терабайты, миллионы строк/сек батчевой вставки и
тысячи QPS по витринам. Шардирование же добавляет: распределённые джойны
(4.7), ребаланс при рескейле (Модуль 9.2), второй класс отказов, церемонии
ON CLUSTER. Правило курса: стартуйте с 1 шард × 2 реплики + 3 Keeper;
шардирование — по измеримой необходимости (данные упёрлись в диск/память
узла, CPU упёрся в INSERT/SELECT, нужна изоляция нагрузки); «на вырост» —
только с планом рескейла: изменить число шардов — перелив данных, а не флаг
в конфиге. Ключ cityHash64(user_id) планируйте заранее — с ним добавление
шарда проще, чем с «диапазонами».
📖 5.4. Async insert и батчинг: спасение от «too many parts»
Почему ошибка случается
Recap из 4.1: каждый INSERT — новая часть, мержи — фон с конечной скоростью.
Если вставок больше, чем мерж успевает уплотнять, в партиции копятся сотни
частей: сначала ClickHouse замедляет вставки (parts_to_delay_insert),
потом отказывает — классический Too many parts (300). Диагностика:
sqlSELECT partition, count() AS parts, sum(rows) AS rows FROM system.parts WHERE active AND database = 'shop' AND table = 'events_local' GROUP BY partition ORDER BY parts DESC; SELECT * FROM system.merges; -- что мержится прямо сейчас
Лечение — частота вставок, а не лимит: батчи от десятков тысяч строк,
порядка 1 INSERT/сек на таблицу (в пике — единицы). Поднимать
parts_to_throw_insert — как снимать лампочку «Check engine»: чтение сотен
частей в партиции и так медленное.
Вариант 1: клиентский батчинг (лучший)
PrepareBatch + Append + Send с flush по размеру или таймеру — паттерн
из 4.9. Даёт контроль: размер батча, ретраи исходного батча (байтовое
совпадение = сработает dedup из 5.1), метрики, backpressure. Это путь
Go-синка курса.
go// boilerplate/internal/chbatch/batcher.go — флаш по размеру ИЛИ таймеру. // Буфер канала = backpressure: продюсер блокируется, а не течёт в память. type Batcher struct { conn driver.Conn columns string maxRows int maxWait time.Duration in chan []any } func New(conn driver.Conn, columns string, maxRows int, maxWait time.Duration) *Batcher { return &Batcher{ conn: conn, columns: columns, maxRows: maxRows, maxWait: maxWait, in: make(chan []any, maxRows*2), } } func (b *Batcher) Add(ctx context.Context, row []any) error { select { case b.in <- row: return nil case <-ctx.Done(): return ctx.Err() } } func (b *Batcher) Run(ctx context.Context) error { batch := make([][]any, 0, b.maxRows) ticker := time.NewTicker(b.maxWait) defer ticker.Stop() flush := func() error { if len(batch) == 0 { return nil } if err := b.send(ctx, batch); err != nil { return err } batch = batch[:0] return nil } for { select { case <-ctx.Done(): return flush() // graceful shutdown: дожимаем хвост case row := <-b.in: batch = append(batch, row) if len(batch) >= b.maxRows { if err := flush(); err != nil { return err } } case <-ticker.C: if err := flush(); err != nil { return err } } } } // send ретраит ИСХОДНЫЙ батч как есть: порядок строк не меняется, значит хэш // блока тот же и повтор после таймаута дедуплицируется Replicated-таблицей. // Именно поэтому «пересобрать батч и попробовать снова» — не ретрай, а дубль. func (b *Batcher) send(ctx context.Context, rows [][]any) error { var lastErr error for attempt := range 3 { lastErr = b.insert(ctx, rows) if lastErr == nil { return nil } select { case <-ctx.Done(): return ctx.Err() case <-time.After(time.Duration(attempt+1) * 500 * time.Millisecond): } } return fmt.Errorf("ch: batch of %d rows failed: %w", len(rows), lastErr) } func (b *Batcher) insert(ctx context.Context, rows [][]any) error { batch, err := b.conn.PrepareBatch(ctx, "INSERT INTO shop.events_local ("+b.columns+")") if err != nil { return err } for _, r := range rows { if err := batch.Append(r...); err != nil { _ = batch.Abort() return err } } return batch.Send() }
Вариант 2: серверный async_insert
Когда научить клиентов батчить нельзя (тысячи агентов, легаси, BI-инструменты), сервер сам собирает мелкие INSERT в батчи:
sql-- per-query: INSERT INTO events VALUES (...) SETTINGS async_insert = 1, wait_for_async_insert = 1; -- профиль пользователя (клиенты менять не нужно): async_insert = 1, -- async_insert_max_data_size (предел буфера, ~МБ), -- async_insert_busy_timeout_ms (интервал слива, сотни мс, adaptive)
Trade-offs честно: латентность записи растёт; сервер тратит ресурсы на «вашу» работу по батчированию; ретраи на клиенте сложнее (ответ о попадании — не факт вставки). Правило: async_insert — accommodation, не архитектура; если код ваш — батчьте сами.
| Симптом | Причина | Лечение |
|---|---|---|
| parts растёт, мержи не успевают | частые мелкие INSERT | батчинг / async_insert |
| «Too many parts (300)» на одной партиции | горячая партиция + вставки чаще мержа | редкие крупные вставки; проверить skew |
| Лаг витрин/MV растёт | вставки душат фоновые задачи | разгрузить запись, проверить мутации |
📖 5.5. Лабораторная: кластер 2 шарда × 2 реплики + Keeper
Архитектура
Топология: shard 01 = ch01 (replica1) + ch02 (replica2), shard 02 =
ch03 (replica1) + ch04 (replica2), один узел Keeper (в лабе — для простоты,
в проде — 3, Raft-кворум). На всех четырёх узлах — одинаковые
events_local (ReplicatedMergeTree) и events (Distributed).
docker compose и конфиги
yaml# docker-compose.ch-cluster.yaml x-ch-base: &ch-base image: clickhouse/clickhouse-server:24.8 depends_on: [keeper] ulimits: nofile: 262144 services: keeper: image: clickhouse/clickhouse-keeper:24.8 volumes: - ./keeper/keeper_config.xml:/etc/clickhouse-keeper/keeper_config.xml ch01: <<: *ch-base volumes: - ./ch/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml - ./ch/macros-ch01.xml:/etc/clickhouse-server/config.d/macros.xml ports: ["9000:9000", "8123:8123"] ch02: <<: *ch-base volumes: - ./ch/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml - ./ch/macros-ch02.xml:/etc/clickhouse-server/config.d/macros.xml ch03: <<: *ch-base volumes: - ./ch/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml - ./ch/macros-ch03.xml:/etc/clickhouse-server/config.d/macros.xml ch04: <<: *ch-base volumes: - ./ch/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml - ./ch/macros-ch04.xml:/etc/clickhouse-server/config.d/macros.xml
xml<!-- keeper/keeper_config.xml --> <clickhouse> <keeper_server> <tcp_port>9181</tcp_port> <server_id>1</server_id> <raft_configuration> <server><id>1</id><hostname>keeper</hostname><port>9234</port></server> </raft_configuration> <storage_path>/var/lib/clickhouse-keeper</storage_path> </keeper_server> </clickhouse>
xml<!-- ch/cluster.xml — одинаковый на всех узлах CH --> <clickhouse> <remote_servers> <company_cluster> <shard> <replica><host>ch01</host><port>9000</port></replica> <replica><host>ch02</host><port>9000</port></replica> </shard> <shard> <replica><host>ch03</host><port>9000</port></replica> <replica><host>ch04</host><port>9000</port></replica> </shard> </company_cluster> </remote_servers> <zookeeper> <node><host>keeper</host><port>9181</port></node> </zookeeper> <!-- очередь ON CLUSTER DDL тоже живёт в Keeper --> <distributed_ddl> <path>/clickhouse/task_queue/ddl</path> </distributed_ddl> </clickhouse>
xml<!-- ch/macros-ch01.xml; на остальных меняются shard/replica: ch02: shard 01 / replica2, ch03: shard 02 / replica1, ch04: shard 02 / replica2 --> <clickhouse> <macros> <cluster>company_cluster</cluster> <shard>01</shard> <replica>replica1</replica> </macros> </clickhouse>
Шаг 1. Подъём и DDL ON CLUSTER
bashdocker compose -f docker-compose.ch-cluster.yaml up -d
sql-- с любого узла, например ch01 CREATE DATABASE IF NOT EXISTS shop ON CLUSTER company_cluster; -- events_local: тот же DDL, что в 5.1, исполняется на всех четырёх узлах: CREATE TABLE shop.events_local ON CLUSTER company_cluster ( event_id UInt64, event_time DateTime, user_id UInt64, event_type LowCardinality(String), amount Decimal(12, 2) ) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events_local', '{replica}') PARTITION BY toYYYYMM(event_time) ORDER BY (event_type, event_time, user_id); CREATE TABLE shop.events ON CLUSTER company_cluster AS shop.events_local ENGINE = Distributed(company_cluster, shop, events_local, cityHash64(user_id)); SELECT host_name, errors_count FROM system.clusters WHERE cluster = 'company_cluster';
Шаг 2. Вставка через Distributed и дедупликация
sqlINSERT INTO shop.events SELECT number AS event_id, toDateTime('2026-09-01 00:00:00') + toIntervalSecond(number % 7776000) AS event_time, cityHash64(toString(number)) % 100000 AS user_id, ['view', 'cart', 'purchase', 'refund'][1 + number % 4] AS event_type, toDecimal32(1 + number % 50000, 2) AS amount FROM numbers(10000000); -- где физически лежат данные (по одной реплике на шард): SELECT hostName() AS host, count() AS rows FROM clusterAllReplicas('company_cluster', 'shop.events_local') GROUP BY host ORDER BY host;
Обратите внимание: ни now(), ни rand() — и это не косметика, а условие
эксперимента. Дедупликация сравнивает хэш блока, поэтому «тот же» INSERT с
now()/rand() даёт другие байты и честно вставляется второй раз. Для
INSERT ... SELECT есть отдельная ручка (insert_select_deduplicate) и
требование детерминированности SELECT.
Теперь идемпотентность. Проверять её корректно вставкой в локальную таблицу: на пути через Distributed блок сначала попадает в файловую очередь узла-инициатора и рассылается в фоне (5.2) — это ещё одно место, где повтор ведёт себя не так, как «в лоб».
sql-- детерминированный батч (1000 строк) INSERT INTO shop.events_local SELECT number, toDateTime('2026-09-01 00:00:00'), 7, 'view', toDecimal32(1, 2) FROM numbers(1000); SELECT count() FROM shop.events_local WHERE user_id = 7; -- 1000 -- тот же INSERT ещё раз: тот же текст, тот же порядок строк INSERT INTO shop.events_local SELECT number, toDateTime('2026-09-01 00:00:00'), 7, 'view', toDecimal32(1, 2) FROM numbers(1000); SYSTEM SYNC REPLICA shop.events_local; SELECT count() FROM shop.events_local WHERE user_id = 7; -- снова 1000
Контрольный эксперимент закрепляет границу механизма: повторите второй INSERT с
ORDER BY number DESC — порядок строк другой, хэш другой, получите 2000 строк.
Отсюда правило синка из Модуля 6: ретраить исходный батч байт в байт, а не
«перечитать и вставить заново».
Шаг 3. Отказ реплики под нагрузкой
bashdocker compose -f docker-compose.ch-cluster.yaml stop ch02
sql-- вставки продолжаются — вторая реплика шарда жива INSERT INTO shop.events SELECT number + 10000000, now(), cityHash64(toString(number)) % 100000, 'view', toDecimal32(rand() % 1000, 2) FROM numbers(1000000); -- чтение тоже: Distributed обслуживает запрос живой репликой SELECT count() FROM shop.events WHERE user_id = 42;
bashdocker compose -f docker-compose.ch-cluster.yaml start ch02
sql-- на ch02: реплика дофетчит пропущенные части, счётчики сходятся SYSTEM SYNC REPLICA shop.events_local; SELECT count() FROM shop.events_local;
Шаг 4. Эксперименты на закрепление
- Кворум записи. При остановленном ch02 выполните
INSERT INTO shop.events_local SETTINGS insert_quorum = 2 VALUES (...): с одной живой репликой вставка висит доinsert_quorum_timeout— цена кворума наощупь. - Сломайте sharding key. Пересоздайте Distributed с
rand()и сравните распределение и время запроса с фильтром поuser_idпротивcityHash64(user_id). - Async insert против мелких вставок. Циклом из Go сделайте 2 000
построчных INSERT: без настроек — смотрите рост
system.parts; затем сSETTINGS async_insert = 1— сравните число частей и латентность. - Уроните Keeper.
stop keeper: чтение живых частей работает, а INSERT в Replicated-таблицу и DDL останавливаются. Верните — координация восстановится; объясните себе, почему данные не пострадали.
💻 Практика
- Соберите лабораторную 2×2 целиком; зафиксируйте топологию в README (узлы, шарды/реплики, sharding key) — войдёт в ADR MVP (Модуль 11, S4).
- Залейте 10 млн событий через Distributed, постройте кластерную витрину
MV из 4.6 и проверьте сходимость:
sum(events)витрины vscount()raw. - Проведите отказ реплики под непрерывной вставкой из Go-райтера: программа не падает, батчи с ретраем доезжают, после возврата узла счётчики реплик сходятся. Докажите границы dedup: повтор батча байтов в байт не меняет данные, батч с изменённым порядком строк — меняет (объясните через 5.1).
- Включите
async_insertна профиле пользователя, проверьте поток построчных вставок и измерьте цену в латентности; уроните Keeper и опишите, что остановилось, а что продолжило работать.
🧩 Quiz "Проверь себя"
Разбор ключевых вопросов
- Дедупликация вставок (Q3–Q4, Q12). Идемпотентность держится на хэшах блоков в Keeper — особенность CH, которой нет у «обычных» брокерских пайплайнов. Но окно ~100 блоков и требование байтового совпадения означают: ретраить нужно исходный батч, а дедуп — страховка, не замена версионной схеме CDC (Модуль 6.3). Повтор с изменённым порядком строк = новый хэш = дубль.
- Топология (Q8–Q9). Формула для памяти: запись и данные шарда живы, пока жива хотя бы одна его реплика (запись с кворумом — пока кворум); «двойной отказ внутри одного шарда» — потеря шарда. Самое взрослое решение модуля — не строить кластер раньше времени: реплика решает отказоустойчивость дёшево, шардирование добавляет джойны, ребаланс и операционную нагрузку. Шардируйте по измеримым причинам.
- «Too many parts» (Q10–Q11). Ошибка — всегда про частоту вставок против
скорости мержей; подъём лимита лишь откладывает деградацию чтения.
Клиентский батчинг предпочтителен;
async_insert— легальный выход, когда клиенты не ваши, с ценой в латентности и ресурсах сервера.
✅ Итоги модуля
- Объяснить, что координирует Keeper (и чего он НЕ хранит), почему 3 узла и что случится при потере кворума.
- Создать ReplicatedMergeTree одним DDL ON CLUSTER через макросы и описать путь части: локальная запись → асинхронный фетч.
- Применять insert_quorum и insert deduplication, зная границы дедупа (окно ~100 блоков, байтовое совпадение).
- Отличать local от Distributed, выбрать sharding key и обосновать выбор (равномерность + локальность).
- Выбрать топологию: честно остановиться на 1 шард × 2 реплики или обоснованно строить 2×2.
- Диагностировать «too many parts» и вылечить его батчингом или async_insert.
- Собрать кластер 2×2 + Keeper в docker compose и пережить отказ реплики без остановки пайплайна.
Шпаргалка модуля
| Вопрос | Практика |
|---|---|
| Что в Keeper | метаданные, части, лидер мержей, хэши блоков, DDL-очередь; данных нет |
| Таблица-реплика | ReplicatedMergeTree('/clickhouse/tables/{shard}/tbl', '{replica}') + макросы |
| Безопасный ретрай | тот же батч байтов в байт → dedup; окно ~100 блоков партиции |
| Запись «большинство записало» | SETTINGS insert_quorum = 2 |
| Виртуальный роутер | Distributed(cluster, db, local, cityHash64(user_id)) |
| Равномерность + локальность | sharding key = хэш ключа фильтров/группировок |
| Топология по умолчанию | 1 шард × 2 реплики + 3 Keeper; 2×2 — по измеримой необходимости |
| «Too many parts» | батч 10K–1M строк, ~1 INSERT/сек на таблицу; иначе async_insert |
| Все реплики глазами | clusterAllReplicas('cluster', 'db.tbl', query) |
Отказы: падение одной реплики шарда ничего не останавливает (отставшая дофетчит части); обе реплики шарда — потеря данных шарда; узел Keeper из трёх — незаметен; кворум Keeper — чтение живых частей работает, но встают вставки в Replicated, мержи и DDL.
🔗 Мостик к Модулю 6
Кластер готов: ReplicatedMergeTree хранит, Distributed маршрутизирует, Keeper координирует, дедупликация прощает повторы. Остался главный вопрос курса: как данные вообще попадают из PostgreSQL в ClickHouse — и что с ними случается по дороге. Модуль 6 собирает полный пайплайн: CDC из WAL через Debezium, Kafka между мирами, выбор модели вставки (Replacing + версия против Collapsing + sign), честная обработка DELETE/UPDATE и тест «убийством» синкера на exactly-once «по факту». Kafka-движок из 4.8 и кластер из этого модуля встретятся там на живой цепочке.