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.