Data Layer — базы данных, кэши и потоки данных
Проблема: Ваше приложение должно хранить данные, читать их быстро и не терять при этом ни одного байта. Звучит просто. Но между «просто сохранить в файл» и production-архитектурой — пропасть размером в пару даунтаймов и потерю данных клиентов.
#Введение: Data layer как сердце системы
Представьте, что ваше приложение — это человек. API — это руки, которые принимают запросы. Бизнес-логика — мозг, который принимает решения. Фронтенд — лицо, которое улыбается пользователю.
А data layer? Это сердце и кровеносная система. Если оно упало — всё остальное бесполезно.
В предыдущей главе мы разобрали, как строить API. Теперь давайте посмотрим, что происходит под капотом — как данные хранятся, как к ним обращаться, и как не потерять их, когда система растёт.
Эта глава получилась большой, и это не случайно. Data layer — та часть системы, где принимаются самые дорогие решения. Выбор базы данных, схемы кэширования или архитектурного паттерна данных — это не «технический выбор». Это архитектурное решение, которое будет влиять на всё: от скорости разработки до стоимости инфраструктуры, от consistency модели до resilience.
Мы пройдём по четырём ключевым слоям:
- Реляционные БД — основа основ, которая никуда не уходит
- NoSQL — когда реляционки не хватает, и как не ошибиться с выбором
- Архитектурные паттерны данных — CQRS, Event Sourcing, Outbox, Saga, CDC (обзорно — глубокое погружение в CQRS и Event Sourcing — в приложении 07a)
- Кэширование — краткий обзор (детали — в Главе 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 использует структуру из трёх уровней:
- Memtable — in-memory буфер, куда сразу пишутся данные (быстрая запись)
- Commit log — WAL на диске для durability (на случай краша до flush на диск)
- 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.
Варианты реализации:
- Polling Publisher — фоновый процесс, который периодически читает
SELECT * FROM outbox WHERE processed_at IS NULL ORDER BY created_at LIMIT 100 - CDC-based Publisher (см. ниже) — использует Change Data Capture для чтения изменений outbox-таблицы
- 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):
- Debezium подключается к PostgreSQL через логическую репликацию
- PostgreSQL отправляет каждое изменение (INSERT, UPDATE, DELETE) в репликационный слот
- 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 инвалидация. Полная очистка — только если это осознанное решение («данные обновляются раз в день, всё равно чистим»).
#Ключевые выводы
-
PostgreSQL — лучший выбор по умолчанию. Он решает 90% задач. Специализированные БД добавляйте только когда PostgreSQL перестаёт справляться.
-
Выбор БД — архитектурное решение. Consistency model, resilience и scaling strategy вашей системы определяются выбором базы данных. Сменить её в production — операция на месяцы.
-
Connection pooling — не опция, а обязательное условие. Настройте PgBouncer на старте. PostgreSQL не прощает тысячи открытых соединений.
-
Архитектурные паттерны данных (CQRS, Outbox, Saga, CDC) — для распределённых систем. В монолите они обычно избыточны. В микросервисах — критичны.
-
Кэширование — трёхуровневая система. HTTP-cache (CDN/браузер) + Application cache (Redis) + Database cache (адаптеры в БД). Каждый уровень — свой TTL, своя стратегия инвалидации.
-
Polyglot persistence — это норма, но добавляйте каждую БД осознанно. Каждый новый тип БД — это не только новые возможности, но и новая операционная сложность.
-
Стратегия миграции данных — часть архитектуры. 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.