open atlas
↑ К треку
Архитектурные паттерны ARCH · 07 · 02

Read models и проекции

Read model — денормализованный артефакт, построенный проецированием изменений write-стороны. Projection создаёт окно рассинхронизации: read model может устареть. Граница допустимого устаревания — осознанное проектное решение.

ARCH Senior ◷ 24 min
Уровень
ОсновыJuniorMiddleSenior

Команда биллинговой платформы разделила write и read модели. Write-сторона — нормализованный aggregate Order — была чистой. Read-сторона представляла собой отдельную таблицу OrderDashboardRow: одна денормализованная строка на заказ, предварительно объединённая с именем аккаунт-менеджера, последним платёжным событием и признаком просрочки. Запросы дашборда стали быстрыми. Но read model должна была откуда-то браться. Первая попытка команды была простой: после того как каждый command handler коммитил запись, он также напрямую обновлял таблицу OrderDashboardRow — в той же транзакции базы данных. Это работало. Согласованность была идеальной. Затем команда заметила, что каждая запись в Order — даже та, которая не имела ничего общего с дашбордом — стала занимать больше времени из-за дополнительного обновления OrderDashboardRow. Хуже того: когда продуктовая команда добавила вторую read model для отчётности аккаунт-менеджеров, command handlers снова разрослись. А когда добавили третью read model, старший инженер указал, что command handlers теперь несут три ответственности: применение инвариантов заказа, обновление view дашборда и обновление view отчёта по менеджерам. Command handlers запутались в построении read models. Команда сделала шаг назад и спросила: откуда должны браться read models? Ответ — projection: read model строится путём наблюдения за изменениями состояния на write-стороне и их преобразования в форму read model. Механизм projection, а не command handler, несёт ответственность за актуальность read model. Но projection вводит вопрос, которого команда не ожидала: read model может быть ещё не актуальной, когда придёт query. Насколько большое устаревание допустимо и как удержать это окно ограниченным?

Что такое read model

Read model — это предварительно построенная, денормализованная структура данных, сформированная для конкретного query. Это не write model, просмотренная под другим углом — это другой артефакт, построенный из изменений состояния write-стороны и структурированный под конкретную потребность в чтении.

Для дашборда заказов read model может быть таблицей OrderDashboardRow с колонками: order_id, customer_name, account_manager_name, submitted_at, total_amount, status, last_payment_event, is_overdue. Эта строка существует исключительно для обслуживания запроса дашборда. Никакой инвариант в ней не применяется. Никакое бизнес-правило в ней не живёт. Это материализованный ответ на вопрос «что нужно отобразить на дашборде?»

Разные read models обслуживают разные queries. Read model для view пайплайна аккаунт-менеджера имеет другие колонки, чем дашборд. Read model для отчёта о просроченных заказах финансовой команды имеет другую группировку и фильтрацию. Каждая read model формируется для своего конкретного потребителя. Это структурная свобода, которую предоставляет CQRS: чтения больше не вынуждены использовать форму write model.

Откуда берутся read models: projection

Projection — это процесс, транслирующий изменения состояния write-стороны в read model. Когда Order подаётся, write-сторона коммитит изменение в нормализованную таблицу orders. Projection наблюдает это изменение и обновляет OrderDashboardRow: получает имя аккаунт-менеджера, вычисляет итог, устанавливает статус. Теперь read model актуальна.

Projection — это механизм, которому принадлежит трансформация: изменение состояния или событие на write-стороне → обновление read model. Command handlers не обновляют read models. Эта ответственность целиком принадлежит projection.

Projection может наблюдать изменения write-стороны через разные механизмы:

  • Доменные события: command handler генерирует событие OrderSubmitted; projection подписывается и обрабатывает его.
  • Database triggers или change-data capture: projection наблюдает за таблицей orders напрямую на уровне базы данных.
  • Синхронный in-process вызов: projection вызывается напрямую после коммита записи, ещё в том же потоке (но в отдельной заботе от command handler).

Выбор механизма определяет характеристики устаревания read model.

Проблема синхронизации: устаревание

Когда projection работает асинхронно — через message bus или фоновый процесс, наблюдающий change-data capture — read model согласована с write-стороной в конечном счёте. Между моментом коммита записи и моментом запуска projection и обновления read model есть окно. В течение этого окна queries видят старые данные.

Это не случайность и не сбой. Это осознанный компромисс. Асинхронная projection отделяет write-путь от построения read model: записи коммитятся быстро; projection работает в фоне без блокировки command handler. Цена — устаревание.

Окно устаревания в здоровой системе обычно составляет миллисекунды — единицы секунд. Но команда должна решить: приемлемо ли это для каждой конкретной read model?

Для дашборда заказов несколько секунд устаревания обычно нормально. Менеджеру по продажам, подавшему заказ, не нужно видеть обновлённый статус на дашборде в том же запросе. Eventual consistency приемлема.

Для read model CurrentCreditAvailability, используемой при принятии решения о кредитном лимите во время подачи заказа, устаревание опасно. Если read model отстаёт, второй заказ может пройти проверку кредита по устаревшей цифре доступного кредита. Эта read model, вероятно, вообще не должна быть денормализованной проекцией — она должна быть синхронным запросом к write-стороне.

Why this works

Правильный вопрос об устаревании не «как его устранить?», а «какое устаревание допустимо для каждой конкретной read model?» Некоторые read models терпят секунды; некоторые — минуты (еженедельный отчёт); некоторые не терпят никакого лага и не должны быть асинхронными проекциями. Проектирование read models означает явное определение бюджета устаревания для каждой из них, а не повсеместный выбор eventual consistency по умолчанию.

Синхронная projection: согласованность ценой связанности

Синхронная projection выполняется в рамках той же транзакции, что и запись. Когда SubmitOrder коммитит, projection обновляет OrderDashboardRow в той же транзакции базы данных. Read model всегда согласована с write-стороной.

Цена: транзакция записи теперь длиннее (она также должна завершить обновление read model), а граница транзакции command handler теперь охватывает заботу read model. Если projection провалится, провалится весь command. Добавление ещё одной read model снова удлиняет транзакцию.

Синхронная projection уместна когда:

  • Read model проста и невелика (одна строка, одно быстрое обновление).
  • Устаревание read model недопустимо даже на миллисекунды.
  • Накладные расходы на дополнительную запись приемлемы.

Она становится проблематичной при наличии многих read models, когда построение read model затратно, или когда требуется независимое развитие write и read сторон.

Асинхронная projection: отвязанность ценой eventual consistency

Асинхронная projection выполняется после коммита записи — через доменное событие на message bus или через change-data capture. Command handler коммитит только изменение write-стороны. Projection выполняется в отдельном процессе или потоке.

Преимущества: транзакция command handler минимальна и быстра; забота projection отвязана от заботы записи; несколько read models могут обновляться независимыми projection workers без влияния на write-путь; write и read модели могут разворачиваться и масштабироваться независимо.

Перестройка через replay

Одно из самых мощных свойств read model на основе projection: её можно перестроить с нуля путём replay write-стороны. Если схема read model изменилась — добавлена колонка is_overdue, которой раньше не было — можно удалить read model, воспроизвести всю историческую write-стороны (или перепроецировать из исходной write-таблицы) и перестроить read model с новой колонкой. Никакой миграции данных на write-стороне. Никакой миграции схемы в write-таблице.

Это работает, потому что read model — производный артефакт. Она не хранит данных, которых нет на write-стороне. Write-сторона — источник истины; read model — материализованный view над ней. Удаление и перестройка read model ничего не теряет, что нельзя восстановить.

Это свойство становится ещё мощнее, когда write-сторона использует журнал событий как источник истины (event sourcing, рассматривается в юните 08). С журналом событий можно воспроизвести точную последовательность событий, породивших каждый заказ в истории, и построить новую read model с нуля в фоновом задании без простоя.

Викторина

У команды есть async projection, обновляющая read model дашборда заказов. Менеджер по продукту жалуется: «После того как я одобряю заказ, дашборд ещё несколько секунд показывает его как ожидающий». Инженерный лид отвечает: «Это ожидаемо — read model eventually consistent». Приемлемо ли это, и как должно было приниматься решение?

Викторина

Команде нужно добавить новую колонку `days_since_last_contact` в read model дашборда заказов. В традиционной нормализованной схеме это потребовало бы миграции базы данных. Как это обрабатывается иначе в CQRS-системе на основе projection?

Викторина

Старший инженер предлагает: «Мы должны иметь одну read model, которую используют все наши queries. Наличие нескольких read models для разных views — бесполезное дублирование». Каков контраргумент?

Вспомните перед уходом
  1. 01
    Что такое projection и почему она существует как отдельная забота от command handler?
  2. 02
    Что такое окно устаревания в async projection и как им управлять?
  3. 03
    Почему read model можно перестроить путём replay write-стороны, и когда это ценно?
Итог

Запутавшиеся command handlers из вступительной истории — обновляющие три read models inline — были не плохой реализацией CQRS. Это была правильная интуиция (держать read models отдельно от write model), воплощённая неправильным механизмом (построение read models в command handlers). Решение — projection: отдельная забота, наблюдающая изменения состояния write-стороны и преобразующая их в обновления read model. Транзакция command handler покрывает только write-сторону.

Projection вводит устаревание. Async projection быстра и отвязана — write-путь лёгкий — но read model отстаёт от write-стороны. Этот лаг почти всегда приемлем для views вроде дашбордов. Он неприемлем для read models, участвующих в write-side принятии решений. Каждая read model требует явного бюджета устаревания.

Read models — производные артефакты. Их можно удалить и перестроить из write-стороны без потери чего-либо. Новые колонки, исправления ошибок, новые view-требования — всё может обрабатываться обновлением логики projection и replay истории. Write-сторона не нуждается в изменении.

Следующий урок рассматривает, когда CQRS избыточен — сложность, которую он вводит, какие случаи его оправдывают, и распространённую ошибку путаницы CQRS с event sourcing.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.