Модуль 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 мастера.

Четыре зоны риска:

  1. «Слишком много коннектов» — PgBouncer в transaction режиме: 1000 клиентских коннектов → 50 серверных.
  2. «800 ГБ и нет скорости» — PgBouncer → Read Replicas → партиционирование по дате. Шардирование — только когда всё остальное исчерпано.
  3. «Платим за воздух» — K8s + HPA для синхронных API, Serverless для воркеров.
  4. «Просто добавить реплику» — не решает 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)
WriteINSERT INTO payments + INSERT INTO outbox_eventsINSERT INTO events (только события, без таблицы состояний)
ReadRead Model из KafkaRead Model из Kafka (те же проекции)
СостояниеВ таблице paymentsВычисляется риплеем событий или из snapshot
АудитТолько последние значенияПолная история всех изменений (append-only)
ИдемпотентностьUnique index на Idempotency-KeyUnique на (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 CPUPgBouncer → Read Replicas → Storage Nodes (NVMe) + Citusgit clone крупного репо = чтение packfile в сотни МБ
МессенджерRead-heavy (лента)PgBouncer → Read Replicas → Кэш → CQRS90% трафика — чтение последних сообщений
ЛентаRead-heavy + агрегацииPgBouncer → Read Replicas → Кэш → CQRS + ClickHouseDashboard, search, сложные запросы

Общий принцип: не начинайте с шардирования. Каждая ступень добавляет сложность — исчерпайте предыдущую первой. Различается только узкое место (write CPU, IOPS, read-heavy).

9.9. Компромиссы масштабирования БД (Trade-offs)

ПодходПлюсМинусКогда выбирать
PgBouncer (pooling)1000 коннектов → 50 серверных; память БД не тратится на forksession-level features не работают (SET, LISTEN/NOTIFY)любой production при >50 коннектах
Read Replicas90% трафика (чтение) уходит с мастера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) добавляет сложность — исчерпайте предыдущую первой.


🛠 Практические задания

Примерный план работы:

  1. PgBouncer + Read Replica → R/W split в коде, read-your-writes 5 с после записи.
  2. Партиционирование payments по дате → Partition Pruning работает.
  3. 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):

  1. Вертикальное масштабирование (64 → 128 vCPU) — сколько стоит, когда кончится?
  2. Партиционирование таблиц (поможет ли от write CPU? — нет, партиции на одном мастере).
  3. Шардирование по user_id (2 шарда → 4 → 8). Resharding-план.
  4. Переход на 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% на мастере).
  — Команда предлагает "просто добавить ещё реплику".

Вопросы студенту:

  1. Почему «просто добавить реплику» не решит проблему с записью?
  2. Что нужно сделать до шардирования? (PgBouncer, read/write split, партиционирование).
  3. Почему 1000 коннектов без пулера — это проблема, хотя max_connections можно поднять?

📐 Задание по визуализации (Mermaid)

Студенту необходимо нарисовать эволюцию масштабирования БД: от одного мастера до шардирования.

Шаблон для ориентира:

Эволюция БД: от мастера к шардированию и CQRS

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

1. 1000 коннектов к PG без PgBouncer — что произойдёт?
2. Как эволюция БД проявляется в Git-хостинге?
3. Зачем нужно партиционирование таблицы payments по месяцам?
4. Пользователь сделал UPDATE, затем сразу SELECT. Чтение ушло на read-реплику. Что произойдёт?
5. При 8000 write RPS мастер на пределе. Что НЕ поможет? — выберите все подходящие

🤖 Инструкция для 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.