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