CQRS на практике: проекции, rebuild, консистентность, tooling

Разделить чтение и запись — идея на слайде. На проде всё в проекциях: как строить read-модели, как жить с их отставанием (read-your-writes ломается), как пересобирать проекцию без даунтайма (blue-green, чекпоинты, идемпотентное применение) и когда CQRS оправдан без Event Sourcing, а когда тащит его за собой. Разбираем sync vs async проекции, несколько read-моделей на запрос, tooling (Axon/EventStoreDB/Marten) и типовую ошибку — CQRS поверх обычного CRUD

Концептуально CQRS — разделение модели записи и модели чтения: пишем в одну, читаем из другой, каждую оптимизируем под свою задачу. На слайде это одна стрелка, а в проде — целый слой проекций (read-моделей), у которого своя жизнь: их надо чем-то наполнять, они отстают от записи (и привычное «прочитал сразу после записи» перестаёт работать), их приходится пересобирать при изменении схемы или багов — желательно без даунтайма. И отдельный вопрос — нужен ли под CQRS Event Sourcing (нет, не обязательно) или он тащится следом.

Практический сиквел к концептуальной статье про CQRS; тесно связан с Event Sourcing на практике — CQRS и ES часто идут вместе, но это разные решения. Эта статья — про инженерию read-моделей: то, что превращает красивую схему в работающую систему.

Единый write-поток разветвляется в три read-модели — список-таблицу, поиск-лупу и кристалл данных; внизу пара blue-green проекций с рычагом-переключателем между ними

В статье

Чем наполнять проекцию

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:

  1. Создаём новую read-таблицу orders_v2 рядом с живой orders_v1.
  2. Запускаем проектор v2 с позиции 0 — он проигрывает всю историю в orders_v2 (может занять время).
  3. Когда v2 догнала хвост (её чекпоинт ≈ текущему максимуму), атомарно переключаем чтение на orders_v2.
  4. Старую 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 оправдан, когда чтение и запись реально расходятся: разная нагрузка, разные модели данных, разные хранилища под разные запросы. Подробнее границу проводит концептуальная статья.

Эксплуатационные ошибки

  1. Неидемпотентное применение. Проектор падает между apply и чекпоинтом → при рестарте задваивает эффект. UPSERT + атомарность.
  2. Чекпоинт не в одной транзакции с read-моделью. Разъезд проекции с источником, который замечают поздно.
  3. Игнор read-your-writes. «Иногда не вижу свою запись» — не флак, а неучтённый лаг async-проекции.
  4. Rebuild с остановкой чтения. Пересборка «на месте» вместо blue-green → даунтайм на время реплея.
  5. CQRS ради архитектурной моды поверх CRUD, которому хватило бы одной модели.
  6. Проекция вечно догоняет — один проектор не успевает за темпом записи; дробят по типам/ключам с учётом порядка.

Checklist: read-модель в проде

  1. Чтение и запись действительно расходятся (нагрузка/модель/хранилище) — CQRS оправдан?
  2. Проектор идемпотентен, чекпоинт атомарен с read-моделью?
  3. Учтён read-your-writes (токен/чтение из write-стороны/stickiness)?
  4. Отработан rebuild без даунтайма (blue-green) хотя бы на одной проекции?
  5. Источник воспроизводим на всю нужную глубину (лог ES / retention CDC-outbox)?
  6. Есть метрика лага проекции (позиция хвоста − чекпоинт)?

Если по большинству «да» — CQRS у вас работает, а не просто нарисован на схеме.

Демо и версии

  • Demo: digital-cookbook/architecture/cqrs — write-модель + async-проекция на событиях (SQL read-таблица orders_read под конкретный запрос, атомарный чекпоинт с проекцией); демонстрация отставания и приёма read-your-writes; rebuild проекции реплеем в blue-green без остановки чтения. Go/Java.
  • Версии хранилищ/движка пиновать при написании.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии