Модуль 5. ClickHouse: кластер и распределённые таблицы

Цель модуля: вынести ClickHouse за пределы одного узла без потери здравого смысла: что координирует ClickHouse Keeper и почему репликация здесь табличная; как писать и читать через ReplicatedMergeTree + Distributed; как выбрать топологию — включая честное «вам хватит одного шарда с репликой»; как победить «too many parts» батчингом и async_insert. Финал — кластер 2×2 в docker compose и отказ реплики под нагрузкой.

Перед началом: Модуль 4 (MergeTree-семейство, parts и мержи, батч-вставка). Инструменты: docker compose, clickhouse-client, clickhouse-go v2. Трудоёмкость: ~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 CLUSTER DDL.

Эксплуатация: минимум 3 узла Keeper в проде (Raft-кворум = N/2+1, три переживают падение одного), разнесённые по зонам. Потеря кворума Keeper — не потеря данных, а стоп координации: вставки в Replicated-таблицы, мержи и DDL встают; чтение живых локальных частей продолжается. Осознанный размен: метаданные консистентны, данные не зависят от памяти Keeper.

ReplicatedMergeTree: как это выглядит

Движку нужны два аргумента — путь таблицы в Keeper и имя реплики; значения подставляются из макросов узла (<macros><shard>01</shard> <replica>replica1</replica></macros> — свои на каждом узле, полный файл в 5.5):

sql
CREATE 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-таблицу и правило раскладки:

sql
CREATE 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-таблицами и от числа шардов не зависит.

Топология 2 шарда × 2 реплики + Keeper

На схеме: 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). Диагностика:

sql
SELECT 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

bash
docker 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 и дедупликация

sql
INSERT 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. Отказ реплики под нагрузкой

bash
docker 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;
bash
docker compose -f docker-compose.ch-cluster.yaml start ch02
sql
-- на ch02: реплика дофетчит пропущенные части, счётчики сходятся
SYSTEM SYNC REPLICA shop.events_local;
SELECT count() FROM shop.events_local;

Шаг 4. Эксперименты на закрепление

  1. Кворум записи. При остановленном ch02 выполните INSERT INTO shop.events_local SETTINGS insert_quorum = 2 VALUES (...): с одной живой репликой вставка висит до insert_quorum_timeout — цена кворума наощупь.
  2. Сломайте sharding key. Пересоздайте Distributed с rand() и сравните распределение и время запроса с фильтром по user_id против cityHash64(user_id).
  3. Async insert против мелких вставок. Циклом из Go сделайте 2 000 построчных INSERT: без настроек — смотрите рост system.parts; затем с SETTINGS async_insert = 1 — сравните число частей и латентность.
  4. Уроните Keeper. stop keeper: чтение живых частей работает, а INSERT в Replicated-таблицу и DDL останавливаются. Верните — координация восстановится; объясните себе, почему данные не пострадали.

💻 Практика

  1. Соберите лабораторную 2×2 целиком; зафиксируйте топологию в README (узлы, шарды/реплики, sharding key) — войдёт в ADR MVP (Модуль 11, S4).
  2. Залейте 10 млн событий через Distributed, постройте кластерную витрину MV из 4.6 и проверьте сходимость: sum(events) витрины vs count() raw.
  3. Проведите отказ реплики под непрерывной вставкой из Go-райтера: программа не падает, батчи с ретраем доезжают, после возврата узла счётчики реплик сходятся. Докажите границы dedup: повтор батча байтов в байт не меняет данные, батч с изменённым порядком строк — меняет (объясните через 5.1).
  4. Включите async_insert на профиле пользователя, проверьте поток построчных вставок и измерьте цену в латентности; уроните Keeper и опишите, что остановилось, а что продолжило работать.

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

1. Какое честное ограничение у insert deduplication в ReplicatedMergeTree?
2. Сколько узлов Keeper нужно для отказоустойчивого кластера и сколько узлов он переживёт?
3. Кластер 2 шарда × 2 реплики + 3 узла Keeper. Какие отказы переживаются без потери данных и остановки записи? — выберите все подходящие
4. Что хранит ClickHouse Keeper?
5. Что хранит таблица с движком Distributed?
6. Что делает async_insert = 1?
7. Когда разумно остановиться на топологии 1 шард × 2 реплики?
8. Что даёт sharding key cityHash64(user_id)?
9. Вы выполнили один и тот же INSERT (идентичный батч) в ReplicatedMergeTree дважды. Что произойдёт?
10. INSERT записал часть локально, и узел умер до репликации. Что произойдёт с данными на других репликах?
11. Какие механизмы делают вставку в ReplicatedMergeTree безопасной при повторных попытках? — выберите все подходящие
12. Микросервисы шлют построчные INSERT, началось «Too many parts (300)». Что делать?

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

  • Дедупликация вставок (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 и кластер из этого модуля встретятся там на живой цепочке.