Модуль 9. Масштабирование и производительность

Цель модуля: отвечать на вопросы «где узкое место?» и «когда и как масштабировать?» цифрами, а не интуицией: read-replicas и роутинг в PostgreSQL, переезд на Citus по шагам, добавление шарда и backfill в ClickHouse, профилирование обеих СУБД, нагрузочные прогоны — и честная цена трёх классических антипаттернов.

Перед началом: Модули 2–8 — пайплайн, HA-стек и мониторинг живы. Инструменты: pgbench, clickhouse-benchmark, docker-compose с профилем citus (координатор + 2 воркера). Трудоёмкость: ~8–10 ч (теория 4 ч · практика 4 ч · квиз 0.5 ч).

🔥 Инцидент: заказ, который исчез после оплаты

OrderFlow в продакшне: заказы и платежи живут в PostgreSQL — источнике правды, аналитика читается из ClickHouse. Нагрузка росла, и команда включила read-replica: листинги «мои заказы» уехали на реплику, мастер занялся записью. Метрики радовали — CPU мастера упал вдвое, p99 записи вернулся к 40 мс.

Через час в поддержку пришла жалоба. Клиент оформил заказ №48151 на 4 900 ₽, оплатил картой, обновил страницу — и заказа в списке нет. Решил, что оплата не прошла, оформил второй — ещё 4 900 ₽. В базе оба платежа успешны; в Grafana лаг реплики держался около 3 секунд — ровно тот интервал, за который клиент успел принять решение. Логи чистые, ошибок нет: система «работала идеально».

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

📖 9.1. PostgreSQL: read-replicas и роутинг запросов

Реплика — прежде всего масштабирования

Streaming-реплика (Модуль 3) — самое дешёвое горизонтальное расширение PG: чтение уезжает на реплику, мастер занимается записью. Но реплика — не «бесплатный мастер»: есть lag, конфликты с vacuum, и главное — роутинг кто-то должен делать осознанно.

Роутинг: в приложении, явно

Автоматические прокси, разруливающие «SELECT → replica» в мире PG слабы (pgpool — отдельная история, доверия не заслуживает). Рабочая практика — два пула в приложении и явные роли методов:

go
type Store struct {
    rw *pgxpool.Pool // primary: запись + read-your-writes
    ro *pgxpool.Pool // replica: дашборды, отчёты, листинги
}

// Явная семантика вместо магического парсера SQL:
func (s *Store) ReadPool() *pgxpool.Pool { return s.ro }

Парсить SQL и угадывать «это можно на реплику» — хрупко: CTE с writes, функции с побочными эффектами,_hint-магия. Роутинг — часть контракта метода: GetOrderForUser идёт в rw, ListOrdersDashboard — в ro, и это зафиксировано в ревью, а не в рантайме.

Read-your-writes через LSN

Классическая беда: записали заказ на мастере, перечитали с реплики — а заказа нет. Лечение — подождать, пока реплика доиграет ваш кусок WAL:

go
// boilerplate/internal/pgstore/lsn.go
//
// pg_lsn отсутствует в type map pgx, поэтому тип приводим на стороне сервера:
// наружу — ::text, внутрь — $1::text::pg_lsn. Никакой магии драйвера.

// CurrentLSN — позиция WAL на primary; вызывать сразу после коммита записи.
func (s *Store) CurrentLSN(ctx context.Context) (string, error) {
	var lsn string
	err := s.rw.QueryRow(ctx, `SELECT pg_current_wal_lsn()::text`).Scan(&lsn)
	if err != nil {
		return "", fmt.Errorf("pgstore: current lsn: %w", err)
	}
	return lsn, nil
}

var ErrReplicaLag = errors.New("pgstore: replica lagged behind the write")

// WaitReplay ждёт, пока реплика доиграет WAL до lsn, либо возвращает ErrReplicaLag.
func (s *Store) WaitReplay(ctx context.Context, lsn string, timeout time.Duration) error {
	deadline := time.Now().Add(timeout)
	for {
		var behind int64
		err := s.ro.QueryRow(ctx,
			`SELECT pg_wal_lsn_diff($1::text::pg_lsn,
			                        COALESCE(pg_last_wal_replay_lsn(), '0/0'))::bigint`,
			lsn).Scan(&behind)
		if err != nil {
			return fmt.Errorf("pgstore: wait replay: %w", err)
		}
		if behind <= 0 {
			return nil // реплика догнала запись
		}
		if time.Now().After(deadline) {
			return ErrReplicaLag // вызывающий уходит читать с primary
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(5 * time.Millisecond):
		}
	}
}
go
// Использование в хендлере «создал заказ → открыл его карточку»:
lsn, err := store.CurrentLSN(ctx)                 // после CreateOrder
if err != nil { /* ... */ }

if err := store.WaitReplay(ctx, lsn, 200*time.Millisecond); err != nil {
	if !errors.Is(err, pgstore.ErrReplicaLag) {
		return err
	}
	order, err = store.GetOrderRW(ctx, id)         // fallback на primary
} else {
	order, err = store.GetOrderRO(ctx, id)         // читаем с реплики
}

Цена — лишний round-trip; включать точечно на «прочитал-своё» сценариях, а не на всё чтение подряд. COALESCE нужен потому, что на только что поднятом standby pg_last_wal_replay_lsn() возвращает NULL.

Конфликты реплики: cancel и hot_standby_feedback

Долгий запрос на реплике держит снапшот; vacuum на мастере хочет убрать строки, нужные этому снапшоту → реплика отменяет ваш запрос (canceling statement due to conflict with recovery). Ручки:

  • max_standby_streaming_delay — сколько реплика готова отставать, прежде чем начать отменять запросы (больше — мягче к читателям, дальше от мастера);
  • hot_standby_feedback = on — реплика сообщает мастеру о своих снапшотах, vacuum щадит нужное: cancel исчезают, цена — bloat на мастере.

И помните: аналитика на реплике всё равно давит на общий IO и кэш. Реплика — это «меньше мешает OLTP», а не «бесплатные ресурсы».

📖 9.2. PostgreSQL: партиции → Citus, переезд по шагам

Лестница масштабирования — в порядке цены

  1. Схема и индексы по данным pg_stat_statements (Модуль 2).
  2. Партиции pg_partman (Модуль 3): партиционный prune, retention, дешёвые удаления.
  3. Read-replicas + роутинг (9.1).
  4. Citus — когда упёрлись в запись или объём одного узла.
  5. Distributed SQL (CockroachDB/YugabyteDB) — только если нужен multi-region OLTP (Модуль 3).

Перепрыгивание ступеней — самая дорогая ошибка: Citus не лечит отсутствие индексов, а distributed SQL не лечит плохую схему.

Переезд на Citus по шагам

Шаг 1. Топология. Координатор + N воркеров (в лабе — 1+2 в docker-compose). Citus — расширение, а не форк PG: ваш pgx-код, миграции и большинство расширений работают без изменений.

Шаг 2. Ключ шардирования — самое важное решение. Ключ = то, по чему живут транзакции и джойны. Для OrderFlow это tenant_id: заказы, платежи и события одного тенанта обрабатываются вместе. Ключ order_id сделает каждый джойн orders↔payments кросс-шардным — навсегда.

Шаг 3. Распределение:

sql
CREATE EXTENSION citus;

-- большие таблицы: hash-распределение с колокацией по tenant_id
SELECT create_distributed_table('orders',       'tenant_id');
SELECT create_distributed_table('payments',     'tenant_id');
SELECT create_distributed_table('order_events', 'tenant_id');

-- маленькие справочники — на все шарды: локальные джойны бесплатно
SELECT create_reference_table('cities');

-- локальные таблицы (служебные очереди и т.п.) остаются на координаторе
SELECT citus_add_local_table_to_metadata('audit_log');

Колокация (одинаковый ключ и число шардов) означает: строки одного тенанта лежат на одном воркере, джойны и транзакции по тенанту выполняются локально.

Шаг 4. Исторические данные. create_distributed_table на непустой локальной таблице перераспределяет её по шардам. Для больших объёмов и минимального простоя — логическая репликация в уже-распределённую схему и короткий cutover, по тем же правилам, что и CDC-миграции (Модуль 8.2).

Шаг 5. Проверка pushdown:

sql
EXPLAIN SELECT sum(amount) FROM payments
WHERE tenant_id = 42 AND created_at >= now() - interval '7 days';
-- Custom Scan (Citus Adaptive): один шард, без перемешивания — то, что нужно

Джойн по неколокированному ключу выдаст в плане Repartition/Broadcast — сигнал, что колокация не сработала и надо пересматривать ключ или запрос.

Что придётся принять: цена Citus

  • UNIQUE / PK / FK — только с ключом шардирования; глобальные ограничения — через reference-таблицы или через дизайн.
  • Кросс-шардные транзакции — 2PC: латентность коммита растёт.
  • Координатор — точка входа и метаданных: его HA — отдельная задача (Patroni на координаторе).
  • Расширения (pgvector, pg_partman) работают на шардах, но не всё умеет распределённую семантику — проверяйте по своему списку.

Citus — не «поставил и разогнался», а контракт архитектуры, подписанный выбором ключа. Дешевле подписать его до продакшена.

📖 9.3. ClickHouse: добавление шарда, рескелинг, backfill

Добавление шарда

Новый узел + Keeper-кворум; шард дописывается в конфиг кластера (remote_servers); на нём создаются локальные ReplicatedMergeTree с теми же ORDER BY и путями ZooKeeper:

sql
CREATE TABLE analytics.events_local ON CLUSTER orderflow
(
    version UInt64, order_id UInt64, tenant_id UInt32,
    kind LowCardinality(String), amount Decimal(18, 2),
    pay_latency_ms UInt32 DEFAULT 0, _deleted UInt8 DEFAULT 0,
    event_time DateTime64(3)
)
ENGINE = ReplicatedReplacingMergeTree(version)
PARTITION BY toYYYYMM(event_time)
ORDER BY (tenant_id, order_id, event_time);

Схема — ровно та же, что в Модуле 11 (S4): version из source.lsn, ReplicatedReplacingMergeTree(version), ключ сортировки как дедуп-ключ. При рескейле ORDER BY, партиционирование и путь в Keeper обязаны совпасть с исходной таблицей — иначе перелив INSERT SELECT превратится в пересборку модели данных, а не в миграцию.

Distributed-таблица подхватывает новый шард автоматически: новые вставки размазываются по хэшу на все шарды — включая новый, пустой.

Рескелинг: честно про отсутствие автоматики

Автоматического ребаланса в ClickHouse нет. После добавления шарда старые данные остаются на старых узлах, новый получает только часть новых вставок — перекос растёт. Полное выравнивание — это перезаливка:

sql
-- перелив в новый кластер 3×2 (выполняется на новых узлах)
INSERT INTO FUNCTION remoteSecure('ch-new:9440', 'analytics', 'events', 'svc', '***')
SELECT * FROM remoteSecure('ch-old:9440', 'analytics', 'events', 'svc', '***');

Практика индустрии (см. blog.altinity.com): рескейл планировать как миграцию с dual-run — старый кластер продолжает писать, новый доигрывает историю, дашборды переключаются на новый, старый гасится. «Добавили шард и разошлось» — так не работает, и это надо закладывать в план до того, как диск кончился.

Backfill исторических данных

sql
-- из PostgreSQL напрямую, ПОРЦИЯМИ по дате
INSERT INTO analytics.events
SELECT lsn::UInt64, order_id, tenant_id, kind, amount, event_time
FROM postgresql('pg-master:5432', 'orderflow', 'order_events', 'svc', '***')
WHERE event_time >= '2026-01-01' AND event_time < '2026-02-01';
  • Порции по времени/ключу — не весь диапазон одним запросом: и на PG, и на сеть, и на CH-мерджи.
  • Из Kafka: сбросить offsets consumer-группы и доиграть retention (если retention покрывает нужный горизонт).
  • Из S3: s3() table function.
  • Не забывать версию (LSN) при вставке — дедупликация ReplacingMergeTree работает и на backfill (Модуль 6).

Рескелинг хранения без изменения кластера

TTL → tiered storage: горячие части на SSD, холодные на S3 (Модуль 4.5). «Диск кончается» часто дешевле лечится переносом температуры, чем добавлением узлов — посчитайте цену обоих путей до графика закупки.

📖 9.4. Профилирование: pg_stat_statements/auto_explain vs system.query_log

PostgreSQL: сначала агрегаты, потом план

sql
-- топ по суммарному времени: что чинить первым
SELECT round(total_exec_time) AS total_ms,
       calls,
       round(mean_exec_time, 2) AS mean_ms,
       rows,
       left(query, 80) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

Два чтения этой таблицы:

  • total большой, mean крошечный → N+1 из приложения: миллионы дешёвых вызовов; чинить батчингом/джойном в коде, не индексом.
  • total большой, mean большой → тяжёлый запрос: EXPLAIN (ANALYZE, BUFFERS) и вплоть до Модуля 2.

auto_explain — ловец «внезапно замедлившегося»:

ini
# postgresql.conf
shared_preload_libraries = 'auto_explain'
auto_explain.log_min_duration = '500ms'
auto_explain.log_analyze = on
auto_explain.log_buffers = on
auto_explain.sample_rate = 0.1   # 10% запросов: не заDDoSить себя логами

sample_rate — обязательная гигиена: полный лог планов под нагрузкой сам становится инцидентом.

ClickHouse: query_log и query_thread_log

sql
-- суточный профиль: где тяжело
SELECT
    query,
    count()                           AS runs,
    round(avg(query_duration_ms))     AS avg_ms,
    max(query_duration_ms)            AS max_ms,
    round(avg(read_rows))             AS avg_rows,
    round(avg(memory_usage) / 1e9, 2) AS avg_mem_gb
FROM system.query_log
WHERE event_date = today() AND type = 'QueryFinish'
GROUP BY query
ORDER BY avg_ms * runs DESC
LIMIT 10;
  • system.query_thread_log — раскладка по потокам: сколько времени в CPU против ожиданий IO.
  • EXPLAIN indexes=1 — какие гранулы отсёк ключ сортировки; EXPLAIN pipeline — как запрос параллелится.
  • Главный коэффициент «правильности» ключа сортировки: read_rows против строк, дошедших до GROUP BY. Огромная разница — читаете то, что выбрасываете.

Разница ментальных моделей: PG-профилирование — про страницы, буферы и планы; CH — про прочитанные колонки/гранулы и merge pressure. Один и тот же вопрос «что БД делает на самом деле» — но разными инструментами.

📖 9.5. Нагрузочное тестирование: pgbench + clickhouse-benchmark

pgbench: сценарий под ваш профиль, а не default

bash
# инициализация: scale 100 = 10M строк
pgbench -i -s 100 orderflow

# свой сценарий: смесь записи, как в проде
cat > bench/order_mix.sql <<'EOF'
\set tid random(1, 10000)
\set oid random(1, 100000000)
UPDATE orders SET status = 2, updated_at = now()
WHERE order_id = :oid AND tenant_id = :tid;
INSERT INTO order_events (tenant_id, order_id, kind, payload)
VALUES (:tid, :oid, 'status_change', '{"to":2}');
EOF

pgbench -c 50 -j 4 -T 300 -P 10 -f bench/order_mix.sql orderflow

Читать отчёт: tps, средняя латентность и перцентили, плюс — важнее цифр — pg_stat_activity во время прогона: wait_event покажет bottleneck (IO/DataFileRead, Lock, LWLock) без угадывания.

clickhouse-benchmark

bash
clickhouse-benchmark \
  --concurrency=8 \
  --iterations=1000 \
  --query "SELECT tenant_id, sum(amount) FROM analytics.events
           WHERE event_time >= now() - INTERVAL 30 DAY
           GROUP BY tenant_id"

Ценность — в A/B: один и тот же запрос на таблицах с разным ORDER BY, с skip-индексом и без, с другим кодеком. Меняете одну переменную — сравниваете статистику (percentiles, rows read).

Поиск bottleneck: метод

Нагрузка растёт ступенями; первый насытившийся ресурс и есть bottleneck:

СимптомГде смотретьТиповая причина
CPU 100%, latency растётpg_stat_activity (state=active); CH query_logтяжёлые планы; в CH — огромные read_rows, ключ не отсекает
iowait, latency скачетiostat -x (await), pg_stat_io (PG16)холодный кэш, fsync; в CH — мерджи и вставки конкурируют
Вставки в CH тормозят «на ровном месте»system.parts на партицию, system.mergesmerge pressure: слишком мелкие батчи
p99 вырос, p50 в нормеauto_explain, прогресс pgbenchлоксы, checkpoint-залп, vacuum-шторм
Lock-таймаутыpg_locks, wait_event=Lockочередь на hotspot-строке (Модуль 2.6)

Правило эксперимента: одна переменная за прогон — иначе не узнаете, что именно помогло.

📖 9.6. Антипаттерны: цена неправильного инструмента

ClickHouse как OLTP

Точечный UPDATE через мутацию — очередь мутаций и тормозящие вставки (Модуль 8.2). Точечный SELECT — константные десятки миллисекунд оверхеда (TLS, разбор запроса, никакого row-кэша) против единиц в PG. Честных транзакций нет. Итог: проигрыш на обеих осях — не быстро точечно и не дёшево.

PostgreSQL как аналитика

GROUP BY по 500M строк: вымывает shared_buffers (OLTP начинает читать диск), долгие запросы держат снапшоты → xid-horizon не двигается → bloat (Модуль 2), реплики отстают. Классика жанра: «после запуска отчёта чекаут замедлился в три раза» — OLTP платит за чужую работу.

«Одна СУБД на всё»

Цена — обе деградации одновременно; экономия — один компонент из docker-compose. Когда честно можно: < десятков GB, аналитика десятками запросов в час, нет жёстких SLA на дашборды — один PG с материализованными view проживёт годы. Курс — про момент, когда это перестаёт быть правдой; сигнал — графики, а не амбиции.

АнтипаттернСимптомЦенаЛекарство
CH как OLTPмутации в очереди, CRUD лагаетнет ни OLTP-латентности, ни дешевизныстатусы/транзакции — в PG; в CH — события
PG как аналитикаbloat, лаг реплик, деградация чекаутаOLTP платит за чужую работуагрегаты — в CH через CDC
Одна СУБД на всёобе деградациисэкономлен компонент, потерян SLAчестные критерии (Модуль 1.1), пара СУБД

💻 Практика

  1. pgbench-профиль на схеме MVP: прогон 300 секунд, найти bottleneck по wait_event; включить auto_explain и поймать план самого медленного запроса.
  2. Citus-лаборатория (профиль citus: координатор + 2 воркера): create_distributed_table с колокацией по tenant_id; EXPLAIN показывает pushdown; джойн по неколокированному ключу — увидеть Repartition.
  3. CH A/B: clickhouse-benchmark одного запроса на таблицах с ORDER BY (event_time, tenant_id) и (tenant_id, event_time) под фильтр по тенанту — сравнить время и read_rows.
  4. query_log: топ-3 запроса за сутки по avg_ms * runs; один оптимизировать и показать дельту цифрами.

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

1. Ключ шардирования в Citus выбирают…
2. После записи на primary приложение перечитало заказ с реплики и не нашло. Правильное лечение в месте вызова?
3. Добавили третий шард в кластер ClickHouse. Что с существующими данными?
4. Нужно догрузить в ClickHouse 500M строк истории из PostgreSQL. Рабочий способ?
5. Когда «одна СУБД (PostgreSQL) на всё» — честно рабочая конфигурация? — выберите все подходящие
6. pg_stat_statements: total_exec_time — 40% всей БД, mean_exec_time 0.4ms, calls 80M. Диагноз?
7. clickhouse-benchmark: запрос читает в 50 раз больше строк, чем возвращает фильтр. Первое действие?
8. create_distributed_table('payments', 'tenant_id') для таблицы с 300M строк — что произойдёт?
9. Запросы на реплике падают с «canceling statement due to conflict with recovery». Какая ручка убирает cancel и какова её цена?
10. Какие признаки говорят о merge pressure в ClickHouse? — выберите все подходящие

✅ Итоги модуля

  • Роутить чтение на реплики осознанно: два пула, LSN-wait для read-your-writes.
  • Знать лестницу масштабирования PG (схема → партиции → реплики → Citus) и не перепрыгивать ступени.
  • Провести переезд на Citus: ключ шардирования, колокация, проверка pushdown в EXPLAIN.
  • Добавить шард CH и объяснить, почему рескелинг — это миграция, а не кнопка.
  • Backfill из PG через postgresql() порциями, с версией для дедупликации.
  • Профилировать обе СУБД: pg_stat_statements/auto_explain против system.query_log/query_thread_log.
  • Прогнать pgbench/clickhouse-benchmark и назвать bottleneck по данным, а не по ощущениям.
  • Назвать цену каждого из трёх антипаттернов — и признаки, что пора менять архитектуру.

Шпаргалка модуля

ВопросОтвет
Read-your-writesпосле записи — pg_current_wal_lsn(); перед чтением с реплики — ждать pg_last_wal_replay_lsn() >= LSN
Конфликты репликиmax_standby_streaming_delay / hot_standby_feedback=on (цена: bloat мастера)
Ключ Citusпо транзакциям и джойнам (tenant_id); UNIQUE/FK только с ним
Проверка CitusEXPLAIN: Custom Scan (Citus Adaptive) = pushdown; Repartition = плохо
Рескелинг CHавто-ребаланса нет; перелив remoteSecure() / dual-run миграция
Backfillpostgresql() порциями по датам; версия (LSN) — для дедупа
PG-профильpg_stat_statements (total vs mean) → auto_explain с sample_rate
CH-профильsystem.query_log: avg_ms * runs; read_rows против результата — качество ключа
АнтипаттерныCH≠OLTP (мутации), PG≠OLAP (bloat), «одна на всё» = обе деградации

🔗 Мостик к Модулям 10–11

Дальше развилка. По основной линии — Модуль 11, MVP «Аналитика OrderFlow», где всё это собирается в один работающий стенд с HA-кластером и дашбордом. Если тянет в сторону AI — опциональный Модуль 10 (pgvector и RAG) проходит параллельно: он не меняет пайплайн и не требует изменений в MVP. Оба пути сходятся в финальном баттле.