Часть II: Строительные блоки
Глава 7

Data Layer — базы данных, кэши и потоки данных

Проблема

Проблема: Ваше приложение должно хранить данные, читать их быстро и не терять при этом ни одного байта. Звучит просто. Но между «просто сохранить в файл» и production-архитектурой — пропасть размером в пару даунтаймов и потерю данных клиентов.


#Введение: Data layer как сердце системы

Представьте, что ваше приложение — это человек. API — это руки, которые принимают запросы. Бизнес-логика — мозг, который принимает решения. Фронтенд — лицо, которое улыбается пользователю.

А data layer? Это сердце и кровеносная система. Если оно упало — всё остальное бесполезно.

В предыдущей главе мы разобрали, как строить API. Теперь давайте посмотрим, что происходит под капотом — как данные хранятся, как к ним обращаться, и как не потерять их, когда система растёт.

Эта глава получилась большой, и это не случайно. Data layer — та часть системы, где принимаются самые дорогие решения. Выбор базы данных, схемы кэширования или архитектурного паттерна данных — это не «технический выбор». Это архитектурное решение, которое будет влиять на всё: от скорости разработки до стоимости инфраструктуры, от consistency модели до resilience.

Мы пройдём по четырём ключевым слоям:

  1. Реляционные БД — основа основ, которая никуда не уходит
  2. NoSQL — когда реляционки не хватает, и как не ошибиться с выбором
  3. Архитектурные паттерны данных — CQRS, Event Sourcing, Outbox, Saga, CDC (обзорно — глубокое погружение в CQRS и Event Sourcing — в приложении 07a)
  4. Кэширование — краткий обзор (детали — в Главе 21)

Поехали.


#Реляционные базы данных: фундамент, который не качнётся

#PostgreSQL — швейцарский нож веб-архитектуры

Если бы мне пришлось выбрать единственную базу данных для веб-проекта без права на ошибку — я бы взял PostgreSQL. Без колебаний.

PostgreSQL прошёл путь от «академической игрушки» (как её называли в 2000-х) до самой уважаемой реляционной БД в индустрии. Его любят не за маркетинг — его любят за то, что он просто работает. Годами. Без даунтаймов. Без сюрпризов.

Что делает PostgreSQL особенным:

  • Расширяемость. Вы можете добавить тип данных, индекс, даже язык программирования для хранимых процедур. На практике это означает, что PostgreSQL адаптируется под вашу задачу, а не вы под него.
  • Полнотекстовый поиск (Full-Text Search). Не нужен отдельный Elasticsearch на старте — встроенный FTS PostgreSQL покроет 80% потребностей.
  • JSONB для гибридных схем. Храните реляционные данные и JSON-документы в одной БД. Это не «MongoDB внутри PostgreSQL», а вполне полноценная гибридная модель.
  • Расширения. PostGIS для геоданных, pg_stat_statements для производительности, pg_partman для партиционирования, hypopg для анализа индексов, pg_cron для планировщика.

Но главное преимущество PostgreSQL не в фичах. Главное — это экосистема и сообщество. Когда вы гуглите проблему, вы находите не «может, кто-то сталкивался», а детальный разбор с планом запроса, ссылками на документацию и несколькими вариантами решения.

Понимание MVCC: цена чтения без блокировок.

PostgreSQL использует MVCC (Multi-Version Concurrency Control) — каждая транзакция видит снэпшот данных на момент своего начала. Это значит, что чтение никогда не блокирует запись, а запись не блокирует чтение. Звучит идеально, но есть нюанс: старые версии строк остаются в таблице (dead tuples), и их нужно чистить. Это делает VACUUM не просто рекомендацией, а обязательным процессом.

Если вы не настроили autovacuum или он не успевает — таблица «раздувается» (bloat). SQL-запрос внезапно читает в 10 раз больше данных, чем нужно, потому что среди живых строк разбросаны мёртвые. Мониторинг bloat — обязательная часть администрирования PostgreSQL.

PostgreSQL — это как кирпич. Не самый модный материал, но на нём построены города.

#Schema migrations: как менять схему без даунтайма

Вот сценарий, который знаком каждому, кто работал с БД в продакшене. Вам нужно добавить колонку в таблицу с миллионами записей. В dev-окружении это занимает 200 миллисекунд. Вы пушите миграцию. Начинается даунтайм.

Почему? Потому что ALTER TABLE в некоторых СУБД блокирует таблицу на запись. А если таблица большая — и на чтение тоже.

Стратегии онлайн-миграций:

1. Expand-Migrate-Contract (Parallel Change)

Классический паттерн для zero-downtime миграций:

  • Expand: Добавляем новую колонку/таблицу, при этом старая структура продолжает работать. Пишем данные в оба места, читаем со старого.
  • Migrate: Запускаем бэкграунд-процесс, который переносит исторические данные. Приложение продолжает работать.
  • Contract: Переключаем чтение на новое место. Удаляем старую структуру.

Пример с переименованием колонки:

-- Phase 1: Expand — добавляем колонку, allowing NULL
ALTER TABLE users ADD COLUMN email_address VARCHAR(255);

-- Приложение пишет и в email, и в email_address
-- Приложение читает из email (старое поле)
-- Фоновый процесс заполняет email_address для существующих записей

-- Phase 2: Migrate — переключаем чтение на новую колонку
-- Приложение теперь читает из email_address
-- Приложение больше не пишет в email

-- Phase 3: Contract — удаляем старую колонку
ALTER TABLE users DROP COLUMN email;

В PostgreSQL ALTER TABLE ... ADD COLUMN с DEFAULT NULL — мгновенная операция (только метаданные). А вот ADD COLUMN ... DEFAULT 'value' перепишет всю таблицу. Всегда проверяйте в документации: что блокирует, а что нет.

2. pgroll и migration-as-code

Современный подход — использовать инструменты вроде pgroll от команды Supabase или sqldef. Они генерируют безопасные миграции, которые не блокируют таблицы. Но идеального решения нет — каждая миграция всё равно требует осмысления.

3. Балансировка через read replicas

Для особенно тяжёлых миграций (например, пересоздание таблицы с новым ключом шардирования): создаёте новую таблицу на read replica, переключаете трафик, повышаете replica до master. Дорого, но zero-downtime.

#Indexing: B-tree, GiST, GIN, BRIN

Индексы — это суперсила, которая может превратить запрос за 5 секунд в запрос за 5 миллисекунд. Или уничтожить производительность записи.

Понимание типов индексов — это не академическое знание. Это разница между «БД тормозит» и «БД летает».

B-tree (Balanced Tree) — стандартный дефолт.

Работает для: =, <, >, <=, >=, BETWEEN, IN, LIKE 'prefix%' (prefix search), ORDER BY, DISTINCT.

Когда поставить B-tree — очевидно почти всегда. PostgreSQL создаёт его по умолчанию для PRIMARY KEY и UNIQUE constraints.

GiST (Generalized Search Tree) — для сложных типов данных.

Работает для: полнотекстовый поиск (tsvector), геоданные (PostGIS), range-типы, массивы.

Если вы ищете «все точки в радиусе 10 км» или «все события, пересекающиеся с интервалом» — GiST ваш выбор.

GIN (Generalized Inverted Index) — для композитных значений.

Работает для: JSONB (@>, ?, ?|, ?&), массивы, полнотекстовый поиск (GIN часто быстрее GiST для FTS).

GIN — это когда вам нужно найти документы, содержащие определённые ключи или значения в JSONB-колонке. Или записи, где массив тегов содержит определённый элемент.

BRIN (Block Range Index) — для огромных таблиц с упорядоченными данными.

Работает для: логов, time-series, данных, которые добавляются в хронологическом порядке.

BRIN — самый недооценённый тип индекса. Если у вас есть таблица с миллиардом записей, где данные добавляются последовательно (например, логи по времени), BRIN-индекс будет в тысячи раз меньше B-tree и почти так же эффективен.

-- BRIN index для логов по времени
CREATE INDEX idx_logs_created_at ON logs USING BRIN(created_at)
  WITH (pages_per_range = 32);

-- GIN для поиска по JSONB
CREATE INDEX idx_users_preferences ON users USING GIN(preferences);

Composite indexes и их порядок:

Порядок колонок в составном индексе критичен. Индекс на (a, b) эффективен для запросов с WHERE по a или по a AND b, но бесполезен для WHERE только по b. Это называется «rule of the leftmost prefix».

-- Эффективен: порядок колонок совпадает с условиями
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at);
-- EXPLAIN покажет Index Scan для:
SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC;

-- Бесполезен: условие не начинается с первой колонки индекса
-- SELECT * FROM orders WHERE created_at > '2024-01-01';

Partial indexes для точечных задач:

Иногда вам не нужен индекс на все строки таблицы. Partial index индексирует только подмножество:

-- Индексируем только активные заказы
CREATE INDEX idx_orders_active ON orders(order_id)
  WHERE status IN ('pending', 'processing');
-- Поиск по активным заказам — смотрит в компактный индекс вместо полной таблицы

Index-only scans:

Когда все нужные для запроса колонки содержатся в самом индексе, PostgreSQL может вообще не ходить в таблицу (heap). Это — index-only scan. Разница — в десятки раз. Секрет успеха — include index:

-- Все поля для типового запроса — в индексе
CREATE INDEX idx_users_email_covering ON users(email)
  INCLUDE (name, avatar_url);
-- SELECT name, avatar_url FROM users WHERE email = '...' — только индекс, таблица не читается

Практическое правило: если индекс меньше 1% памяти таблицы — скорее всего, вы где-то ошиблись. Если индекс больше 50% таблицы — вы тоже ошиблись. BRIN решает эту проблему для упорядоченных данных.

#Connection pooling: почему «один connection — один поток» — не работает

Каждое подключение к PostgreSQL — это отдельный процесс на сервере (не поток, а процесс). 1000 подключений = 1000 процессов. У каждого свой стек памяти. Это дорого.

Более того, при большом количестве одновременных подключений производительность PostgreSQL падает из-за контекст-переключений. Эмпирическое правило: PostgreSQL показывает лучшую производительность при 2-8 параллельных запросах на ядро CPU. Всё что сверху — деградация.

PgBouncer — лёгкий connection pooler, который работает на уровне протокола. Поддерживает три режима:

  • Session pooling — connection закрепляется за клиентом на всю сессию. Самый простой режим.
  • Transaction pooling — connection возвращается в пул после завершения транзакции. Позволяет обслуживать больше клиентов меньшим числом соединений. Но требует, чтобы приложение не использовало session-level features (temporary tables, prepared statements).
  • Statement pooling — connection возвращается после каждого statement. Самый агрессивный.

Типичная конфигурация для веб-приложения: 20-50 соединений в PgBouncer, 4-8 worker processes в приложении, transaction pooling.

RDS Proxy — managed-версия от AWS. Работает похоже, но интегрируется с IAM (не нужно хранить пароли в приложении) и автоматически масштабируется.

Важный нюанс: connection pooling — это не только про базу данных. На стороне приложения тоже нужен pool. Большинство ORM (Prisma, TypeORM, Django ORM) имеют встроенные пулы — убедитесь, что они правильно настроены (обычно 5-10 соединений на инстанс приложения).

#Read replicas: когда мастер не справляется

Сценарий: ваше приложение растёт, и вы замечаете, что SELECT-запросы начинают влиять на latency записи. База данных всё ещё справляется, но уже не так уверенно.

Решение — read replicas.

Как это работает:

  • Master-нода принимает все операции записи
  • Read replicas получают изменения через streaming replication (асинхронно или синхронно)
  • Приложение отправляет запросы на чтение (SELECT) на replicas, запись — на master

Когда это имеет смысл:

  • Соотношение read/write > 10:1
  • Запросы на чтение требуют тяжёлых операций (агрегации, JOIN'ы с большими таблицами)
  • Нужна географическая близость данных для read workloads (replica в другом регионе)

Когда НЕ сработает:

  • Если write workload растёт — replicas не помогут, нужен scaling master'а
  • Если нужно строгое read-after-write consistency — придётся читать с master или использовать synchronous_commit
  • Если запросы на чтение must be always up-to-date — асинхронная репликация даёт задержку (lag), иногда значительную

На практике read replicas — это первая ступень масштабирования. Они дёшевы, относительно просты в настройке (по крайней мере в managed-сервисах) и дают заметный прирост производительности.

Failover и High Availability:

Read replicas дают ещё один бонус — если мастер упал, одна из replicas может быть повышена до мастера. В managed-сервисах (RDS, Cloud SQL, Azure Database) это делается кнопкой или автоматически. В self-hosted PostgreSQL — через Patroni, repmgr или pg_auto_failover.

Но failover — не мгновенный:

  • Асинхронная репликация: при падении мастера теряются транзакции, которые не успели дойти до replica. Это называется RPO (Recovery Point Objective) — в худшем случае он равен replication lag
  • Promotion replica до master занимает секунды или минуты (RTO — Recovery Time Objective)
  • Если используете синхронную репликацию — данные не теряются (RPO=0), но latency записи растёт

Стандартная практика: 1 master + 2 read replicas. Одна replica — для чтения, вторая — для failover (она не принимает трафик, пока не понадобится). Это минимальная конфигурация для HA.

Простое правило: пока влезаете в одну PostgreSQL инстанцию — сидите в ней. Read replicas — это первое, что вы добавите, когда упрётесь. Partitioning — второе. Sharding — только когда partition перестанут спасать.


#NoSQL — когда SQL не справляется

Ирония в том, что в 90% случаев SQL справляется отлично. Но есть задачи, где реляционная модель — не лучший выбор.

Не попадайтесь в ловушку «SQL или NoSQL — один победит». Это не война. Это выбор правильного инструмента под задачу.

#Document Stores (MongoDB, Firestore)

Когда выбрать:

  • У вас слабо структурированные данные, где схема меняется часто
  • Объекты в коде естественно маппятся на документы (не нужно ORM с mapping'ом)
  • Вы готовы пожертвовать consistency ради гибкости схемы
  • Вам нужен built-in horizontal scaling (шардирование из коробки в MongoDB)

Типичные сценарии:

  • Content management системы (каждая статья может иметь разный набор полей)
  • Каталоги товаров с динамическими атрибутами
  • Пользовательские сессии и профили
  • Event logging (когда структура события не фиксирована)

Когда НЕ выбирать:

  • Вам нужны JOIN'ы между разными типами сущностей — MongoDB не умеет (по крайней мере эффективно)
  • Вам нужны сложные транзакции с ACID — MongoDB добавил ACID-транзакции в 4.0, но с ограничениями
  • Ваши данные — чисто реляционные (orders, items, payments) — вы выстрелите себе в ногу
  • Вам нужна строгая consistency на чтение — MongoDB по умолчанию не гарантирует strong consistency

[Anecdotal] Я видел проект, где выбрали MongoDB для интернет-магазина. «Гибкость схемы же!» Через два года они мигрировали на PostgreSQL. Потому что заказы — это реляционно. Всегда.

#Key-Value (Redis, DynamoDB)

Redis и DynamoDB — это совершенно разные звери, хотя оба — key-value stores.

Redis — in-memory data structure store. Всё в памяти (с опциональной персистентностью на диск). Микросекундная latency. Поддерживает сложные типы данных: списки, сеты, хеши, sorted sets, HyperLogLog, геоданные, streams.

Где Redis незаменим:

  • Кэш (но об этом ниже)
  • Rate-limiting (INCR + EXPIRE — всё, что нужно)
  • Real-time leaderboards (Sorted Sets — идеально)
  • Pub/Sub для лёгкого messaging
  • Session store с TTL
  • Distributed locks (Redlock, хотя и с оговорками)

DynamoDB — managed key-value + document store от AWS. Полностью бессерверный, масштабируется до любого объёма. Главное — спроектировать ключи правильно.

Сильные стороны DynamoDB:

  • Абсолютный zero-ops — не нужно думать про инстансы, шардинг, репликацию
  • Single-digit millisecond latency на любом объёме
  • Интеграция с AWS-экосистемой (Lambda, S3, Kinesis, DAX)
  • Автоматическое масштабирование (настраивается через autoscaling или on-demand)

Ключевые ограничения:

  • Нет JOIN'ов — нужно проектировать data model с учётом access patterns
  • Размер одного item — 400KB максимум
  • GSI (Global Secondary Index) — eventual consistency
  • Query patterns диктуются ключами, а не данными

Когда выбирать Key-Value:

  • Вам нужен сверхбыстрый доступ по ключу
  • Сложные запросы не нужны (или вы готовы их реализовать на стороне приложения)
  • Данные нормально шардируются по partition key

#Column-family (Cassandra, ScyllaDB)

Cassandra — это не про колонки в Excel. Это про распределённую column-family модель, где данные организованы в строки с переменным числом колонок.

Ключевая идея: Вы проектируете таблицы под конкретные запросы, а не под нормализацию данных. Cassandra — это «No JOIN'ы, no problem».

Когда Cassandra сияет:

  • Time-series данные: логи, метрики, события (с TTL)
  • Write-heavy workloads (Cassandra пишет быстрее, чем читает)
  • Multi-datacenter deployments с актив-актив репликацией
  • Сценарии, где недоступность хуже, чем stale data (Cassandra — AP в терминах CAP)

Когда Cassandra бьёт по рукам:

  • Запросы «WHERE some_column = ?» без partition key — full scan (убийственно)
  • Сложные агрегации — не для Cassandra
  • Транзакции — есть lightweight transactions, но они дорогие и медленные
  • Изменение схемы — легко, но не все migration-стратегии безопасны

ScyllaDB — совместимая с Cassandra СУБД на C++, использующая архитектуру shared-nothing. Обещает значительно бОльшую производительность на тех же ресурсах. Для high-load сценариев — стоит смотреть в её сторону.

Глубже: как Cassandra хранит данные.

Под капотом Cassandra использует структуру из трёх уровней:

  1. Memtable — in-memory буфер, куда сразу пишутся данные (быстрая запись)
  2. Commit log — WAL на диске для durability (на случай краша до flush на диск)
  3. SSTable — неизменяемый файл на диске, куда сбрасывается Memtable при заполнении

Компактизация (compaction) — фоновый процесс, который мержит SSTable'ы, удаляет tombstones (метки удаления) и объединяет старые версии строк. Не настроенный compaction убивает производительность Cassandra быстрее всего остального.

Выбор стратегии компактизации:

  • Size-Tiered (STCS) — дефолт. Мержит SSTable'ы похожего размера. Просто, но может оставлять много мелких файлов.
  • Leveled (LCS) — разбивает SSTable'ы на уровни. Лучше для read-heavy, но больше write amplification.
  • Time-Window (TWCS) — для time-series данных. SSTable'ы группируются по временным окнам и мержатся только внутри окна. Идеально для логов и метрик.

Cassandra силён, но не универсален. Если ваша модель данных меняется каждый месяц — вы будете страдать. Cassandra требует дисциплины дизайна: «сначала access patterns, потом таблицы».

#Graph DB (Neo4j, DGraph)

Когда связи между сущностями важнее самих сущностей — графовые БД показывают свою силу.

Neo4j — самый зрелый графовый движок. Использует язык запросов Cypher. Хранит узлы (nodes) и рёбра (relationships) как first-class citizens.

Паттерны, где Neo4j кратно эффективнее реляционных БД:

  • Рекомендательные системы («что ещё покупают те, кто купил X?»)
  • Анализ социальных графов (друзья друзей)
  • Fraud detection (поиск колец и необычных паттернов связей)
  • Анализ зависимостей (Impact Analysis — «кто использует этот сервис?»)
  • Knowledge graphs (Wikipedia, корпоративные базы знаний)

DGraph — графовая БД от бывших Googler'ов. Использует GraphQL+- (похож на GraphQL). Отличается лучшей горизонтальной масштабируемостью по сравнению с Neo4j.

Графовые БД — нишевый, но мощный инструмент. Не пытайтесь хранить в них всё — используйте для задач, где отношения — первая сущность.

#Decision Matrix: SQL vs NoSQL — что выбрать

Критерий Реляционная Document Key-Value Column-Family Graph
Сложные запросы Превосходно Ограниченно Плохо Плохо Хорошо для графов
JOIN'ы Есть Нет Нет Нет Есть (графовые)
ACID транзакции Да Ограниченно Нет Нет Ограниченно
Гибкость схемы Низкая Высокая Максимум Средняя Средняя
Горизонтальное масштабирование Сложно Хорошо Отлично Отлично Сложно
Производительность чтения (по ключу) Хорошо Хорошо Отлично Удовлетворительно Хорошо
Write throughput Средний Хороший Отличный Отличный Средний
Multi-DC репликация Сложно Хорошо Хорошо Отлично Средняя
Зрелость 50+ лет 15+ лет 15+ лет 15+ лет 15+ лет
Когда использовать Всё по дефолту Слабоструктур. данные Кэш, сессии, счетчики Time-series, logs Соцсети, рекомендации, fraud

Goldilocks-правило: Начните с PostgreSQL. Если упрётесь в ограничения — добавьте Redis для кэша. Если всё ещё не хватает — смотрите на специфические БД под задачу. Не наоборот.


#Архитектурные паттерны данных

Этот раздел — обзорный. Паттерны CQRS и Event Sourcing детально разобраны в приложении 07a-cqrs-in-depth.md. Здесь мы пробежимся по ключевым идеям и свяжем их в общую картину.

#CQRS — Command Query Responsibility Segregation

Идея: Разделите модели для чтения и для записи. Вместо одной модели, которая делает и то и другое плохо, используйте две — каждая под свою задачу.

Когда это нужно:

  • Вы не можете сделать эффективную read-модель на той же схеме, что и write
  • Read и write workload'ы имеют разные требования к производительности
  • Одна модель становится слишком сложной из-за разной логики чтения и записи

Типичная реализация:

  • Команды (Command) — мутируют данные (Create, Update, Delete)
  • Запросы (Query) — только читают
  • Проекция (Projection) — обрабатывает события и обновляет read-модель

CQRS — это не про разделение на два сервиса. Это про разделение моделей. Даже в одном монолите вы можете иметь CQRS на уровне классов или модулей.

Подробно (с кодом, компромиссами, анти-паттернами) — в 07a-cqrs-in-depth.md.

#Event Sourcing — храним факты, а не состояние

Идея: Вместо хранения текущего состояния сущности храните последовательность событий, которые привели к этому состоянию. Текущее состояние — это projection (снэпшот) всей истории событий.

Связь с CQRS: Event Sourcing и CQRS — естественные партнёры. CQRS говорит «раздели модели», Event Sourcing — «храни события как источник правды для write-модели».

Когда имеет смысл:

  • Нужен полный audit trail (аудит — не побочный эффект, а основа хранения)
  • Нужно «отмотать» состояние назад (debugging, реконструкция)
  • События — ваш естественный язык (всё, что происходит — это событие)
  • Много конкурирующих изменений одного ресурса

Расплата:

  • Сложность запросов — чтобы получить текущее состояние, нужно aggregare события (снэпшоты помогают, но не решают)
  • Event schema evolution — события живут вечно. Изменять их — наука
  • Версионирование — каждое событие имеет версию, старые события не меняются

Детали — в 07a-cqrs-in-depth.md (раздел Event Sourcing).

#Outbox Pattern — надёжная публикация событий

Проблема классическая: Вы сохранили заказ в БД и опубликовали событие order.created в Kafka/RabbitMQ/любой брокер. Но что, если сообщение ушло, а транзакция откатилась? Или транзакция закоммитилась, а брокер упал и сообщение потерялось? Вы в неконсистентном состоянии.

Решение Outbox:

Вместо прямой публикации в брокер — сохраняем событие в той же транзакции, что и бизнес-сущность. Для этого заводим таблицу outbox:

-- В одной транзакции с бизнес-данными
BEGIN;

INSERT INTO orders (id, user_id, total, status)
VALUES ('order-123', 'user-456', 1500.00, 'created');

INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload, created_at)
VALUES ('evt-001', 'order', 'order-123', 'OrderCreated',
        '{"order_id": "order-123", "total": 1500.00}',
        NOW());

COMMIT;

Отдельный процесс (Outbox Publisher) читает из outbox и публикует в брокер. После успешной публикации — удаляет запись из outbox.

Варианты реализации:

  1. Polling Publisher — фоновый процесс, который периодически читает SELECT * FROM outbox WHERE processed_at IS NULL ORDER BY created_at LIMIT 100
  2. CDC-based Publisher (см. ниже) — использует Change Data Capture для чтения изменений outbox-таблицы
  3. Transactional Outbox + Message Queue — некоторые брокеры (Kafka в режиме exactly-once) поддерживают двухфазную фиксацию, но это редкость на практике

Детали реализации:

  • Убедитесь, что публикация идемпотентна (брокер должен deduplicate по ID события)
  • Мониторьте размер outbox-таблицы — если Publisher упал, outbox растёт
  • Используйте TTL или партиционирование для очистки старых записей
  • Обрабатывайте duplicate delivery — при краше Publisher может опубликовать одно событие дважды. Consumer должен быть идемпотентным
  • Обеспечьте ordering — для некоторых событий важен порядок обработки. Используйте partition_key в Kafka или сохраняйте sequence number, чтобы consumer мог восстановить порядок

Outbox vs CDC: что выбрать?

Критерий Outbox CDC
Контроль Полный (вы пишете код) Зависит от плагина БД
Сложность Ниже Выше (Debezium + Kafka)
Latency Зависит от poll interval Почти реального времени
Изменение схемы Вы контролируете Автоматически подхватывает
Источник правды Приложение База данных
Когда использовать У вас есть контроль над кодом Legacy system или строгая синхронизация

В реальном production часто используется комбинация: Outbox для критических сообщений (заказы, платежи) и CDC для синхронизации данных между микросервисами (профили, настройки).

#Saga Pattern — распределённые транзакции

Когда одна операция затрагивает несколько сервисов, каждая со своей БД — двухфазные транзакции (2PC) плохо работают в распределённых системах. Saga — альтернатива.

Core idea: Вместо одного ACID-атомарного действия — последовательность локальных транзакций, каждая с компенсирующей операцией (compensating transaction) на случай сбоя.

Choreography-based Saga: Каждый сервис публикует события после завершения своей транзакции. Другие сервисы подписываются и реагируют. Нет центрального координатора.

Order Service: create order → publish OrderCreated
Payment Service: ← listen OrderCreated → process payment → publish PaymentProcessed
Inventory Service: ← listen PaymentProcessed → reserve items → publish ItemsReserved
Notification Service: ← listen ItemsReserved → send confirmation → publish Notified

Если Payment Service падает на обработке — публикует PaymentFailed. Order Service слушает это и запускает компенсацию (отменяет заказ).

Orchestration-based Saga: Центральный координатор (Orchestrator) управляет шагами.

Order Orchestrator:
  1. Command: CreateOrder → response: ok
  2. Command: ProcessPayment → response: fail!
  3. Command: CancelOrder (compensation)
  4. Command: NotifyAboutFailure

Что выбрать?

Критерий Choreography Orchestration
Сложность Проще для простых сценариев Дороже, сложнее
Наблюдаемость Сложно понять, что происходит Единая точка мониторинга
Связанность Меньше (сервисы слабо связаны) Больше (Orchestrator знает всё)
Простота при увеличении шагов Становится нечитаемым Добавляем ещё один шаг

Saga Pattern — мощный, но не бесплатный. Вы жертвуете ACID изоляцией: одна Saga может видеть промежуточное состояние другой. Нужны идемпотентные операции и продуманные компенсации.

#Change Data Capture (CDC) — Debezium, логическая репликация

Идея: Читайте изменения в базе данных на уровне WAL (Write-Ahead Log) и передавайте их как поток событий. CDC превращает БД в event source.

Как это работает (на примере Debezium + PostgreSQL):

  1. Debezium подключается к PostgreSQL через логическую репликацию
  2. PostgreSQL отправляет каждое изменение (INSERT, UPDATE, DELETE) в репликационный слот
  3. Debezium преобразует их в события и публикует в Kafka
// Событие CDC после UPDATE пользователя
{
  "op": "u",              // operation: u = update, c = create, d = delete
  "before": {"id": 1, "email": "old@example.com"},
  "after":  {"id": 1, "email": "new@example.com"},
  "source": {
    "db": "myapp",
    "table": "users",
    "lsn": 123456789,
    "ts_ms": 1689000000000
  }
}

Типичные применения CDC:

  • Кэш-инвалидация: изменения в БД → обновление кэша
  • Поддержка поискового индекса: изменения происходят в основной БД, CDC синхронизирует Elasticsearch или Meilisearch
  • Хранилище данных: streaming данных из транзакционной БД в аналитическую (Redshift, ClickHouse)
  • Обогащение событий: микросервисы могут реагировать на изменения данных других сервисов
  • Audit trail: все изменения данных сохраняются как события

Когда CDC не нужен:

  • Для ETL с latency > 5 минут — обычный batch подойдёт и дешевле
  • Если данных мало — Debezium/Kafka — избыточная сложность
  • Если вы контролируете код — Outbox Pattern может быть проще

#Кэширование (обзорно)

Кэширование — одна из самых мощных и одновременно опасных техник в арсенале архитектора. Детально — в Главе 21 (Cache, CDN, Edge). Здесь — необходимый минимум, чтобы понимать, как кэш вписывается в data layer.

#Уровни кэширования

1. HTTP-кэширование (Cache-Control, ETag)

Самый недооценённый кэш. Работает до того, как запрос дошёл до вашего сервера.

  • Cache-Control: public, max-age=3600 — ответ можно кэшировать в CDN/браузере на час
  • ETag: "abc123" — отдаётся в ответе, клиент присылает его в If-None-Match, сервер отвечает 304 Not Modified, если данные не изменились
  • Cache-Control: must-revalidate — требует проверки с сервером перед использованием закэшированного ответа

HTTP-caching не решает все проблемы, но снимает огромный слой нагрузки с бэкенда. Настройте его правильно — и вы удивитесь, как много запросов не доходит до ваших серверов.

2. Application cache (Redis, Memcached)

Кэш на уровне приложения. Хранит результаты частых запросов, сессии, computed data.

Когда это нужно:

  • Один и тот же запрос выполняется тысячи раз в секунду
  • Данные меняются редко, но читаются часто
  • Вычисление данных дорогое (агрегации, отчёты, ML inference)

Ключевые решения:

  • Какой TTL (время жизни записи в кэше) — баланс между свежестью и нагрузкой
  • Как инвалидировать кэш при изменении данных
  • Что делать, если кэш протух, а БД упала (stale cache to the rescue?)
  • Как не мучать кэш неиспользуемыми записями (eviction policy: LRU, LFU, TTL)

3. CDN и Edge caching

Для статики (CSS, JS, images) — очевидно. Но CDN всё чаще используется и для API.

  • Edge caching для GET-запросов (настроить Cache-Control правильно)
  • Surge Queue / request collapsing — CDN консолидирует множество одинаковых запросов в один
  • HTML-кэширование на Edge для SSR-приложений (Next.js ISR флиртует с этим)

#Cache Invalidation Strategies

Phil Karlton: «В информатике есть только две сложные проблемы: инвалидация кэша, именование вещей и off-by-one errors.»

Cache-aside (Lazy Loading):

def get_user(user_id):
    user = cache.get(f"user:{user_id}")
    if user is None:
        user = db.query("SELECT * FROM users WHERE id = ?", user_id)
        cache.set(f"user:{user_id}", user, ttl=3600)
    return user

Плюс: простота. Минус: при старте программы кэш холодный.

Write-through: Приложение сначала пишет в кэш, потом в БД (синхронно). Плюс: данные всегда в кэше. Минус: latency записи выше.

Write-behind (Write-back): Приложение пишет в кэш, а асинхронный фоновый процесс сбрасывает данные в БД. Плюс: очень быстрая запись. Минус: риск потери данных при краше кэша.

Cache invalidation по событию: Когда данные меняются, сервис публикует событие (через CDC или outbox). Подписчики инвалидируют соответствующие ключи в кэше. Самый «правильный» подход, но требует event-driven инфраструктуры.

Практический совет: Начните с cache-aside и TTL. Если данные не критичны — пусть TTL решает проблему инвалидации. Добавляйте event-driven инвалидацию только когда TTL даёт слишком длительное окно stale data.


#Компромиссы и альтернативы

#Базы данных — это не «выбери одну и живи с ней»

Самый распространённый сценарий в production: polyglot persistence — несколько разных БД в одной системе, каждая под свою задачу.

┌─────────────────────────────────────────────────────┐
│                   Ваше приложение                     │
├──────────┬──────────┬──────────┬──────────────────────┤
│ Postgres │   Redis  │ Elastic │    S3 (cold data)     │
│ (OLTP)   │  (кэш,   │(full-text│                      │
│ (orders) │ sessions)│  search) │                      │
└──────────┴──────────┴──────────┴──────────────────────┘

Но: каждая дополнительная БД увеличивает operational complexity. Не добавляйте пятую, не продумав мониторинг, бэкапы и аварийное восстановление для четвёртой.

#Consistency vs Performance

В любой базе данных есть выбор между strong consistency и performance. Он не бинарный:

  • Read-after-write consistency: клиент должен видеть свои изменения сразу после записи
  • Eventual consistency: данные сойдутся к одному состоянию, но не мгновенно
  • Strong consistency: все видят одно и то же состояние всегда

PostgreSQL даёт strong consistency внутри одной ноды. Cassandra — eventual (по умолчанию). DynamoDB — на выбор (eventual или strong read, за price и latency).

Чем строже consistency — тем дороже latency и ниже throughput. Это trade-off, который нужно делать осознанно.

#Cost of Complexity

Каждая база данных имеет свою «цену входа» (complexity budget). Но есть скрытая цена — стоимость эксплуатации.

База DevOps complexity Cost to operate (self-hosted) Managed alternative
PostgreSQL Низкая Низкая RDS, Cloud SQL, Supabase
MongoDB Средняя Средняя Atlas
Cassandra Высокая Высокая Astra (DataStax)
Redis Низкая Средняя ElastiCache, Redis Cloud

Эти цифры — не только про деньги. Высокая operational complexity означает, что у вас должна быть команда, которая разбирается в этой БД, пишет плейбуки для инцидентов, настраивает мониторинг и бэкапы. Если такой команды нет — managed-версия удваивает стоимость в прайсе, но экономит десять X на зарплатах инженеров.

#Vendor Lock-in

Managed-сервисы удобны — это факт. Но они привязывают вас к провайдеру.

  • AWS Aurora — прекрасна, но мигрировать с неё на vanilla PostgreSQL больно (даже с совместимостью протокола)
  • DynamoDB — если вы спроектировали приложение под его data model, то переезд на что-то другое — это переписывание бэкенда
  • Firestore — почти то же самое

Стратегия: сознательно принимайте lock-in, когда выгоды очевидны. DynamoDB для проекта, который точно останется в AWS на годы — ок. Но если вы не уверены в провайдере — держитесь за PostgreSQL. С него мигрировать куда угодно проще, чем с чего угодно на PostgreSQL.


#Когда это применимо

#Стартап (0-10 разработчиков)

  • PostgreSQL — единственная БД. Максимум — добавить Redis для кэша
  • Никакого шардирования, никаких специальных БД
  • Outbox pattern? Только если вам действительно нужна надёжная публикация событий (вряд ли)
  • Schema migrations — через обычный migration tool (без online миграций — и так сойдёт)
  • Managed-сервис (Supabase, Render, Railway) — чтобы не думать про администрирование
  • Единственная база данных — до момента, когда вы почувствуете её боль

#Растущий продукт (10-50 разработчиков)

  • PostgreSQL + Redis
  • Read replicas, когда начинает тормозить SELECT
  • Начинаете думать про CQRS, если read/write модели расходятся
  • Outbox для гарантированной публикации событий между сервисами
  • HTTP-кэширование обязательно
  • Partitioning для больших таблиц (логи, события, orders по годам)
  • Connection pooling — уже настроен, иначе PostgreSQL умрёт при первом thundering herd
  • Monitoring: pgbadger для анализа логов, pg_stat_statements для медленных запросов, Prometheus + Grafana для метрик

#Масштабная система (50+ разработчиков)

  • Polyglot persistence: PostgreSQL для OLTP, S3/объектный сторадж для холодных данных и блобов, специализированные БД под конкретные задачи (Elasticsearch для поиска, Cassandra для time-series, Neo4j для графов)
  • CQRS с Event Sourcing для критических доменов (транзакционные контексты)
  • CDC для синхронизации между сервисами и системами (Debezium + Kafka — стандарт де-факто)
  • Saga pattern для распределённых транзакций с orchestration через Temporal/Camunda/AWS Step Functions
  • Многоуровневое кэширование (L1: in-app cache, L2: Redis/ElastiCache, L3: CDN/Edge)
  • Read replicas — несколько, разнесённые географически
  • Автоматическое тестирование failover (game days, chaos engineering)
  • Dedicated DBA или SRE-команда, которая отвечает за базы данных как продукт

#Практический кейс

#Как выбранная БД влияет на архитектуру

Возьмём простой домен: интернет-магазин. Продаём книги. Три варианта архитектуры data layer — и три совершенно разные картины.

Вариант А: PostgreSQL + Redis (Simple Stack)

PostgreSQL: users, orders, books, inventory, payments (таблицы)
Redis: сессии, корзины (TTL), кэш каталога, rate-limiter
  • Resilience: PostgreSQL определяет availability. Redis можно пережить (data loss ≈ потеря корзин, не больше)
  • Scaling: PostgreSQL стал узким местом. Read replicas снимают проблему чтения. Partitioning — когда заказов > 100M. Затем — manual sharding (или переезд на managed решение с built-in scaling)
  • Consistency: Сильная — внутри PostgreSQL транзакций. ACID, no surprises

Вариант Б: DynamoDB + OpenSearch

DynamoDB: все OLTP-данные (orders, users, inventory)
OpenSearch: поиск по книгам, analytics
DAX: встроенный кэш DynamoDB
  • Resilience: DynamoDB — managed, практически не падает. Зато может словить throttling (если key design плохой или неудачный request spike)
  • Scaling: Автоматическое. Платите за потребление. Но раз в месяц надо чистить неиспользуемые GSI и пересматривать access patterns
  • Consistency: Eventual по умолчанию. Заказы могут прийти в несогласованном состоянии, если в сервисах нет сквозной consistency. Приходится проектировать эту consistency на уровне приложения.

Вариант В: Cassandra + ScyllaDB + Event-driven

Cassandra: пользовательские сессии, корзины, логи
ScyllaDB: каталог книг с большой нагрузкой на чтение
Kafka: event backbone, взаимодействие между сервисами
PostgreSQL (одна таблица): for audit only
  • Resilience: Очень высокая (AP-система). Cassandra переживает потерю ноды без downtime. Но отслеживание консистентности данных — головная боль
  • Scaling: Встроенный, через consistent hashing. Добавили ноду — данные ребалансируются автоматически. Но это не бесплатно: ребалансинг нагружает кластер
  • Consistency: Продуктовая, серьёзные компромиссы. Иногда данные «не сходятся», приходится писать проверки

[Anecdotal] Реальный кейс из жизни: как мы умнели на своей шкуре

Первый проект, где я работал с базой данных в продакшене, хранил всё в MySQL с единственной таблицей. Буквально: CREATE TABLE everything (id INT, data TEXT, created_at DATETIME). Пользовательские данные, транзакции, логи — всё в одном текстовом поле.

Мы добавляли индекс, когда «БД тормозила». Мы делали дамп раз в день — и это был наш «бэкап». Когда таблица выросла до 500 миллионов записей, SELECT с ORDER BY стал выполняться по 40 секунд. Мы добавили RAM. Стало 35 секунд.

Мы перешли на PostgreSQL после того, как очередной «ALTER TABLE ADD COLUMN» заблокировал продакшен на 20 минут в пятницу вечером.

Урок, который я выучил раз и навсегда: data layer не прощает дилетантства. Вы не «разберётесь потом». Вы разберётесь в 3 часа ночи в воскресенье, когда упадёт мастер-нода.

Вывод: выбор БД — это не технический выбор. Это архитектурный, влияющий на consistency model, инциденты, которых не избежать, complexity всей системы, стоимость поддержки, и способность масштабироваться. Смена БД в production — это операция года.


#Чек-лист

#Data Layer Design

  • Определили access patterns перед выбором БД (не наоборот)
  • PostgreSQL — дефолт. Специализированные БД — только когда PostgreSQL не справляется
  • Connection pool настроен (PgBouncer + app-side pool)
  • Read replicas добавлены, если read/write ratio > 10:1
  • Polyglot persistence — каждая БД под свою задачу, без «one database to rule them all»

#Schema Migrations

  • Все миграции тестируются на стейджинге с production-like data
  • Проверено, какие ALTER'ы блокируют таблицу
  • Для zero-downtime: Expand-Migrate-Contract или аналогичный паттерн
  • Есть rollback-план для каждой миграции

#Indexing

  • Все запросы с WHERE, JOIN, ORDER BY покрыты индексами
  • BRIN-индексы для sequential data (логи, time-series)
  • GIN для JSONB-запросов
  • Мониторинг bloat индексов (регулярная REINDEX при необходимости)

#NoSQL

  • Определены consistency требования (strong vs eventual)
  • Продуман disaster recovery (как восстанавливать данные из одной БД без другой)
  • В DynamoDB — спроектированы partition key и sort key под access patterns
  • Учится смена access pattern'а (не только текущие, но и будущие)

#Кэширование

  • HTTP-кэш настроен для публичных API (Cache-Control, ETag)
  • Application cache имеет TTL и стратегию инвалидации
  • Продумано поведение при падении кэша (degradation, а не 500)
  • Кэш мониторится (hit ratio, memory usage, evictions)

#Архитектурные паттерны

  • CQRS — читали 07a-cqrs-in-depth.md?
  • Outbox — проверка идемпотентности публикации
  • Saga — проверены compensating transactions на всех шагах
  • CDC — мониторинг replication lag

#«Не делай так»

#1. Использовать MongoDB как PostgreSQL

Самый частый анти-паттерн: взять Mongo (потому что «гибкая схема!») и начать моделировать реляционные данные. Заказы, пользователи, связи многие-ко-многим.

Что происходит: вы пишете кучу вложенных документов, потом выясняется, что JOIN'ы не работают, нормализация невозможна, а при попытке сделать сколь-нибудь сложный запрос — БД «садится». В итоге — миграция на PostgreSQL через полгода-год. Я это видел. Не один раз.

Правильно: MongoDB — для документов со сложной внутренней структурой и редким изменением схемы. Если ваши данные — это таблицы со связями — используйте реляционную БД.

#2. Игнорировать connection pooling

«У нас всего 500 concurrent users, зачем нам connection pool?»

Через полгода при 2000 подключений — бд умирает. PostgreSQL держит примерно 100-200 активных подключений, дальше — деградация. PgBouncer стоит в 5 минутах настройки. Настройте его сразу.

#3. «One database to rule them all»

«Давайте все данные хранить в одной БД. И кэш туда же. И логи. И аналитику.»

Одна база для всего — это как один швейцарский нож для всей кухни. Можно, но неудобно, неэффективно и опасно. Аналитические запросы kill'ят производительность OLTP. Кэш не заменит in-memory store. Search engine — отдельный зверь.

Правильно: polyglot persistence. Но добавлять каждую новую БД обоснованно: «Нам нужна эта БД, потому что ...» (не «нам нужна эта БД, потому что она интересная»).

#4. Преждевременное шардирование

«Мы быстро вырастем, давайте сразу шардировать БД на 64 ноды!»

Вы не вырастете так быстро. Шансы, что вы вырастете как Uber — 0.001%. А шардирование — это operation hell: распределённые транзакции, перебалансировка данных, cross-shard запросы, мониторинг каждой ноды.

Правильно: начните с одного экземпляра. Когда упрётесь — добавите read replicas. Когда и они не помогут — partition'ы (range или list). И только когда partition'ы на одной ноде перестанут справляться — думайте о шардировании.

#5. «Для нас Redis — просто кэш»

Redis — это не «просто кэш». Это data structure server. Его частое неправильное использование — хранить всё подряд, без плана эвакуации данных при падении.

Правильно: думайте, какие данные вы готовы потерять при рестарте Redis (кэш — можно). А какие — нет. Для вторых — настройте сохранение (RDB/AOF) или используйте другую БД.

#6. Cache invalidation эмуляция через "DELETE FROM cache"

«Давайте очищать весь кэш при каждом изменении». Работает, пока кэш маленький. Когда кэш — 500GB и 10 инстансов — очистка всего кэша вызывает «thundering herd»: все запросы одновременно валят на БД.

Правильно: точечная инвалидация (удалять конкретные ключи) или TTL-based инвалидация. Полная очистка — только если это осознанное решение («данные обновляются раз в день, всё равно чистим»).


#Ключевые выводы

  1. PostgreSQL — лучший выбор по умолчанию. Он решает 90% задач. Специализированные БД добавляйте только когда PostgreSQL перестаёт справляться.

  2. Выбор БД — архитектурное решение. Consistency model, resilience и scaling strategy вашей системы определяются выбором базы данных. Сменить её в production — операция на месяцы.

  3. Connection pooling — не опция, а обязательное условие. Настройте PgBouncer на старте. PostgreSQL не прощает тысячи открытых соединений.

  4. Архитектурные паттерны данных (CQRS, Outbox, Saga, CDC) — для распределённых систем. В монолите они обычно избыточны. В микросервисах — критичны.

  5. Кэширование — трёхуровневая система. HTTP-cache (CDN/браузер) + Application cache (Redis) + Database cache (адаптеры в БД). Каждый уровень — свой TTL, своя стратегия инвалидации.

  6. Polyglot persistence — это норма, но добавляйте каждую БД осознанно. Каждый новый тип БД — это не только новые возможности, но и новая операционная сложность.

  7. Стратегия миграции данных — часть архитектуры. Expand-Migrate-Contract — базовый паттерн для zero-downtime изменений. Имейте план rollback для каждой миграции.


#Cross-references

  • 07a-cqrs-in-depth.md — глубокое погружение в CQRS и Event Sourcing: от истории (Bertrand Meyer, Greg Young) до production-реализации с кодом, архитектурой и анти-паттернами. Обязательное чтение после этой главы, если вас заинтересовали паттерны данных.
  • Глава 17 — Data Management in Distributed Systems — когда вы перешли от одной БД к нескольким сервисам: распределённые транзакции, consistency patterns, distributed querying. Saga Pattern и CDC, разобранные здесь на уровне концепций, там — в контексте distributed data management.
  • Глава 20 — Database Scaling — когда PostgreSQL перестаёт влезать в один инстанс: partitioning (declarative partitioning в PG 11+), manual sharding, распределённые SQL-базы (Citus, CockroachDB, Vitess, TiDB). Продолжение темы «что делать, когда read replicas + partitioning перестают спасать».
  • Глава 21 — Caching and CDN — глубокое погружение в кэширование: HTTP caching headers, CDN architecture, application caching с Redis/Memcached, cache invalidation patterns, distributed caching, edge caching с Varnish/Cloudflare Workers.