Событийная архитектура, CQRS и Event Sourcing

Что если сервисы общаются не вызовами, а событиями, команды отделены от запросов, а история изменений и есть данные?

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

В прошлом уроке сервисы звали друг друга синхронно — и нам приходилось спасаться предохранителями. Событийная архитектура предлагает иной стиль связи: компонент не вызывает другой напрямую, а публикует факт («заказ оплачен»), и все, кому это интересно, реагируют сами. Связность падает, но появляется новая модель мышления — и набор приёмов, которые мы разберём.

Событийная архитектура: реакция на факты

В событийной системе есть издатели (publishers), которые сообщают о произошедшем, и подписчики (subscribers), которые реагируют. Связывает их брокер (Kafka, RabbitMQ). Ключевое: издатель не знает своих подписчиков и не ждёт ответа — он лишь объявляет факт.

[Заказ оплачен] ──> (брокер событий) ──> Склад: зарезервировать
                                      ├──> Почта: отправить чек
                                      └──> Аналитика: записать продажу

Сравните с синхронным вызовом: там сервис заказов сам дёргал бы склад, почту и аналитику и ждал каждого. Здесь он публикует одно событие — и свободен. Добавить четвёртого подписчика (например, программу лояльности) можно, ничего не меняя в издателе. Минус — слабая связь усложняет отладку: поток управления «размазан», и трудно увидеть всю цепочку. Важно различать событие («OrderPaid» — уже случилось, в прошедшем времени) и команду («ReserveStock» — приказ что-то сделать).

CQRS: разделить запись и чтение

Обычно одна модель обслуживает и запись, и чтение. Но требования к ним расходятся: запись должна проверять инварианты и быть нормализованной, а чтение — быть быстрым и часто хочет данные в денормализованном, готовом к показу виде. CQRS (Command Query Responsibility Segregation) разделяет их на две модели:

Команды (Command)Запросы (Query)
меняют состояниетолько читают, не меняют
«ОткрытьСчёт», «Снять»«ПоказатьБаланс»
модель записи, инвариантымодель чтения, денормализована
возвращают подтверждениевозвращают данные

Команда и запрос идут разными путями и могут опираться на разные хранилища: запись — в нормализованную БД, а чтение — в заранее построенные «проекции» под конкретные экраны. CQRS особенно силён в связке с событиями: команда порождает событие, а подписчик обновляет читающую проекцию. Платой становится сложность и, как правило, итоговая согласованность между моделями записи и чтения.

Event Sourcing: журнал как истина

Привычно хранить текущее состояние: в строке счёта лежит balance = 100, и при каждой операции мы это число перезаписываем. Event Sourcing переворачивает подход: источник истины — неизменяемый журнал событий («Открыт со 100», «Внесено 50», «Снято 30»), а текущий баланс — это результат их последовательного применения. Мы храним не итог, а всю историю, как бухгалтерская книга, где записи только дописываются.

class Account:
    def __init__(self):
        self.balance = 0          # состояние выводится из событий

    def apply(self, event):
        if event["type"] == "Opened":
            self.balance = event["amount"]
        elif event["type"] == "Deposited":
            self.balance += event["amount"]
        elif event["type"] == "Withdrawn":
            self.balance -= event["amount"]


# Журнал событий — единственный источник истины
event_store = [
    {"type": "Opened", "amount": 100},
    {"type": "Deposited", "amount": 50},
    {"type": "Withdrawn", "amount": 30},
]


def withdraw(store, account, amount):      # команда: дописывает событие
    if amount > account.balance:
        raise ValueError("недостаточно средств")
    event = {"type": "Withdrawn", "amount": amount}
    store.append(event)
    account.apply(event)


# Восстанавливаем состояние, проигрывая весь журнал
account = Account()
for e in event_store:
    account.apply(e)
print(f"Баланс после восстановления: {account.balance}")

withdraw(event_store, account, 20)
print(f"Баланс после снятия 20: {account.balance}")
print(f"Всего событий в журнале: {len(event_store)}")

Вывод:

Баланс после восстановления: 120
Баланс после снятия 20: 100
Всего событий в журнале: 4

Текущий баланс нигде не хранится напрямую — он вычисляется прогоном журнала методом apply. Это даёт суперспособности: полная история «кто, что, когда» бесплатно (аудит), возможность «отмотать» состояние на любой момент и построить новые проекции задним числом. Чтобы не проигрывать миллионы событий каждый раз, периодически сохраняют снимок (snapshot) и доигрывают только хвост после него.

Как это работает под капотом

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

Частые ошибки

  • CQRS/ES без надобности. Для обычного CRUD это тяжёлая артиллерия: два пути, проекции, согласованность. Если домен прост и аудит не нужен, обычная модель проще и дешевле. Подход берут под конкретные требования (аудит, сложный домен, разные нагрузки на чтение/запись).
  • Изменяемые события. Кто-то «исправляет» запись в журнале задним числом — и рушит весь смысл Event Sourcing. События неизменяемы; ошибку исправляют новым компенсирующим событием, а не правкой старого.
  • Ожидание мгновенной согласованности. Команда выполнилась, а запрос ещё показывает старое — и разработчик считает это багом. В CQRS с событиями это норма (eventual consistency); интерфейс проектируют с её учётом.
  • «Тощие» события. Событие Updated без указания, что именно изменилось, бесполезно для подписчиков и проекций. Событие описывает конкретный бизнес-факт: EmailChanged, OrderShipped, в прошедшем времени, с нужными данными.
  • Путаница события и команды. «ОтправитьПисьмо» — это команда (приказ одному адресату), а «ПисьмоОтправлено» — событие (факт для многих). Смешение ломает модель связности и направление потока.

Итоги

  • Событийная архитектура: издатели объявляют факты, подписчики реагируют через брокер; издатель не знает подписчиков и не ждёт ответа — низкая связность ценой сложной отладки.
  • CQRS разделяет модель записи (команды, инварианты) и модель чтения (запросы, денормализованные проекции) — каждую можно оптимизировать и масштабировать отдельно.
  • Event Sourcing хранит неизменяемый журнал событий как источник истины; текущее состояние выводится их проигрыванием, а снимки ускоряют восстановление.
  • Вместе они образуют цепочку: команда → событие в журнал → подписчик обновляет читающую проекцию; отсюда итоговая согласованность.
  • Выгоды — полный аудит, гибкие проекции, независимое масштабирование чтения; цена — сложность и отказ от мгновенной согласованности, поэтому подход применяют осознанно.
Проверьте себя
1. В чём главная идея Event Sourcing?
AИсточник истины — неизменяемый журнал событий, а текущее состояние выводится последовательным применением этих событий
BТекущее состояние перезаписывается при каждой операции, а история не хранится
CСобытия можно свободно редактировать задним числом для исправления ошибок
DВсе данные хранятся только в оперативной памяти без журнала
2. Что разделяет паттерн CQRS?
AМодель записи (команды, меняющие состояние) и модель чтения (запросы, возвращающие данные) — их можно оптимизировать раздельно
BФронтенд и бэкенд приложения
CБазу данных и кэш
DРазработчиков на две команды
3. Чем событие отличается от команды в событийной архитектуре?
AСобытие — это факт о уже случившемся (в прошедшем времени, для многих подписчиков), а команда — приказ что-то сделать, адресованный конкретному обработчику
BСобытие меняет состояние, а команда только читает данные
CКоманда асинхронна, а событие всегда синхронно
DМежду ними нет разницы, оба означают одно и то же