Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → CQRS: разделение чтения и записи

CQRS на практике: когда разделение чтения и записи оправдано

Как работает CQRS: отдельные модели чтения и записи, проекции и eventual consistency, связь с event sourcing и критерии применимости паттерна.

Прямой ответ

CQRS (Command Query Responsibility Segregation) — разделение операций записи (commands — изменяют состояние) и чтения (queries — возвращают данные) на отдельные модели (write-модель оптимизирована для записи/консистентности, read-модель — для чтения/скорости — микросервисы — разные требования — разные модели); проблема, которую решает: одна модель на чтение и запись конфликтует (запись хочет нормализованную схему/консистентность — чтение хочет денормализованные «готовые» представления — один SELECT с 5 JOIN против 5 таблиц — планы — компромисс ухудшает обе стороны — CQRS разрешает конфликт разделением — event-driven); уровни CQRS (по нарастанию разделения): (1) разные модели в одной БД (write — нормализованные таблицы, read — денормализованные view/материализованные представления — Postgres — минимальное разделение — часто достаточно — «CQRS-лайт»); (2) разные хранилища (write — Postgres (консистентность/транзакции — транзакции), read — ElasticSearch/Redis/ClickHouse (скорость/поиск/аналитика — выбор — «запись в Postgres, чтение из ES»); (3) event sourcing-база (write — события, read — проекции — event sourcing — максимальное разделение — event-driven); проекция (главный механизм): write → событие/изменение → проекция (read-модель обновляется асинхронно — eventually consistent — event-driven — «записал — в read через секунды» — норма — CQRS-семантика); синхронизация write→read: (1) outbox → consumer → проекция (saga — надёжно — атомарность write+событие — event-driven), (2) CDC (Debezium — логическая репликация — изменения БД → Kafka → проекция), (3) двойная запись (write пишет в оба — хрупко — не атомарно — антипаттерн — saga — outbox/CDC надёжнее); eventual consistency (главный компромисс): read-модель отстаёт от write на секунды (проекция асинхронна — event-driven — «заказ создан, в списке через 2 с» — UX-ожидания настроены — «read-your-writes» — паттерн (после записи читать из write-модели — идемпотентность); когда CQRS оправдан: чтение и запись конфликтуют (сложные запросы против консистентной записи — планы), разные масштабы чтения/записи (чтение ×100 записи — read-реплики/кэш — реплики — ёмкость), разные требования к хранилищам (запись — ACID, чтение — поиск/аналитика — выбор), event sourcing (event sourcing — CQRS почти обязателен); когда CQRS — оверхед: простое CRUD (одна модель — CQRS усложняет без выгоды — микросервисы — «CQRS для блога» — антипаттерн), сильная консистентность нужна для чтения (eventually не подходит — CAP — «баланс должен быть актуален сразу» — CQRS-read отстаёт), команда без опыта (техдолг — eventual consistency/проекции — сложность).

Практика: проекции, консистентность, реплики

Проекции (read-модели — сердце CQRS): обработчик читает write-изменения (outbox/CDC — saga), обновляет read-модель (event-driven — eventually consistent — lag — метрика (метрики — «проекция отстала на N секунд» — алерт — алерты); пересборка проекции (схема изменилась — replay write-данных — consumer-reset — «новая проекция с нуля» — время — ёмкость); несколько проекций (разные read-модели из одного write — «список заказов», «дашборд продаж», «поисковый индекс» — каждая оптимизирована под свой запрос — Grafana); read-your-writes (паттерн консистентности): после записи клиент читает из write-модели (или ждёт проекцию — идемпотентность — «создал заказ — сразу вижу» — UX — event-driven); реплики как CQRS-лайт (самый частый практический CQRS): write — primary Postgres, read — read-реплики (асинхронная репликация — eventually consistent — «чтение с реплики» — масштабирование чтения — ёмкость — «CQRS без проекций» — реплики — простейшая форма — реплики); кэш как read-модель (Redis — «горячие данные в кэше» — read-модель — инвалидация при write — кэш-паттерны — CQRS-лайт — ёмкость).

Практический сценарий: каталог товаров — чтение ×100 записи

Контекст: e-commerce каталог (100k товаров), боль: чтение (поиск/фильтры/списки) ×100 записи (добавление/обновление товара) — одна Postgres-схема на обе (чтение — сложные JOIN по категориям/атрибутам/ценам — jsonb — запись — транзакции — конфликт — планы — p95 чтения 800 мс под нагрузкой — SLO); решение — CQRS с разными хранилищами: (1) write-модель — Postgres (товары/категории/цены — нормализованно — транзакции — транзакции — консистентность записи); (2) outbox → Kafka (изменение товара → outbox → Kafka — saga — атомарность write+событие — event-driven); (3) read-модель — ElasticSearch (consumer читает Kafka, обновляет ES-индекс — денормализованный документ товара (категория/атрибуты/цена в одном — поиск/фильтры быстрые — fulltext-контекст — «чтение из ES» — p95 80 мс — ×10 быстрее — ёмкость); (4) вторая проекция — Redis-кэш (горячие товары/цены — Redis — «список категорий» — кэш — инвалидация при write — кэш); (5) eventually consistency (товар обновлён в Postgres, в ES через 1–2 с — event-driven — админка «товар обновлён» — read-your-writes (после записи админ читает из Postgres — идемпотентность — витрина — eventual — UX-ожидания — «цена обновится через секунды»); (6) пересборка проекции (схема ES-индекса изменилась — replay всех товаров из Postgres — consumer-reset — 100k товаров — час — ёмкость — плановая операция); результат: чтение p95 800→80 мс (×10 — ES оптимизирован под поиск — fulltext), запись не деградирует (Postgres без read-нагрузки — планы — ёмкость), масштабирование чтения независимо (ES-ноды добавляются — ёмкость — read-нагрузка растёт — write не затронут — реплики-аналогия); цена: два хранилища эксплуатировать (Postgres + ES — зависимости — операционная нагрузка — ёмкость), eventual consistency (проекция отстаёт — UX-настройка — event-driven), сложность (outbox/проекции/пересборка — техдолг); вывод: CQRS решили конкретную проблему (чтение ×100 записи с разными требованиями — ёмкость), не «всё на CQRS» — только каталог (где конфликт явный) — заказы/платежи — обычная модель (консистентность критична — транзакции — микросервисы — CQRS — инструмент для конфликтных доменов — ADR.

FAQ

CQRS против event sourcing — в чём разница и как связаны? CQRS — разделение чтения/записи на модели (event-driven), event sourcing — хранение состояния как событий (event sourcing — разные паттерны, часто вместе; связь: ES почти всегда требует CQRS (write — события, read — проекции — event sourcing — паттерн-пара), CQRS без ES возможен (раздельные модели без событий — обычное состояние — сценарий выше — event-driven); «CQRS = ES» — миф (CQRS — про разделение моделей, ES — про хранение истории — event sourcing — можно CQRS без ES (проще — микросервисы); когда что: CQRS — чтение/запись конфликтуют (разные модели/хранилища — ёмкость), ES — нужна история/аудит/воспроизведение (event sourcing — комплаенс), оба — домены с историей + конфликтным чтением (event sourcing — ADR — не путать — разные проблемы решают — event-driven).

Что такое eventual consistency в CQRS и как с ней жить? Eventual consistency — read-модель отстаёт от write на секунды (проекция асинхронна — event-driven — «записал — прочитал старое» — норма CQRS); как жить: read-your-writes (после записи читать из write-модели — идемпотентность — «создал — сразу вижу» — UX — сценарий выше), UX-ожидания («обновится через секунды» — пользователь предупреждён — event-driven), метрика lag (метрики — «проекция отстала» — алерт — алерты — lag растёт — проекция сломана — incident), идемпотентность (дубли событий — проекция не ломается — идемпотентность); когда eventual не подходит: сильная консистентность нужна (баланс/платёж — «актуально сейчас» — CAP — читать из write-модели — не CQRS-read — транзакции); «eventual — это баг» — нет, это компромисс CQRS (разделение даёт скорость/масштаб — цена — консистентность — event-driven — бизнес решает, приемлема ли задержка — ADR — SLO).

Можно ли делать CQRS без отдельного хранилища (в одной БД)? Да, и часто достаточно (CQRS-лайт): write — нормализованные таблицы, read — денормализованные view/материализованные представления (Postgres — «одна БД, разные модели» — минимальное разделение — часто решает конфликт — планы); механика: write-операции — в таблицы (транзакции — транзакции), read-запросы — из view/материализованных представлений (денормализованы — jsonb — обновление — триггер/CDC/периодическое (реплики-аналогия); когда достаточно: чтение/запись конфликтуют в схеме, но масштаб один (одна БД справляется — ёмкость — «CQRS без второго хранилища» — проще — микросервисы); когда нужно отдельное: разные масштабы (чтение ×100 — реплики/ES — реплики — ёмкость), разные требования (поиск/аналитика — ES/ClickHouse — выбор), event sourcing (event sourcing — проекции — отдельное хранилище обычно); вывод: CQRS в одной БД — валидный старт (часто достаточно — техдолг — «сразу два хранилища» — оверхед, если одна БД справляется — ADR — начинать с лайт-CQRS — event-driven).

Как тестировать CQRS-систему? Слои: unit write-модели (команды → состояние — pytest — «CreateOrder → заказ создан» — тесты), unit проекций (событие → read-модель — «OrderCreated → проекция обновлена» — event-driven — мок); интеграционные (write → outbox → проекция → read — полный поток — testcontainers — eventually consistency проверена — saga — «запись дошла до read» — не мок); консистентность (read-your-writes — после записи read актуален — идемпотентность — lag-проверка — метрики); пересборка проекции (replay → проекция корректна — consumer-reset — «схема изменилась — пересборка работает» — тесты); идемпотентность (дубль события → проекция не ломается — идемпотентность — негативный тест — тесты); «CQRS не тестируется» — миф — outbox/проекции тестируемы (testcontainers — тесты — eventual consistency — проверяется lag/идемпотентность — event-driven — распределённость — не оправдание — техдолг).

Источники: Martin Fowler (CQRS), Microsoft Azure patterns, microservices.io — ссылки в шапке.