CQRS и разделение чтения/записи
Что такое CQRS
CQRS (Command Query Responsibility Segregation) — архитектурный подход, который разделяет модель данных и компоненты, отвечающие за запись (commands) и чтение (queries).
Идея: процесс изменения состояния оптимизируется под валидные инварианты и транзакции, а чтение — под быстрые, целевые проекции и масштабирование.
Зачем это нужно
Производительность чтения: материализованные проекции под конкретные сценарии (ленты, отчеты, каталоги).
Стабильность критического пути: запись изолирована от «тяжелых» джоинов и агрегатов.
Свобода выбора хранилищ: OLTP для записи, OLAP/кеш/поисковые движки для чтения.
Ускоренная эволюция: добавляйте новые представления без риска «сломать» транзакции.
Наблюдаемость и аудит (особенно в связке с Event Sourcing): легче восстановить и переиграть состояние.
Когда применять (и когда нет)
Подходит, если:- Превалируют чтения с разными срезами данных и сложной агрегацией.
- Критический путь записи должен быть тонким и предсказуемым.
- Нужны разные SLO/SLA для чтения и записи.
- Требуется изоляция доменной логики записи от аналитических/поисковых нужд.
- Домен прост, нагрузка невысока; CRUD справляется.
- Сильная согласованность между чтением и записью обязательна для всех сценариев.
- Команда неопытна, а операционная сложность неприемлема.
Базовые понятия
Команда (Command): намерение изменить состояние (`CreateOrder`, `CapturePayment`). Проверяет инварианты.
Запрос (Query): получение данных (`GetOrderById`, `ListUserTransactions`). Без побочных эффектов.
Модель записи: агрегаты/инварианты/транзакции; хранение — реляционное/ключ-значение/событийный лог.
Модель чтения (проекции): материализованные таблицы/индексы/кеш, синхронизируются асинхронно.
Согласованность: часто eventual между записью и чтением; критические пути — через прямое чтение из write-модели.
Архитектура (скелет)
1. Write-сервис: принимает команды, валидирует инварианты, фиксирует изменения (БД или события).
2. Outbox/CDC: гарантированная публикация факта изменений.
3. Процессоры проекций: слушают события/CDC и обновляют read-модели.
4. Read-сервис: обслуживает queries из материализованных представлений/кешей/поиска.
5. Саги/оркестрация: координируют кросс-агрегатные процессы.
6. Наблюдаемость: лаг проекций, процент успешных применений, DLQ.
Проектирование модели записи
Агрегаты: четкие границы транзакций (например, `Order`, `Payment`, `UserBalance`).
Инварианты: формализуйте (денежные суммы ≥ 0, уникальность, лимиты).
Команды идемпотентны по ключу (например, `idempotency_key`).
Транзакции минимальны по охвату; внешние побочные эффекты — через outbox.
Пример команды (псевдо-JSON)
json
{
"command": "CapturePayment",
"payment_id": "pay_123",
"amount": 1000,
"currency": "EUR",
"idempotency_key": "k-789",
"trace_id": "t-abc"
}
Проектирование модели чтения
Отталкивайтесь от запросов: какие экраны/отчеты нужны?
Денормализация допустима: read-модель — «оптимизированный кэш».
Несколько проекций для разных задач: поиск (OpenSearch), отчеты (колонночное хранилище), карточки (KV/Redis).
TTL и пересборка: проекции должны уметь восстанавливаться из источника (реплей событий/снапшоты).
Согласованность и UX
Eventual consistency: интерфейс может кратковременно показывать старые данные.
UX-паттерны: «данные обновляются…», optimistic UI, индикаторы синхронизации, блокировка опасных действий до подтверждения.
Для операций, требующих сильной согласованности (например, показ точного баланса перед списанием), читайте напрямую из write-модели.
CQRS и Event Sourcing (по желанию)
Event Sourcing (ES) хранит события, а состояние агрегата — результат их свертки.
Связка CQRS+ES дает идеальный аудит и легкую пересборку проекций, но повышает сложность.
Альтернатива: обычная OLTP-БД + outbox/CDC → проекции.
Репликация: Outbox и CDC
Outbox (в одной транзакции): запись доменных изменений + запись события в outbox; паблишер доставляет в шину.
CDC: считывание из лога БД (Debezium и т. п.) → трансформация в доменные события.
Гарантии: по умолчанию at-least-once, потребители и проекции должны быть идемпотентны.
Выбор хранилищ
Write: реляционные (PostgreSQL/MySQL) для транзакций; KV/Document — где инварианты простые.
Read:- KV/Redis — карточки и быстрые ключевые чтения;
- Поиск (OpenSearch/Elasticsearch) — поиск/фильтры/фасеты;
- Колонночные (ClickHouse/BigQuery) — отчеты;
- Кеш на CDN — публичные каталоги/контент.
Паттерны интеграции
API слой: отдельные эндпоинты/сервисы для `commands` и `queries`.
Идемпотентность: ключ операции в заголовке/теле; хранение recent-keys с TTL.
Саги/оркестрация: тайм-ауты, компенсации, повторяемость шагов.
Backpressure: ограничение параллелизма процессоров проекций.
Наблюдаемость
Метрики write: p95/99 латентности команд, доля успешных транзакций, ошибки валидации.
Метрики read: p95/99 запросов, hit-rate кеша, нагрузка на поисковый кластер.
Лаг проекций (время и сообщения), DLQ-ставка, процент дедупликаций.
Трейсинг: `trace_id` проходит через команду → outbox → проекцию → query.
Безопасность и комплаенс
Разделение прав: разные scopes/роли для записи и чтения; принцип наименьших привилегий.
PII/PCI: минимизируйте в проекциях; шифрование at-rest/in-flight; маскирование.
Аудит: фиксируйте команду, актера, исход, `trace_id`; WORM-архивы для критичных доменов (платежи, KYC).
Тестирование
Contract tests: для команд (ошибки, инварианты) и queries (форматы/фильтры).
Projection tests: подайте серию событий/CDC и проверьте итоговую read-модель.
Chaos/latency: инжекция задержек в процессоры проекций; проверка UX при лаге.
Replayability: пересборка проекций на стенде из снапшотов/лога.
Миграции и эволюция
Новые поля — аддитивно в событии/CDC; read-модели пересобираются.
Двойная запись (dual-write) при редизайне схем; старые проекции держим до переключения.
Версионирование: `v1`/`v2` событий и эндпоинтов, Sunset-план.
Feature flags: ввод новых queries/проекций по канарейке.
Антипаттерны
CQRS «ради моды» в простых CRUD-сервисах.
Жесткая синхронная зависимость чтения от записи (убивает изоляцию и устойчивость).
Один индекс на все: смешивание разнородных запросов в один read-store.
Нет идемпотентности у проекций → дубли и расхождения.
Невосстанавливаемые проекции (нет реплея/снапшотов).
Примеры доменов
Платежи (онлайн-сервис)
Write: `Authorize`, `Capture`, `Refund` в транзакционной БД; outbox публикует `payment.`.
Read:- Redis «карточка платежа» для UI;
- ClickHouse для отчетов;
- OpenSearch для поиска транзакций.
- Критический путь: авторизация ≤ 800 мс p95; согласованность чтения для UI — eventual (до 2–3 с).
KYC
Write: команды на старт/апдейт статуса; хранение PII в защищенной БД.
Read: облегченная проекция статусов без PII; PII подтягивается точечно при необходимости.
Безопасность: разные scopes на чтение статуса и доступ к документам.
Балансы (iGaming/финансы)
Write: агрегат `UserBalance` с атомарными инкрементами/декрементами; идемпотентные ключи на операцию.
Read: кеш для «быстрого баланса»; для списания — прямое чтение из write (строгая согласованность).
Сага: депозиты/выводы координируются событиями, при сбоях — компенсации.
Чек-лист внедрения
- Выделены агрегаты и инварианты write-модели.
- Определены ключевые queries и спроектированы проекции под них.
- Настроены outbox/CDC и идемпотентные процессоры проекций.
- Есть план пересборки проекций (snapshot/replay).
- SLO: латентность команд, лаг проекций, доступность read/write по отдельности.
- Разделены права доступа и шифрование данных реализовано.
- Алерты на DLQ/лаг/провалы дедупликации.
- Тесты: контракты, проекции, хаос, реплей.
FAQ
Обязательно ли Event Sourcing для CQRS?
Нет. Можно строить на обычной БД + outbox/CDC.
Как бороться с рассинхронизацией?
Явно проектировать UX, измерять лаг проекций, давать критическим операциям читать из write.
Можно ли в одном сервисе держать и write, и read?
Да, физическое разделение — опционально; логическое разделение ответственности обязательно.
Что насчет транзакций между агрегатами?
Через саги и события; избегайте распределенных транзакций, если можно.
Итог
CQRS развязывает руки: тонкий, надежный путь записи с четкими инвариантами и быстрые, целевые чтения из материализованных проекций. Это повышает производительность, упрощает эволюцию и делает систему устойчивее к нагрузкам — если дисциплинированно управлять согласованностью, наблюдаемостью и миграциями.