Модуль 9. Эволюция БД: от PgBouncer до CQRS
Цель модуля: Освоить эволюцию масштабирования БД: от одного инстанса до шардирования и CQRS. Понять, что не начинайте с шардирования — каждая ступень (пулинг → реплики → кэш → партиции → шарды) добавляет сложность. Освоить PgBouncer (connection pooling), Read Replicas (read/write splitting), партиционирование (Partition Pruning), шардирование (Citus) и CQRS (event-driven проекции).
Стадия роста проекта: Рост нагрузки ×10. Проект пережил интеграции, Outbox, Saga, Resilience, Observability, Security и IaC. Теперь система работает 24/7, метрики прозрачны, секреты в Lockbox, инфраструктура в Terraform. Но пришло время масштабировать БД — и первое, что ломается — коннекты, CPU мастера, VACUUM.
Перед началом: Модули 1–8 (особенно Resilience — кэш, IaC — Terraform) Инструменты: PostgreSQL, PgBouncer, Citus, pgx v5, k6, Terraform Трудоёмкость: ~8 ч (теория 2.5 ч · практика 5 ч · ADR 0.5 ч)
🔥 Инцидент: когда «всё работает» — это уже проблема
Инфраструктура в Terraform (Модуль 8), Multi-AZ, бэкапы настроены. Но в понедельник — письмо от CFO: «Счёт за облако вырос в 2,3 раза. Объясните». В Billing: 12 ВМ по 8 ядер, загрузка 15–20% — греют воздух. Пики вечером досят до 60%, остальное время ты платишь за пустоту. Это FinOps-риск: хорошая архитектура не должна стоить как плохая эксплуатация.
В pg_stat_activity — 950 активных соединений при max_connections = 500. PostgreSQL превысил лимит, потому что каждый коннект = отдельный процесс (~10 МБ RAM). При следующем всплеске трафика — too many connections, сервис лежит. PgBouncer либо не настроен, либо настроен неверно.
Таблица payments — 800 ГБ, индексы раздуты, VACUUM идёт часами. 90% запросов — чтение за последние 30 дней, но сканится вся таблица. Команда предлагает «просто добавить реплику» — но реплика разгрузит чтение, а узкое место сейчас запись и CPU мастера.
Четыре зоны риска:
- «Слишком много коннектов» — PgBouncer в
transactionрежиме: 1000 клиентских коннектов → 50 серверных. - «800 ГБ и нет скорости» — PgBouncer → Read Replicas → партиционирование по дате. Шардирование — только когда всё остальное исчерпано.
- «Платим за воздух» — K8s + HPA для синхронных API, Serverless для воркеров.
- «Просто добавить реплику» — не решает write-CPU. Реплика разгружает чтение, не запись.
Не начинайте с шардирования. Каждая ступень эволюции (пулинг → реплики → кэш → партиции → шарды) добавляет сложность — исчерпайте предыдущую первой.
📖 Теория: Эволюция масштабирования БД
Глоссарий модуля:
- Connection Pool (PgBouncer) — пулинг соединений к БД; переиспользует коннекты вместо открытия нового процесса на каждый запрос. Режим
transaction— один коннект обслуживает много транзакций. - Read Replica — асинхронная копия мастера БД; чтение направляется на реплику, запись — на мастер. Снижает нагрузку на мастер, но создаёт replication lag (задержку).
- Replication Lag — задержка между записью на мастер и появлением данных на реплике; причина «stale reads» (клиент видит старые данные после своего же update).
- Partitioning (партиционирование) — разбиение большой таблицы на части по правилу (
RANGEпо дате,LIST,HASH) внутри одной БД. Не путать с шардированием. - Sharding (шардирование) — горизонтальное масштабирование: данные распределены по разным физическим инстансам БД (шардам) по ключу (например,
user_id). Самый сложный вид скейлинга. - CQRS (Command Query Responsibility Segregation) — разделение моделей: команды (запись) и запросы (чтение) используют разные схемы/хранилища. Чтение из materialised view или проекции, обновляемой событиями.
- Event Sourcing — хранение только событий (append-only лог) без таблиц состояний; состояние вычисляется риплеем событий или из snapshot.
9.1. Эволюция хранения данных: от мастера к шардам
Вы развернули Managed PostgreSQL (Модуль 8) — один кластер в 3 AZ. На 100 RPS этого достаточно. Но что делать, когда RPS растёт? БД — узкое место №1 в распределённых системах, и масштабируется она труднее всего.
Линейка эволюции (каждый шаг решает проблему предыдущего):
| Стадия | Проблема | Решение | RPS |
|---|---|---|---|
| 0. Один мастер | — | Стартовая точка | ~100 |
| 1. PgBouncer | Коннекты кончаются (каждый = fork, ~10 МБ RAM) | Connection pooling (transaction mode) | ~500 |
| 2. Read Replicas | Мастер не справляется с чтением (90% трафика) | Read/Write Split: reads → replica, writes → master | ~2000 |
| 3. Кэш (Модуль 5) | Реплика тоже не справляется | Cache-Aside + SWR: 90% запросов не доходят до БД | ~5000 |
| 4. Партиционирование | Таблица 500 ГБ+, индексы деградируют, vacuum часами | PARTITION BY RANGE/LIST/HASH внутри одной БД | — |
| 5. Шардирование | Мастер не справляется с записью | Данные по разным инстансам (шардам) по ключу | 10k+ |
| 6. CQRS | Сложные запросы (aggregation, join) не маппятся на write-модель | Отдельные read-модели, обновляемые событиями | — |
Главный вывод: не начинайте с шардирования. Каждая ступень добавляет сложность. Шардирование — крайняя мера, когда исчерпаны pooling, реплики, кэш и партиционирование.
9.2. Connection Pooling (PgBouncer)
PostgreSQL создаёт отдельный процесс на каждое соединение (~10 МБ RAM). При 1000 коннектов — 10 ГБ только на overhead. Managed PG имеет лимит max_connections (обычно 200–500).
Решение: PgBouncer — пулер коннектов между приложением и БД. В режиме transaction один серверный коннект переиспользуется для множества клиентских транзакций.
Go (pgx): pgxpool уже встроенный пул на стороне клиента, но PgBouncer нужен между пулом приложения и БД — он делит коннекты между всеми подами/воркерами.
go// pgxpool — клиентский пул, но PgBouncer — серверный pool, err := pgxpool.New(ctx, "postgres://app:pass@pgbouncer:6432/mydb?pool_mode=transaction")
Trade-off: в режиме transaction не работают session-level features: SET, LISTEN/NOTIFY, WITH HOLD (named cursors), temporary tables. Prepared statements требуют max_prepared_statements в PgBouncer ≥ 1.21 или режим session.
9.3. Read Replicas (Read-Write Splitting)
10 000 RPS, из них 90% — чтение профиля (Модуль 5). Кэш снимает часть, но cache miss всё равно бьёт мастера. Решение — асинхронные read-реплики.
Read/Write Split: Запись (INSERT/UPDATE/DELETE) → мастер. Чтение (SELECT) → реплика.
Replication Lag: Реплика «догоняет» мастера с задержкой (миллисекунды → секунды при нагрузке). Чтение с реплики может вернуть stale-данные.
Read-your-writes consistency: После UPDATE пользователь ожидает увидеть свои изменения. Решение: в течение N секунд после записи — читать с мастера, не с реплики (или использовать pg_dirtyread / LSN tracking).
Go (pgx): На уровне Repository — два подключения: readPool / writePool. Use Case решает, какое использовать. Не делать это на уровне Middleware — бизнес-логика должна явно выбирать, чтение ли это «после записи».
go// Repository: два пула, Use Case выбирает type Repo struct { readPool *pgxpool.Pool // реплика writePool *pgxpool.Pool // мастер через PgBouncer } func (r *Repo) GetProfile(ctx context.Context, userID string, lastWriteAt time.Time) (*Profile, error) { // Read-your-writes: если запись была < 5 сек назад — читать с мастера if time.Since(lastWriteAt) < 5*time.Second { return r.getFromMaster(ctx, userID) } return r.getFromReplica(ctx, userID) }
Связь с Модулем 1: асинхронная репликация = eventual consistency (AP). Чтение с реплики = stale read. Если бизнес требует strong consistency — читать с мастера (PC/EC, но дороже).
9.4. Партиционирование таблиц (Declarative Partitioning)
Таблица payments выросла до 500 ГБ. Индексы деградируют, VACUUM идёт часами, SELECT по последнему месяцу сканирует всю таблицу.
Решение: PostgreSQL Declarative Partitioning — таблица разбивается на partitions внутри одной БД:
- RANGE по дате:
payments_2024_01,payments_2024_02. Запрос за последний месяц сканирует одну партицию, не всю таблицу (Partition Pruning). - LIST по
tenant_id: каждый тенант в своей партиции. - HASH по
user_id: равномерное распределение. - Drop old данных:
DROP TABLE payments_2023_01— мгновенно, безDELETEиVACUUM.
sql-- Партиционирование по дате CREATE TABLE payments ( id BIGSERIAL, user_id UUID NOT NULL, amount NUMERIC(18, 2) NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ) PARTITION BY RANGE (created_at); CREATE TABLE payments_2024_01 PARTITION OF payments FOR VALUES FROM ('2024-01-01') TO ('2024-02-01'); CREATE TABLE payments_2024_02 PARTITION OF payments FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
Trade-off: Unique constraint должен включать partition key (нельзя UNIQUE(payment_id) если партиция по created_at). Cross-partition FK не работают. SELECT без фильтра по partition key сканирует все партиции.
9.5. Шардирование (Horizontal Scaling)
Реплики масштабируют чтение, но запись идёт в один мастер. При 10 000+ write RPS мастер становится бутылочным горлышком. Шардирование распределяет и запись.
Стратегии:
- Hash-based:
shard = hash(user_id) % N. Просто, но добавление шарда → перемещение данных (resharding problem). Решение: consistent hashing (минимум перемещений). - Range-based: шарды по диапазону ключа (
user_id 0-999→ shard 1,1000-1999→ shard 2). Хорошо для range-запросов, но риск hotspots. - Directory-based: отдельный сервис (directory) маппит
key → shard. Гибко, но добавляет SPOF.
Инструменты: Vitess (MySQL), Citus (PostgreSQL extension), YDB (native sharding).
Главный риск — cross-shard transactions: перевод денег между пользователями на разных шардах. Решения: 2PC (мёртв, см. Модуль 4), Saga (оркестрация компенсаций), или проектирование так, чтобы агрегат целиком лежал на одном шарде (shard by user_id → все платежи пользователя на одном шарде).
Вывод: шардирование — крайняя мера. Сначала исчерпайте PgBouncer, read-реплики, кэш, партиционирование. Шардирование добавляет сложность (resharding, cross-shard joins, согласованность) — это плата за write-скейлинг. См. ADR-9.
9.6. CQRS и Materialised Views
Сложные read-запросы (агрегации, dashboard, search) не маппятся на нормализованную write-модель. SELECT SUM(amount) GROUP BY tenant, day по 500 ГБ — медленно.
CQRS (Command Query Responsibility Segregation): команды (запись) и запросы (чтение) используют разные модели. Запись идёт в нормализованную БД. Чтение — из денормализованной проекции (materialised view, Elasticsearch, ClickHouse), обновляемой событиями (через Outbox из Модуля 3).
Write model (PostgreSQL): payments (нормализованная)
↓ Outbox → Kafka
Read model (ClickHouse): payments_daily_stats (агрегаты по дням)
Связь с Модулем 3: CQRS + Outbox = event-driven проекции. Консьюмер Kafka обновляет read-модель. Latency чтения = миллисекунды (запрос к агрегату), latency актуализации = секунды (replication lag event pipeline). Trade-off: eventual consistency на чтение.
9.7. Event Sourcing — CQRS без write-таблиц
CQRS с классическими write-таблицами (payments) + Outbox — это MVP. Следующий уровень эволюции — Event Sourcing: убрать write-таблицы полностью, хранить только события (append-only лог).
| CQRS + Outbox (MVP) | Event Sourcing (v2) | |
|---|---|---|
| Write | INSERT INTO payments + INSERT INTO outbox_events | INSERT INTO events (только события, без таблицы состояний) |
| Read | Read Model из Kafka | Read Model из Kafka (те же проекции) |
| Состояние | В таблице payments | Вычисляется риплеем событий или из snapshot |
| Аудит | Только последние значения | Полная история всех изменений (append-only) |
| Идемпотентность | Unique index на Idempotency-Key | Unique на (aggregate_id, version) |
| Шардирование | Сложно (cross-shard FK, JOIN) | Просто — события append-only шардятся по aggregate_id |
| Snapshotting | Не нужен | Каждые N событий — snapshot состояния (иначе риплей растёт) |
Когда переходить: строгий аудит (финтех, комплаенс), потребность в шардировании write-нагрузки (события шардить проще), или сложный домен с ветвящимися процессами. Не для MVP — Event Sourcing добавляет сложность (snapshotting, projection rebuild, schema evolution). См. Модуль 3 (§«Эволюция»).
9.8. Этот паттерн в архетипах
Эволюция БД проявляется по-разному в четырёх архетипах проектов:
| Архетип | Узкое место | Эволюция | Почему критично |
|---|---|---|---|
| Финтех | Write CPU мастера | PgBouncer → Read Replicas → Партиционирование → Шардирование по user_id | Двойное списание недопустимо; шардирование = Saga для cross-shard |
| Git-хостинг | IOPS диска + write CPU | PgBouncer → Read Replicas → Storage Nodes (NVMe) + Citus | git clone крупного репо = чтение packfile в сотни МБ |
| Мессенджер | Read-heavy (лента) | PgBouncer → Read Replicas → Кэш → CQRS | 90% трафика — чтение последних сообщений |
| Лента | Read-heavy + агрегации | PgBouncer → Read Replicas → Кэш → CQRS + ClickHouse | Dashboard, search, сложные запросы |
Общий принцип: не начинайте с шардирования. Каждая ступень добавляет сложность — исчерпайте предыдущую первой. Различается только узкое место (write CPU, IOPS, read-heavy).
9.9. Компромиссы масштабирования БД (Trade-offs)
| Подход | Плюс | Минус | Когда выбирать |
|---|---|---|---|
| PgBouncer (pooling) | 1000 коннектов → 50 серверных; память БД не тратится на fork | session-level features не работают (SET, LISTEN/NOTIFY) | любой production при >50 коннектах |
| Read Replicas | 90% трафика (чтение) уходит с мастера | replication lag → stale reads; read-your-writes сложности | read-heavy: >1000 RPS на чтение |
| Партиционирование | prune по дате; DROP старой партиции мгновенно; vacuum быстрее | unique constraint должен включать partition key; cross-partition FK | таблицы > 100 ГБ, time-series |
| Шардирование | write-скейлинг; каждый шард — независимый мастер | resharding; cross-shard transactions (Saga); cross-shard joins | только когда мастер не справляется с writes |
| CQRS | быстрый read из денормализованной проекции | задержка актуализации (event pipeline); сложность поддержки проекций | dashboards, search, сложная аналитика |
| Event Sourcing | полный аудит; шардирование проще | snapshotting; projection rebuild; schema evolution | строгий аудит (финтех, комплаенс) |
Вывод: не начинайте с шардирования. Каждая ступень (pooling → реплики → кэш → партиции → шарды → CQRS → Event Sourcing) добавляет сложность — исчерпайте предыдущую первой.
🛠 Практические задания
Примерный план работы:
- PgBouncer + Read Replica → R/W split в коде, read-your-writes 5 с после записи.
- Партиционирование
paymentsпо дате → Partition Pruning работает. - ADR-9: «Шардировать или нет?» → 3 альтернативы, количественные метрики, план миграции.
Задача 1: PgBouncer + Read Replica (Read/Write Split в коде)
Добавить в Terraform-манифест из Модуля 8 read-реплику (асинхронная, в другой AZ). Настроить PgBouncer перед мастером (порт 6432, режим transaction).
В коде приложения (Repository layer) реализовать read/write splitting:
- Запись (
INSERT/UPDATE/DELETE) → мастер через PgBouncer. - Чтение (
SELECT) → реплика. - Read-your-writes: в течение 5 секунд после записи пользователем — читать с мастера.
Сценарий: Пользователь делает перевод → UPDATE balance на мастере → сразу читает профиль. Если чтение уйдёт на реплику, он не увидит свой перевод (replication lag). Решение: lastWriteAt в контексте + проверка в Repository.
go// Repository: два пула, Use Case выбирает type Repo struct { readPool *pgxpool.Pool // реплика writePool *pgxpool.Pool // мастер через PgBouncer } func (r *Repo) GetProfile(ctx context.Context, userID string, lastWriteAt time.Time) (*Profile, error) { // Read-your-writes: если запись была < 5 сек назад — читать с мастера if time.Since(lastWriteAt) < 5*time.Second { return r.getFromMaster(ctx, userID) } return r.getFromReplica(ctx, userID) }
✅ Готово, когда:
- Terraform: read-реплика в отдельной AZ + PgBouncer (порт 6432,
pool_mode = transaction) - Repository: два подключения (
read/write), Use Case выбирает маршрут - Read-your-writes: после записи чтение идёт с мастера в течение 5 секунд
- k6: 90% SELECT идут на реплику (проверить через
pg_stat_activity— запросы на реплике, не на мастере)
Задача 2: Партиционирование payments по дате
Создать таблицу payments с партиционированием по created_at (RANGE по месяцам). Проверить Partition Pruning через EXPLAIN.
sql-- Партиционирование по дате CREATE TABLE payments ( id BIGSERIAL, user_id UUID NOT NULL, amount NUMERIC(18, 2) NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ) PARTITION BY RANGE (created_at); CREATE TABLE payments_2024_01 PARTITION OF payments FOR VALUES FROM ('2024-01-01') TO ('2024-02-01'); CREATE TABLE payments_2024_02 PARTITION OF payments FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
✅ Готово, когда:
- Таблица
paymentsпартиционирована поcreated_at(RANGE по месяцам) -
EXPLAINподтверждает Partition Pruning (запрос за последний месяц сканирует одну партицию) -
DROP TABLE payments_2023_01— мгновенно, безDELETEиVACUUM - Unique constraint включает partition key (
UNIQUE(id, created_at))
Задача 3: ADR-9 «Шардировать или нет?»
Студент получает сценарий: P2P-сервис вырос до 8 000 write RPS. Мастер PG (64 vCPU) упирается в 100% CPU. Реплики и кэш уже есть (Модуль 5), но запись — узкое место.
Студент должен принять решение и написать ADR-9:
Альтернативы (мин. 3):
- Вертикальное масштабирование (64 → 128 vCPU) — сколько стоит, когда кончится?
- Партиционирование таблиц (поможет ли от write CPU? — нет, партиции на одном мастере).
- Шардирование по
user_id(2 шарда → 4 → 8). Resharding-план. - Переход на YDB (native sharding, eventual consistency).
Метрики: write RPS до/после, стоимость/мес, сложность миграции (часы), риск cross-shard transactions.
План миграции: как перейти с 1 мастера на 2 шарда без простоя (dual-write → backfill → cut-over).
✅ Готово, когда:
- ADR-9: минимум 3 альтернативы с количественным сравнением (write RPS, $/мес, риск)
- Учитывается cross-shard transaction risk (перевод между пользователями на разных шардах → Saga)
- План миграции без простоя (dual-write → backfill → cut-over)
- Обосновано, почему вертикальное масштабирование — временное решение
⚠️ Антипаттерн для разбора
Студенту дается сценарий масштабирования БД для анализа.
Система: P2P-переводы, 10 000 RPS (90% чтение, 10% запись).
Инфраструктура:
— Один Managed PostgreSQL кластер (мастер + 2 реплики).
— НЕТ PgBouncer (приложение открывает по 50 коннектов на каждый под × 20 подов = 1000 коннектов).
— Чтение и запись — оба на мастере (read replica не используется).
— Таблица payments: 800 ГБ, без партиционирования.
Симптомы:
— max_connections exhausted → новые запросы падают с "too many connections".
— VACUUM на payments идёт 4 часа → bloat, деградация индексов.
— p99 записи > 2 секунд (CPU 100% на мастере).
— Команда предлагает "просто добавить ещё реплику".
Вопросы студенту:
- Почему «просто добавить реплику» не решит проблему с записью?
- Что нужно сделать до шардирования? (PgBouncer, read/write split, партиционирование).
- Почему 1000 коннектов без пулера — это проблема, хотя
max_connectionsможно поднять?
📐 Задание по визуализации (Mermaid)
Студенту необходимо нарисовать эволюцию масштабирования БД: от одного мастера до шардирования.
Шаблон для ориентира:
🧩 Проверь себя
🤖 Инструкция для LLM (Coding Plan Assistant) — Режим Модуля 9
Привет! Я начинаю Модуль 9 курса «Архитектура распределённых систем».
Дай короткий ответ (2–3 предложения): почему не начинайте с шардирования, и какая линейка эволюции БД правильная (PgBouncer → Read Replicas → Кэш → Партиционирование → Шардирование → CQRS)?
Объясни разницу между партиционированием и шардированием. Почему партиционирование не снижает write-CPU, а шардирование — снижает?
Покажи Антипаттерн (1000 коннектов без PgBouncer, read replica не используется, таблица 800 ГБ без партиционирования) и попроси объяснить, почему «просто добавить реплику» не решит проблему с записью.
Дай сценарий: «8 000 write RPS, мастер на пределе CPU». Попроси написать ADR-9: минимум 3 альтернативы (вертикальное масштабирование / партиционирование / шардирование / YDB), количественные метрики, план миграции.
НЕ ПИШИ за меня ADR. Дождись моего ответа с архитектурным планом. После этого дай фидбек по чек-листу ментора и укажи на слепые зоны (например, «нет PgBouncer», «read/write split не реализован», «не учтён cross-shard transaction risk»).
📝 Чек-лист ментора
-
Connection Pooling: PgBouncer настроен (порт 6432,
pool_mode = transaction). Приложение подключается через пулер, не напрямую к PG. -
Read/Write Split: Запись → мастер (через PgBouncer). Чтение → read-реплика. Реализован read-your-writes (N секунд после записи → мастер).
-
Партиционирование: Большие таблицы (payments, outbox_events) партиционированы по дате. Partition Pruning работает (
EXPLAINподтверждает). -
Шардирование: Если write-нагрузка близка к пределу мастера — написан ADR-9 с 3+ альтернативами, количественными метриками и планом миграции без простоя.
-
CQRS: Если есть сложные read-запросы (агрегации, dashboard) — описана event-driven проекция через Outbox → Kafka → ClickHouse.
-
Event Sourcing: Если нужен строгий аудит или шардирование write-нагрузки — описан план перехода на Event Sourcing (snapshotting, projection rebuild).
-
Связь с Модулем 5: Кэш Cache-Aside + SWR снимает 90% read-нагрузки до реплики.
-
Связь с Модулем 3: CQRS + Outbox = event-driven проекции. Консьюмер Kafka обновляет read-модель.
-
Связь с Модулем 4: Cross-shard transactions → Saga (оркестрация компенсаций).
-
Mermaid-диаграмма: Отображает эволюцию БД от одного мастера до шардирования.
-
ADR-9: Student сформулировал ADR «Шардировать или нет?» (контекст, альтернативы, решение, метрики, план миграции).
🔗 Мостик к Модулю 10 (Chaos Engineering + Cost Awareness)
В конце ревью скажи студенту:
- «Отлично, мы освоили эволюцию масштабирования БД: PgBouncer, read-реплики, партиционирование и — в крайнем случае — шардирование. Мы теперь знаем, как масштабировать БД без простоя (dual-write → backfill → cut-over). Но в реальном мире дата-центры не предупреждают перед сбоями. Как доказать, что наша система действительно переживет падение целой Зоны Доступности, отказ мастера БД (failover на реплику) или отключение сети на 30 секунд? В Модуле 10 мы займемся Chaos Engineering: будем намеренно "убивать" поды, рвать сеть и смотреть, как отрабатывают Circuit Breaker (Модуль 5), Saga (Модуль 4) и failover БД (Модуль 9), измеряя стоимость этих отказов в Error Budget и ROI.»
Если ты прошёл курс «Git Hosting: Serverless MVP», ты уже видел этот паттерн на упрощённом стеке: YDB Serverless (native sharding, eventual consistency) вместо Managed PostgreSQL. Паттерн тот же — инфраструктура разная. Здесь мы разбираем его на полном стеке, с глубиной и trade-offs.