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

Проекции, replay и снапшоты

Read models в event sourcing строятся replay журнала событий через функцию projection. Replay позволяет перестраивать модели по требованию. Снапшоты ограничивают стоимость replay. Версионирование событий и upcasting сохраняют читаемость старых событий при эволюции схемы.

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

Новый журнал событий биллинговой команды работал. Каждое событие заказа добавлялось корректно. Но менеджер по продукту спросил: «Можете дать мне дашборд со всеми открытыми заказами, отсортированными по аккаунт-менеджерам, с выделенными просроченными?» Команда посмотрела на журнал событий. Он содержал полную историю каждого заказа — но ответить на этот запрос означало прочитать полную историю каждого из 200 000 заказов, свернуть каждый для получения текущего статуса, отфильтровать ожидающие, объединить с данными аккаунт-менеджера и отсортировать. На 200 000 заказов такой запрос занял бы минуты. Журнал событий был источником истины, но не был спроектирован для паттернов доступа к данным при чтении. Команде нужна была отдельная структура — read model — сформированная для запроса дашборда. И нужен был механизм для её построения и поддержки. Этот механизм — projection: процесс, читающий журнал событий и преобразующий его в денормализованную read model. Но projection сразу поставил новый вопрос: что происходит, когда формат журнала событий изменится через шесть месяцев? Команда только что добавила поле approval_threshold в события OrderApproved. А как быть с 40 000 событиями OrderApproved, уже записанными в журнал до появления этого поля?

Проекции: read models из журнала событий

Журнал событий — источник истины для write-стороны. Он не предназначен для паттернов доступа при чтении. Отвечать на запрос «все просроченные заказы аккаунт-менеджера Чена» сканированием и свёрткой 200 000-записного журнала при каждой загрузке страницы — структурно неправильно.

Projection — процесс, читающий события из журнала и строящий денормализованную read model, сформированную для конкретного запроса. Концепция та же, что в CQRS (юнит 07) — ключевое отличие в том, что источником проецирования является журнал событий, а не нормализованная реляционная таблица.

Каждая projection подписывается на поток событий и обрабатывает каждый интересующий тип:

on OrderPlaced:          вставить строку в OrderDashboard со status "pending"
on OrderApproved:        обновить строку, установить status "approved", approved_by, approved_at
on InvoiceGenerated:     обновить строку, установить status "invoiced", invoice_id

Результат — таблица OrderDashboard с одной строкой на заказ, денормализованной и сформированной для запроса дашборда. Запросы обращаются напрямую к read model — без свёртки событий во время запроса.

Rebuild by replay: структурная суперсила

Поскольку журнал событий — источник истины, а read model — производный артефакт, read model можно перестроить с нуля в любое время, воспроизводя полный журнал событий через функцию projection. Это структурно отличается от CRUD-системы, где read model и write model — одни и те же данные, и «воспроизводить» нечего.

Rebuild-by-replay ценен в нескольких сценариях:

Изменение схемы: Дашборду нужен новый столбец days_to_first_payment. Добавить столбец в таблицу read model, обновить логику projection для его вычисления, затем воспроизвести все события. Каждая историческая строка получает корректное значение, вычисленное из исходных данных события. Никакой write-side миграции.

Исправление бага: В projection был баг — она неправильно вычисляла признак просрочки, помечая заказы просроченными на два дня раньше. Исправить логику projection, воспроизвести журнал, и read model теперь корректна для всех исторических данных.

Новая read model: Финансовой команде нужен новый view: все заказы, сгруппированные по расчётному периоду. Создать новую projection, строящую таблицу BillingPeriodSummary. Воспроизвести существующий журнал событий для построения из истории. Не нужно ждать накопления будущих событий.

Why this works

Rebuild-by-replay возможен только потому, что журнал событий никогда не изменяется. Если бы события можно было редактировать или удалять, replay не воспроизводил бы исходную историю. Неизменяемость — не просто предпочтение дизайна в event sourcing: это структурная предпосылка для того, чтобы replay имел смысл.

Снапшоты: ограничение стоимости replay

Загрузка агрегата свёрткой его полного журнала событий становится дорогой по мере роста журнала. Заказ с 4000 событий требует 4000 вызовов applyEvent, прежде чем команда вообще начнёт обрабатываться. Это проблема производительности свёртки из урока 1.

Снапшот — кешированный промежуточный результат свёртки. На каком-то порядковом номере N приложение сохраняет производное состояние агрегата как снапшот рядом с журналом событий:

{
  aggregate_id: "order-48291",
  snapshot_seq: 3800,
  state: { status: "invoiced", total: 48200, line_items: [...], ... },
  created_at: "2024-06-01T12:00"
}

При следующей загрузке вместо начала с события 1 и применения всех 4000 событий приложение:

  1. Загружает последний снапшот (состояние на seq 3800)
  2. Загружает только события после seq 3800 (события 3801–4000)
  3. Сворачивает только эти 200 событий поверх состояния снапшота

Стоимость свёртки падает с 4000 до 200 шагов. По мере добавления новых событий новый снапшот берётся периодически.

Критический инвариант: снапшот — не источник истины. Источник истины — журнал событий. Снапшот можно удалить в любое время — состояние агрегата всегда можно восстановить свёрткой полного журнала с события 1. Снапшоты существуют исключительно для производительности свёртки; они не заменяют события.

Временны́е запросы: replay префикса журнала

Урок 1 ввёл временно́й запрос: чтобы получить состояние заказа №48291 третьего марта в 14:00, отфильтровать журнал до временно́й метки и свернуть префикс. Это структурное следствие неизменяемого append-only журнала.

Временны́е запросы влекут следствие для производительности: они не могут использовать снапшот, взятый после целевой временно́й метки (этот снапшот включает события, которые нужно исключить). Нужно складывать с начала журнала до целевой временно́й метки. Для агрегатов с очень длинной историей временны́е запросы к старым временны́м меткам дороги. Это известная структурная стоимость event sourcing — управляема для разовых комплаенс-запросов, но не должна быть на горячем пути real-time запросов.

Версионирование событий и upcasting

Журнал событий неизменяем — уже хранящиеся события нельзя изменить. Но понимание приложением того, что означает событие, эволюционирует. Через шесть месяцев после запуска системы OrderApproved получает новое поле approval_threshold. В журнале есть 40 000 событий OrderApproved, записанных до этого поля. Когда projection воспроизводит их, она встречает события без нового поля.

Два структурных ответа:

Weak schema / tolerant reader: Projection обрабатывает OrderApproved с политикой толерантности: если approval_threshold отсутствует, считать его null или значением по умолчанию. Код projection обрабатывает как старую, так и новую версии события. Работает для аддитивных изменений (новые необязательные поля) и является самым простым подходом.

Upcasting: При загрузке (или во время чтения) старые версии событий преобразуются в текущий формат до передачи в функцию applyEvent агрегата. Функция upcast для v1 → v2 OrderApproved может устанавливать approval_threshold: null для всех v1 событий. Логика свёртки агрегата видит только текущий формат. Журнал событий сохраняет исходные байты v1 — upcasting применяется при чтении, а не записывается обратно в журнал.

Викторина

Журнал событий биллинговой команды вырос до 12 миллионов событий для агрегата заказов. Команда замечает, что перестройка read model OrderDashboard с нуля (при изменениях схемы) теперь занимает 4 часа. Разработчик предлагает: «Нужно делать снапшот read model через регулярные интервалы и использовать его как отправную точку для будущих перестроек вместо replay с события 1». Это обоснованное предложение?

Викторина

Биллинговая команда хочет добавить новое поле `freight_zone` во все будущие события OrderPlaced. Они также хотят заполнить это поле для существующих событий OrderPlaced в журнале. Разработчик предлагает: «Можно UPDATE существующие строки событий OrderPlaced в базе данных, добавив поле freight_zone для исторических событий». В чём структурная проблема этого предложения?

Викторина

Команда проектирует event-sourced систему для агрегата заказов. Планируют делать снапшот каждые 100 событий. Новый член команды спрашивает: «Если у нас есть снапшот, можно ли удалять события до него для экономии места?» Каков правильный ответ и от чего он зависит?

Вспомните перед уходом
  1. 01
    Почему read model в event sourcing можно перестроить с нуля в любое время и почему это ценно?
  2. 02
    Что такое снапшот в event sourcing и какой инвариант он должен соблюдать?
  3. 03
    Что такое upcasting и почему он существует в event-sourced системах?
Итог

Журнал событий структурно решает проблему аудита и временны́х запросов. Но запрос менеджера по продукту к дашборду — все открытые заказы, отсортированные по аккаунт-менеджеру — нельзя ответить сканированием журнала событий во время запроса. Журнал событий оптимизирован для согласованности write-стороны и сохранения истории, а не для произвольных read-запросов.

Проекции закрывают этот разрыв: функция projection воспроизводит события из журнала и материализует read model, сформированную для конкретного запроса. Поскольку read model производна из неизменяемого журнала событий, её можно перестроить в любое время — изменения схемы, исправления багов, новые требования к запросам обрабатываются обновлением логики projection и replay. Никаких write-side миграций.

По мере роста журнала событий производительность свёртки деградирует. Снапшоты решают это: кешируют производное состояние агрегата на порядковом номере, ограничивая количество событий, которые нужно свернуть при загрузке. Снапшоты — чистая оптимизация производительности: журнал событий остаётся источником истины, снапшоты можно удалять и перестраивать.

Журнал событий неизменяем, но доменная модель не статична. Поля добавляются, переименовываются, реструктурируются. Upcasting справляется с этим: старые события преобразуются в текущую схему при чтении, сохраняя хранящиеся байты нетронутыми. История сохраняется; доменная модель может эволюционировать.

Следующий урок рассматривает ловушки: операционные и структурные издержки, которые event sourcing вводит в продакшне — eventual consistency, противоречие с GDPR, компенсирующие события и честное руководство о том, когда не применять 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.