CQRS и Event Sourcing (обзор)

Что делать, когда одна модель данных перестаёт устраивать одновременно и запись, и чтение — и почему Event Sourcing бывает и спасением, и капканом.

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

Это два разных приёма, которые из-за конференционных докладов слиплись в одно словосочетание. CQRS отлично живёт без Event Sourcing. Event Sourcing почти всегда тянет за собой CQRS. Разберём их по очереди — и честно поговорим о том, когда всё это не нужно.

Разделение чтения и записи

Модель для записи и модель для чтения хотят разного, и в монолите этот конфликт обычно давят компромиссом.

АспектЗапись (команды)Чтение (запросы)
Что важноинварианты, валидация, консистентностьскорость ответа и удобная форма
Форма данныхнормализованная, много таблицденормализованная витрина, один документ
Доля нагрузкиединицы процентов запросов90–99% запросов
Масштабированиевертикальное, аккуратноегоризонтальное: реплики, кэш, индексы

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

{
  "order_id": 101,
  "user_id": 7,
  "status": "Доставляется",
  "total": 9980,
  "items": [
    {"title": "Клавиатура", "qty": 2, "price": 4990}
  ],
  "delivery": {"eta": "2026-07-16", "courier": "Почта"},
  "updated_at": "2026-07-14T10:12:03Z"
}

Важно понимать: CQRS бывает разной глубины, и не обязательно сразу прыгать в самый сложный вариант.

УровеньЧто делаемЦена
1. Разные модели в кодекоманды работают с богатой доменной моделью, запросы — отдельными DTO и SQL-вьюхами; база однапочти бесплатно, это просто хорошая архитектура
2. Отдельное хранилище для чтенияпроекция в Redis, Elasticsearch или отдельную таблицу, обновляемая по событиямeventual consistency, лишняя инфраструктура
3. CQRS + Event Sourcingзапись — журнал событий, все read-модели строятся из неговысокая: версии событий, rebuild, новые навыки у команды

Главная плата за уровни 2 и 3 — eventual consistency. Пользователь нажал «Сохранить», сервер ответил 200, страница перезагрузилась — а проекция ещё не обновилась, и он видит старые данные. Это проблема read-your-writes. Лечится обычно так: команда возвращает клиенту версию (или сам изменённый объект), интерфейс показывает оптимистичный результат и при необходимости ждёт, пока проекция догонит нужную версию. Игнорировать эту проблему нельзя — она мгновенно превращается в поток баг-репортов «данные не сохраняются».

Event Sourcing: событие как источник истины

Обычная база хранит текущее состояние: balance = 4200. Как мы к нему пришли — неизвестно: историю затёр каждый UPDATE. Event Sourcing переворачивает картину: истина — это неизменяемый журнал произошедших фактов, а состояние — всего лишь их свёртка.

events = [
    {"type": "AccountOpened",  "owner": "Аня", "amount": 0},
    {"type": "MoneyDeposited", "amount": 5000},
    {"type": "MoneyWithdrawn", "amount": 1500},
    {"type": "MoneyDeposited", "amount": 700},
    {"type": "AccountFrozen",  "amount": 0},
]

def apply(state, event):
    t = event["type"]
    if t == "AccountOpened":
        return {"owner": event["owner"], "balance": 0, "frozen": False}
    if t == "MoneyDeposited":
        return {**state, "balance": state["balance"] + event["amount"]}
    if t == "MoneyWithdrawn":
        return {**state, "balance": state["balance"] - event["amount"]}
    if t == "AccountFrozen":
        return {**state, "frozen": True}
    return state

state = {}
for i, e in enumerate(events, 1):
    state = apply(state, e)
    print(i, e["type"], "баланс:", state["balance"])

print("Итоговое состояние:", state)

past = {}                    # а каким счёт был раньше? просто свернём меньше событий
for e in events[:3]:
    past = apply(past, e)
print("После 3-го события:", past)

Результат:

1 AccountOpened баланс: 0
2 MoneyDeposited баланс: 5000
3 MoneyWithdrawn баланс: 3500
4 MoneyDeposited баланс: 4200
5 AccountFrozen баланс: 4200
Итоговое состояние: {'owner': 'Аня', 'balance': 4200, 'frozen': True}
После 3-го события: {'owner': 'Аня', 'balance': 3500, 'frozen': False}

Обратите внимание на последние две строки. Мы бесплатно получили машину времени: состояние на любой момент — это свёртка событий до этого момента. В обычной базе вопрос «каким был баланс во вторник до обеда?» требует отдельной таблицы истории, которую надо было завести заранее, до того как вопрос возник.

Отсюда и настоящие выгоды Event Sourcing:

  • Аудит из коробки. Не «кто-то изменил цену», а «менеджер изменил цену тогда-то, событие PriceCorrected, причина такая-то». Для финансов, медицины, логистики это часто требование регулятора, а не пожелание.
  • Новые проекции задним числом. Аналитики придумали метрику, которой полгода назад не существовало? Постройте новую read-модель и прогоните по ней весь журнал с самого начала. Данные для этого уже есть — в отличие от мира UPDATE, где они безвозвратно затёрты.
  • Отладка. Баг воспроизводится проигрыванием реальной последовательности событий конкретного клиента.

И честные минусы, о которых на конференциях говорят заметно меньше:

  • Версионирование событий. Журнал вечен, а формат события меняется. Через год у вас в сторе лежат OrderPlaced трёх разных форм, и код обязан читать их все (апкастеры, версии схемы).
  • Удаление данных. Журнал неизменяем, а закон о персональных данных требует «удалить всё о клиенте». Приходится городить crypto-shredding: персональные поля шифруются, а «удаление» — это уничтожение ключа.
  • Никаких ad-hoc-запросов. На вопрос «сколько заказов дороже 5000 было в марте» журнал ответить не может — нужна заранее построенная проекция.
  • Нельзя просто «поправить руками». Ошибочный факт не редактируют, а компенсируют новым событием — ровно как сторно в бухгалтерии.

Как это работает

Под капотом Event Sourcing держится на четырёх деталях.

Event store и оптимистичная блокировка

События одного агрегата (например, одного счёта) лежат в потоке (stream) строго по порядку, запись — только в конец, append-only. Конкурентные изменения ловят по номеру версии: «добавь событие, если в потоке сейчас ровно 5 событий». Если параллельный процесс успел записать шестое, запись падает и команда переигрывается. Это заменяет привычные блокировки строк. Технически event store — часто обычная таблица PostgreSQL вида (stream_id, version, type, payload), и это совершенно нормально. А вот Kafka в роли event store — плохой выбор по умолчанию: у неё нет запроса «дай все события агрегата X» и нет проверки версии при записи.

Проекции и подписчики

Отдельный процесс читает журнал и обновляет read-модели: одну — в PostgreSQL для экрана заказов, другую — в Elasticsearch для поиска. Проекция всегда производная и восстановимая: её можно снести и построить заново с нуля (rebuild). Проверьте это заранее — команда, которая ни разу не пересобирала проекцию, обнаружит в самый неподходящий момент, что процесс занимает восемь часов.

Снапшоты

У счёта, живущего десять лет, миллион событий, и сворачивать их при каждом чтении глупо. Раз в N событий сохраняют снимок состояния, а при загрузке применяют только события после снимка.

Событие — это факт, а не уведомление об UPDATE

Самая частая содержательная ошибка: события вида OrderUpdated с полным телом объекта внутри. Это не событие, это репликация строк. Настоящее событие названо в прошедшем времени и несёт бизнес-смысл: OrderPlaced, PaymentFailed, ItemReturned, DiscountApplied. Разница становится очевидной, как только аналитик спросит «почему у этого заказа изменилась сумма» — из OrderUpdated вы этого не узнаете никогда.

Когда это оправдано, а когда оверинжиниринг

СитуацияЧто брать
Простой CRUD: справочники, админка, карточкиОбычная база. Никакого CQRS и ES
Чтений в 50–100 раз больше записей, экран тяжёлыйCQRS уровня 2 (проекция), без ES
Нужен аудит и ответ «как мы к этому пришли»: деньги, склад, медицинаEvent Sourcing уместен
Сложные и часто меняющиеся бизнес-правила, много вопросов «а почему так»ES + CQRS на одном агрегате, не на всей системе
Команда впервые слышит слова eventual consistencyНе начинать. Сначала база на сервис и Saga

Здравый подход звучит так: Event Sourcing — точечный инструмент для одного-двух самых ценных агрегатов (счёт, заказ, остаток на складе), а не архитектура всей системы. Каталог товаров и профиль пользователя прекрасно живут обычным CRUD в соседнем сервисе. Смешивать можно и нужно — в этом и прелесть микросервисов: разные сервисы имеют полное право на разные подходы к данным.

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

  • ES «на всю систему». Скорость разработки падает: даже добавление поля в справочник превращается в церемонию с версионированием событий.
  • CQRS ради CQRS. Разделили модели, завели вторую базу, получили eventual consistency — при нагрузке двадцать запросов в минуту. Чистый убыток.
  • Проекцию считают источником правды. В неё начинают писать напрямую, а потом rebuild всё затирает. Проекция — всегда только производная от журнала.
  • События-CRUD. UserUpdated вместо EmailChanged: вся ценность журнала теряется, остаётся одна сложность.
  • Не проверяли rebuild. Восстановление проекции — такая же процедура, как восстановление из бэкапа: не проверял — значит, её у тебя нет.
  • Ждут от read-модели строгой консистентности. «Почему в списке ещё старый статус?» — потому что вы выбрали eventual consistency. Об этом надо было предупредить продакт-менеджера на старте, а не после релиза.
  • Kafka как event store по умолчанию. Отличная шина, но не журнал агрегатов: нет чтения по агрегату и оптимистичной блокировки по версии.

Итоги

  • CQRS разделяет модель записи и модель чтения; это не обязывает вас брать Event Sourcing.
  • Уровни CQRS: разные модели в коде → отдельное хранилище для чтения → CQRS вместе с ES. Берите минимальный, который решает вашу задачу.
  • Плата за отдельную read-модель — eventual consistency и проблема read-your-writes; её надо явно спроектировать в интерфейсе.
  • Event Sourcing хранит журнал фактов, состояние — свёртка событий. Даёт аудит, машину времени и новые проекции задним числом.
  • Цена ES: версионирование событий, удаление персональных данных, отсутствие ad-hoc-запросов, невозможность «поправить руками».
  • Применяйте ES точечно — к одному-двум ценным агрегатам. Простой CRUD должен остаться простым CRUD.
Проверьте себя
1. Что из перечисленного НЕ следует автоматически из применения CQRS?
AОтдельная модель (а возможно, и отдельное хранилище) для запросов
BОбязательное использование Event Sourcing
CВозможность масштабировать чтение независимо от записи
DПоявление eventual consistency, если read-модель вынесена в отдельное хранилище
2. В каком случае Event Sourcing, скорее всего, окажется оверинжинирингом?
AУчёт остатков на складе с разбором спорных списаний
BБанковский счёт, где по закону нужен полный аудит операций
CСправочник городов и категорий в админке
DЗаказ со сложным жизненным циклом, возвратами и частичными отменами