Концептуально CQRS — разделение модели записи и модели чтения: пишем в одну, читаем из другой, каждую оптимизируем под свою задачу. На слайде это одна стрелка, а в проде — целый слой проекций (read-моделей), у которого своя жизнь: их надо чем-то наполнять, они отстают от записи (и привычное «прочитал сразу после записи» перестаёт работать), их приходится пересобирать при изменении схемы или багов — желательно без даунтайма. И отдельный вопрос — нужен ли под CQRS Event Sourcing (нет, не обязательно) или он тащится следом.
Практический сиквел к концептуальной статье про CQRS; тесно связан с Event Sourcing на практике — CQRS и ES часто идут вместе, но это разные решения. Эта статья — про инженерию read-моделей: то, что превращает красивую схему в работающую систему.
В статье
- Чем наполнять проекцию
- Sync vs async и read-your-writes
- Rebuild проекции без даунтайма
- Несколько read-моделей из одного источника
- CQRS с Event Sourcing и без
- Лунная база: панель диспетчера vs журнал расхода
- Tooling
- Когда CQRS не надо
- Эксплуатационные ошибки
- Checklist: read-модель в проде
Чем наполнять проекцию
Read-модель (проекция) не берётся из воздуха — её кто-то наполняет из изменений write-стороны. Источник зависит от того, есть ли у вас событийный лог:
| Источник | Когда | Как наполняется проекция |
|---|---|---|
| События (Event Sourcing) | write-сторона на ES | проектор подписан на поток событий |
| CDC | обычная БД, менять код лениво | Debezium читает WAL → поток изменений строк |
| Outbox | обычная БД, нужен контроль над событиями | сервис пишет доменные события в outbox → консьюмер наполняет read-таблицу |
Общий каркас проектора одинаков независимо от источника и держится на двух свойствах:
loop:
pos = load_checkpoint("orders_read") -- докуда дочитали
for change in read_after(pos):
apply(change) -- UPSERT в read-таблицу, идемпотентно
pos = change.position
save_checkpoint("orders_read", pos) -- атомарно с apply
- идемпотентное применение — изменение может прийти повторно (at-least-once), UPSERT гасит дубли (см. гарантии доставки и идемпотентность);
- чекпоинт позиции — сохраняется в той же транзакции, что и изменение read-модели, иначе после сбоя проекция тихо разъедется с источником.
Sync vs async и read-your-writes
Главный практический выбор — обновлять проекцию синхронно с записью или отдельно:
| Sync-проекция | Async-проекция | |
|---|---|---|
| Когда обновляется | в той же транзакции, что запись | отдельным проектором |
| Консистентность | строгая (сразу видно) | eventual (с лагом) |
| Масштабирование | связывает запись и чтение | независимое |
| Read-your-writes | работает | ломается |
Async — норма для CQRS (ради масштабирования и разных хранилищ), но у неё есть цена: read-your-writes перестаёт работать. Пользователь сделал запись и тут же читает — а проекция ещё не догнала, и он «не видит свою запись». Приёмы против этого:
- токен/версия и ожидание — запись возвращает позицию; чтение ждёт, пока проекция догонит эту позицию;
- чтение из write-модели для собственных данных — «свои» данные читаем из источника, чужие — из проекции;
- session stickiness — сессию пользователя на время держим на узле, который уже применил его запись.
Это тот же класс проблем, что аномалии консистентности и инвалидация кэшаСкоро — read-модель, по сути, кэш, собранный из изменений.
Rebuild проекции без даунтайма
Проекции перестраивают чаще, чем кажется: поменялась схема read-модели, нашли баг в проекторе, добавили новую проекцию задним числом. Раз источник (события/изменения) воспроизводим, проекцию можно собрать заново с нуля. Приём — blue-green:
- Создаём новую read-таблицу
orders_v2рядом с живойorders_v1. - Запускаем проектор
v2с позиции 0 — он проигрывает всю историю вorders_v2(может занять время). - Когда
v2догнала хвост (её чекпоинт ≈ текущему максимуму), атомарно переключаем чтение наorders_v2. - Старую
orders_v1удаляем.
Требования те же — идемпотентность и чекпоинты: реплей должен давать тот же результат, что онлайн-обработка. Ключевое требование к источнику — воспроизводимость: события/изменения должны переигрываться (прямой мостик к ES на практике, где это гарантировано неизменяемым логом; на CDC/outbox — насколько хватает retention).
Несколько read-моделей из одного источника
Одно из главных практических преимуществ CQRS: из одного потока изменений собирают несколько проекций под разные запросы, каждую в оптимальном хранилище:
- список заказов → денормализованная SQL-таблица;
- полнотекстовый поиск по заказам → OpenSearch;
- «горячая» карточка → кэш в Redis;
- аналитика → колоночное хранилище.
Каждая проекция — независимый проектор со своим чекпоинтом. Добавить новую read-модель = запустить новый проектор с позиции 0, не трогая существующие.
CQRS с Event Sourcing и без
Частая путаница: CQRS ≠ Event Sourcing. Разделить чтение и запись можно без событийного лога — наполняя проекции через CDC/outbox из обычной БД. И наоборот, ES без CQRS малополезен (состояние всё равно надо как-то читать).
Вместе они усиливают друг друга: неизменяемый лог событий — идеальный источник для любых проекций, включая те, что придумали через год. Но вместе они и складывают сложность — брать связку осознанно, а не «за компанию» (мостик к матрице решений).
Лунная база: панель диспетчера vs журнал расхода
Write-сторона учёта ресурсов принимает команды: доставка кислорода, списание модулем, резерв. Из одного потока этих изменений собираются три разные read-модели:
- панель диспетчера — текущие остатки по резервуарам (SQL-таблица
balances, запрос «покажи всё сейчас»); - журнал расхода — временной ряд по модулям (колоночное хранилище, «динамика за сутки»);
- поиск по инцидентам — когда и почему трогали аварийный резерв (полнотекстовый индекс).
Одна запись — три оптимизированных представления, и ни одно не мешает другому. Обратная сторона видна тут же: оператор списал кислород вручную и сразу смотрит на панель — а async-проекция ещё не догнала, остаток «старый». Здесь и включается read-your-writes-приём: панель для собственного только что сделанного списания читает из write-модели, для остального — из проекции.
Tooling
| Инструмент | Что даёт | Когда |
|---|---|---|
| EventStoreDB projections | серверные проекции из событий | write-сторона на EventStoreDB |
| Marten (Postgres/.NET) | проекции и async-daemon «батарейками» | .NET + Postgres |
| Axon (JVM) | query-модели, event handlers, tracking processors | JVM, полный CQRS+ES фреймворк |
| Самодельное | денормализованные read-таблицы + консьюмер | максимум контроля, простые требования |
Отдельно — materialized views СУБД как частный случай проекции: дёшево, но обновление и гранулярность ограничены движком БД; годится для простых агрегатов, не заменяет полноценный проектор.
Когда CQRS не надо
Главный антипаттерн — CQRS поверх простого CRUD. Если запросы на чтение прекрасно обслуживаются той же таблицей, что и запись, CQRS добавляет только лишнее: отдельную модель, проектор, eventual-консистентность и класс багов read-your-writes — в обмен ни на что. CQRS оправдан, когда чтение и запись реально расходятся: разная нагрузка, разные модели данных, разные хранилища под разные запросы. Подробнее границу проводит концептуальная статья.
Эксплуатационные ошибки
- Неидемпотентное применение. Проектор падает между apply и чекпоинтом → при рестарте задваивает эффект. UPSERT + атомарность.
- Чекпоинт не в одной транзакции с read-моделью. Разъезд проекции с источником, который замечают поздно.
- Игнор read-your-writes. «Иногда не вижу свою запись» — не флак, а неучтённый лаг async-проекции.
- Rebuild с остановкой чтения. Пересборка «на месте» вместо blue-green → даунтайм на время реплея.
- CQRS ради архитектурной моды поверх CRUD, которому хватило бы одной модели.
- Проекция вечно догоняет — один проектор не успевает за темпом записи; дробят по типам/ключам с учётом порядка.
Checklist: read-модель в проде
- Чтение и запись действительно расходятся (нагрузка/модель/хранилище) — CQRS оправдан?
- Проектор идемпотентен, чекпоинт атомарен с read-моделью?
- Учтён read-your-writes (токен/чтение из write-стороны/stickiness)?
- Отработан rebuild без даунтайма (blue-green) хотя бы на одной проекции?
- Источник воспроизводим на всю нужную глубину (лог ES / retention CDC-outbox)?
- Есть метрика лага проекции (позиция хвоста − чекпоинт)?
Если по большинству «да» — CQRS у вас работает, а не просто нарисован на схеме.
Демо и версии
- Demo:
digital-cookbook/architecture/cqrs— write-модель + async-проекция на событиях (SQL read-таблицаorders_readпод конкретный запрос, атомарный чекпоинт с проекцией); демонстрация отставания и приёма read-your-writes; rebuild проекции реплеем в blue-green без остановки чтения. Go/Java. - Версии хранилищ/движка пиновать при написании.
Документация и первоисточники
- Канон: Martin Fowler — CQRS, Greg Young — CQRS Documents (PDF), microservices.io — CQRS, Microsoft — CQRS pattern.
- Продукт/движки: Axon, EventStoreDB — projections, Marten.
- Смежное на сайте: CQRS — концепция, Event Sourcing на практике, гарантии доставки и идемпотентность, Outbox/Inbox, CDC (Debezium), аномалии и изоляция, кэш и инвалидацияСкоро, матрица решений.
Комментарии