Модуль 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 — отдельная история, доверия не заслуживает). Рабочая практика — два пула в приложении и явные роли методов:
gotype 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, переезд по шагам
Лестница масштабирования — в порядке цены
- Схема и индексы по данным pg_stat_statements (Модуль 2).
- Партиции pg_partman (Модуль 3): партиционный prune, retention, дешёвые удаления.
- Read-replicas + роутинг (9.1).
- Citus — когда упёрлись в запись или объём одного узла.
- 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. Распределение:
sqlCREATE 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:
sqlEXPLAIN 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:
sqlCREATE 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
bashclickhouse-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.merges | merge 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), пара СУБД |
💻 Практика
- pgbench-профиль на схеме MVP: прогон 300 секунд, найти bottleneck по
wait_event; включить auto_explain и поймать план самого медленного запроса. - Citus-лаборатория (профиль
citus: координатор + 2 воркера):create_distributed_tableс колокацией поtenant_id; EXPLAIN показывает pushdown; джойн по неколокированному ключу — увидеть Repartition. - CH A/B: clickhouse-benchmark одного запроса на таблицах с
ORDER BY (event_time, tenant_id)и(tenant_id, event_time)под фильтр по тенанту — сравнить время и read_rows. - query_log: топ-3 запроса за сутки по
avg_ms * runs; один оптимизировать и показать дельту цифрами.
🧩 Quiz "Проверь себя"
✅ Итоги модуля
- Роутить чтение на реплики осознанно: два пула, 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 только с ним |
| Проверка Citus | EXPLAIN: Custom Scan (Citus Adaptive) = pushdown; Repartition = плохо |
| Рескелинг CH | авто-ребаланса нет; перелив remoteSecure() / dual-run миграция |
| Backfill | postgresql() порциями по датам; версия (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. Оба пути сходятся в финальном баттле.