CQRS и Event Sourcing
Назначение: паттерны работы с данными для асимметричных нагрузок: CQRS — разделение моделей записи и чтения; Event Sourcing — хранение состояния как упорядоченного журнала событий.
Аудитория: архитекторы, техлиды, backend-разработчики.
Статус: устоявшиеся паттерны (CQS — Бертран Мейер, 1988; Event Sourcing — Мартин Фаулер, 2005; CQRS — Грег Янг, 2010).
Не путать с: EDA — архитектурным стилем асинхронного обмена событиями; микросервисами — стилем декомпозиции и развёртывания; репликацией БД — инфраструктурным копированием данных.
Общее
CQRS (Command Query Responsibility Segregation, разделение ответственности команд и запросов) — паттерн, при котором операции записи (команды) и операции чтения (запросы) работают с разными моделями: у командной стороны своя модель данных со своими объектами и схемой хранения, у запросной — своя, оптимизированная под конкретные сценарии чтения. Разделение проходит не по горизонтальным слоям, как в слоистой архитектуре, а по направлению: всё, что меняет состояние, идёт через одну модель; всё, что возвращает данные, — через другие.
Event Sourcing (событийная фиксация состояния) — паттерн хранения данных, при котором текущее состояние агрегата не хранится вовсе: единственный источник истины — упорядоченный журнал событий его жизни (OrderCreated, ItemAdded, OrderConfirmed), а состояние в любой момент вычисляется свёрткой (fold) журнала — последовательным применением событий к пустому начальному состоянию.
Два ключевых тезиса этой статьи. Первый: CQRS и Event Sourcing — независимые паттерны. CQRS описывает, как разделить запись и чтение, и не предписывает, как хранить данные; Event Sourcing описывает, как хранить данные, и не предписывает, как читать. Применять их можно по отдельности: CQRS поверх обычной реляционной базы — самый частый вариант; Event Sourcing без CQRS — редкий, но возможный. Второй: вместе они усиливают друг друга — журнал событий оказывается естественным «транспортом», из которого перестраиваются read-модели, поэтому связка «Event Sourcing + проекции» стала каноническим воплощением CQRS в зрелых системах.
Корни CQRS — в принципе CQS (Command-Query Separation) Бертрана Мейера из книги Object-Oriented Software Construction (1988): метод либо командует (меняет состояние и ничего не возвращает), либо спрашивает (возвращает данные и ничего не меняет), но не делает оба сразу. Грег Янг (Greg Young) в 2010 году перенёс принцип с уровня метода на уровень архитектуры: если команда и запрос — принципиально разные вещи, пусть у них будут разные модели. Термин Event Sourcing ввёл Мартин Фаулер (Martin Fowler) в заметке Event Sourcing (martinfowler.com, 2005). Оба паттерна выросли из сообщества DDD: команды и агрегаты — тактические паттерны DDD, доменные события — их естественный язык; Янг и Уди Дахан продвигали связку в дискуссиях 2010 года (CQRS Task Force). Инфраструктурную зрелость обеспечило десятилетие 2010-х: специализированные хранилища журналов (EventStoreDB), фреймворки (Axon), распределённые логи (Apache Kafka) — и волна микросервисов, где управление данными без единой базы стало ежедневной задачей.
| Год | Веха | Значение для паттернов |
|---|---|---|
| 1988 | Мейер, Object-Oriented Software Construction — принцип CQS | Разделение команд и запросов на уровне метода |
| 2005 | Фаулер, заметка Event Sourcing | Состояние агрегата как свёртка журнала событий |
| 2010 | Янг, доклады и «CQRS Documents»; CQRS Task Force | CQRS как архитектурный паттерн; связка с DDD |
| 2011 | Фаулер, заметка CQRS | Трезвая оценка: «полезный приём в конкретных местах, не архитектура целиком» |
| 2010-е | EventStoreDB, Axon, Apache Kafka | Журналы и проекции стали рядовой инфраструктурой |
| 2018 | Ричардсон, Microservices Patterns | CQRS и проекции как стандартный приём управления данными в микросервисах |
Стоит сразу развести частые смешения. CQRS — это не два микросервиса, не обязательная вторая база и не брокер сообщений (подробности — в следующем разделе). Event Sourcing — это не audit log: журнал у него — не побочная запись рядом с «настоящими» таблицами, а единственный источник истины, из которого всё выводится. Наконец, оба паттерна — не «архитектура верхнего уровня»: Фаулер в заметке 2011 года предупреждал, что CQRS уместен точечно, там, где асимметрия чтения и записи реальна, и что большинство систем прекрасно живёт без него. Порядок статьи повторяет логику системы: сначала CQRS (разделение моделей), затем Event Sourcing (хранение), затем их связи с DDD и EDA — и честный счёт за всё это в разделах «Плюсы», «Минусы» и «Когда применять».
CQRS: разделение моделей
Команда — запрос на изменение состояния: PlaceOrder, CancelOrder, ConfirmShipment. Команда выражена императивом, адресна (обрабатывается командной моделью), может быть отклонена — нарушением инварианта («нельзя подтвердить пустой заказ», «нельзя списать больше остатка») — и возвращает лишь факт принятия или отказа, но не данные. Запрос — чтение: «активные заказы клиента C-42», «сумма продаж по SKU за август». Запрос не меняет состояние и потому не может быть отклонён бизнес-правилом — только технической ошибкой. Семантика сообщений «команда против события» подробно разобрана в статье об EDA; здесь важна разница ролей.
| Ось | Команда | Запрос |
|---|---|---|
| Семантика | «сделать» | «показать» |
| Меняет состояние | да | нет |
| Может быть отклонена | да — нарушением инварианта | нет — только технический сбой |
| Возвращает | факт принятия или отказа | данные |
| Модель | модель записи: агрегаты, инварианты | read-модели: проекции под сценарий |
| Оптимизируется под | целостность, корректность переходов | скорость и форму ответа |
Когда разделение оправдано — три типовых сигнала:
- Несимметричная нагрузка. Типичный интернет-магазин выполняет сотни и тысячи чтений на одно изменение: каталог листают все, заказы оформляют единицы. Единая модель обязана проводить обе нагрузки одинаково дорогим путём; CQRS позволяет масштабировать сторону чтения — реплики, кеши, денормализованные таблицы, — не трогая сторону записи.
- Разные формы данных. Модели записи нужен нормализованный агрегат, отражающий инварианты; экранам — плоские «широкие» представления: список заказов клиента со статусами, суммами и составом позиций на одном экране. В единой модели это вырождается в тяжёлые JOIN’ы и ленивые загрузки; в read-модели — один SELECT из заранее собранной таблицы.
- Много противоречивых сценариев чтения. Один и тот же заказ читают витрина клиента (первые позиции и статус), склад (только резервы), поддержка (всё плюс история), аналитика (агрегаты по SKU). Ни одна единая модель не отвечает всем сценариям одинаково хорошо; CQRS даёт каждой задаче собственную проекцию.
Поток данных в CQRS:
┌────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌────────────┐
│ Команда │ │ Модель записи │ │ Модель чтения │ │ Запрос │
│ PlaceOrder ├───►│ агрегат Order: │ │ orders_overview │◄───┤ «активные │
└────────────┘ │ инварианты, │ │ плоская таблица │ │ заказы │
│ переходы │ │ под экран списка│ │ клиента» │
└────────┬─────────┘ └─────────▲────────┘ └────────────┘
│ │
│ доменные события │ проекция: события
│ (OrderPlaced, ItemAdded) │ перестраивают таблицу
└───────────────►─────────────┘
Обратите внимание на характерную асимметрию: командная сторона насыщена доменной логикой (инварианты, переходы состояний), запросная — почти пуста. У чтения нет инвариантов, поэтому query-обработчик идёт из контроллера прямо к read-модели, минуя домен; в этом CQRS смыкается с рассуждениями об асимметричной насыщенности слоёв в слоистой архитектуре и с устройством запросной стороны в Clean Architecture.
Чего CQRS не требует — развенчание трёх главных мифов:
- Не требует двух сервисов. CQRS — про модели, а не про процессы. Обе стороны могут жить в одном монолите: два пакета
commandsиqueries, у каждого свои обработчики и представления. Это лучший первый шаг; выделение в отдельный сервис — самостоятельное решение со своей ценой (микросервисы). - Не требует отдельной базы данных. Read-модель может быть SQL-представлением над таблицами записи, отдельной схемой в той же СУБД или отдельным хранилищем под конкретную задачу (Elasticsearch для поиска, ClickHouse для аналитики). Выбор определяется нагрузкой, а не паттерном.
- Не требует Event Sourcing и брокера. Read-модель можно обновлять в той же транзакции, что и запись, — синхронный CQRS («CQRS lite»): строгая согласованность ценой пары дополнительных UPDATE. Асинхронность и eventual consistency появляются, когда обновление проекций уходит в фон, — это осознанный выбор, а не обязательное свойство паттерна.
Главное заблуждение — считать CQRS «архитектурой всего приложения». Фаулер называл CQRS «последней вещью, которую стоит рассматривать»: точечный приём для мест с доказанной асимметрией, а не каркас, на который собирают систему целиком.
Event Sourcing: журнал событий и проекции
В классическом хранении таблица держит «текущее состояние»: UPDATE затирает прошлое безвозвратно — после изменения статуса заказа узнать, что было до, уже неоткуда. В Event Sourcing хранится только журнал, а состояние всегда вычисляется. Журнал жизни одного заказа:
| № | Событие | Полезная нагрузка (data) |
|---|---|---|
| 1 | OrderCreated | orderId: 10427, customerId: "C-42" |
| 2 | ItemAdded | sku: "SKU-0042", qty: 3, price: 4330 |
| 3 | ItemAdded | sku: "SKU-0117", qty: 1, price: 2980 |
| 4 | OrderConfirmed | confirmedAt: 2026-09-01T07:00:03+03:00 |
Текущее состояние — свёртка журнала:
fold(#1..#4) →
status = CONFIRMED # OrderCreated создал «черновик», OrderConfirmed перевёл в «подтверждён»
items = 2 позиции # два ItemAdded
total = 3 × 4330 + 1 × 2980 = 15 990
Та же свёртка в коде (Python, псевдокод):
def fold_order(events):
"""Текущее состояние агрегата = свёртка журнала событий."""
state = None # агрегата ещё не существует
for event in events:
match event.type:
case "OrderCreated":
state = Order(id=event.order_id, status="draft",
items=[], total=0)
case "ItemAdded":
state.items.append(Item(event.sku, event.qty))
state.total += event.qty * event.price
case "OrderConfirmed":
assert state and state.items # инвариант: подтверждать есть что
state.status = "confirmed"
return state
Свойства журнала следуют из его природы. События иммутабельны и только добавляются (append-only): история не переписывается никогда. «Отмена» — новое событие-компенсация (OrderCancelled), а не DELETE: журнал фиксирует, что было, включая ошибки и их исправления. Порядок внутри одного агрегата гарантирован (нумерация seq в потоке), глобального порядка между агрегатами нет. Конкурирующие записи разруливаются оптимистичным контролем версий: команда читает агрегат на версии N, фиксирует события с версией N+1; чужая запись с той же версией — конфликт и повтор команды.
Отличие от audit log принципиально: журнал — не побочная запись «рядом с данными», а единственный источник истины. Таблиц текущего состояния в системе нет; всё — включая сами read-модели — производно от журнала. Отсюда бесплатный аудит («что происходило с заказом 10427») и «машина времени»: состояние на любой момент прошлого — это fold событий до нужного номера, что незаменимо при разборе инцидентов («что видела система в момент расчёта скидки»).
Проекции (read-модели) — обработчики, которые применяют события к представлениям, заточенным под конкретные запросы: orders_overview — плоская таблица для списка заказов, sales_by_sku — агрегаты для аналитики, order_search — поисковый индекс. Проекций может быть сколько угодно, и это главный выигрыш Event Sourcing для чтения: каждому сценарию — своя форма данных, все перестраиваются из одного журнала.
ЕДИНЫЙ ЖУРНАЛ СОБЫТИЙ АГРЕГАТА (append-only)
┌──────────────────────────────────────────────────────────────────────┐
│ #1 OrderCreated ─► #2 ItemAdded ─► #3 ItemAdded ─► #4 OrderConfirmed │
└──────────────┬───────────────────────────────────────────┬───────────┘
│ │
│ fold: свёртка журнала, │ проекции: события применяют
│ возможно от снапшота │ обработчики read-моделей
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────────┐
│ Состояние агрегата │ │ Read-модели: │
│ (командная сторона): │ │ orders_overview — список │
│ status = CONFIRMED │ │ sales_by_sku — аналитика │
│ items = 2 позиции │ │ order_search — поиск │
│ total = 15 990 │ └───────────────────────────────┘
└───────────────────────────┘
Проекция — производный артефакт: её можно в любой момент удалить и перестроить заново повторным применением журнала (replay). Это превращает «миграцию чтения» в рутинную операцию: изменилась форма экрана — завели новую проекцию, проиграли в неё историю, переключили чтение, старую удалили. Данные записи при этом не тронуты.
Снапшоты — оптимизация свёртки для долгоживущих агрегатов. Когда событий становится сотни и тысячи, fold от первого события делается недопустимо долго; поэтому каждые N событий сохраняется снимок состояния:
снапшот v97 = {status: CONFIRMED, items: [...], total: 15 990} # после события #97
журнал: #1 ... #97 | #98 ... #134 # хвост после снапшота
восстановление = снапшот(97) + fold(#98..#134) # вместо fold всех 134 событий
Снапшот — тоже производные данные: его потеря не страшна (состояние перестраивается полным fold’ом), и он никогда не становится источником истины.
Где хранить журнал. Специализированные событийные СУБД (EventStoreDB) дают потоки на агрегат, подписки и встроенные проекции. Обычная реляционная база справляется через append-only таблицу events(aggregate_id, seq, type, payload, occurred_at) с уникальным индексом (aggregate_id, seq) — он же механизм оптимистичного контроля версий. Распределённые логи (Apache Kafka) используются как хранилище событий с оговорками: retention по умолчанию ограничен, а партиционирование даёт порядок только внутри ключа. Выбор хранилища — отдельное решение; паттерн от него не зависит.
Связь с DDD / EDA / eventual consistency
С DDD связана командная сторона. Оба паттерна выросли из DDD-сообщества, и словарь у них общий. Агрегат — единица журнала: поток событий — история одного агрегата (orders/10427), транзакция не пересекает его границу (границы согласованности агрегата — в статье о DDD). Доменное событие — формат записи: событие называется фактом предметной области в прошедшем времени (OrderPlaced, а не UpdateOrderStatus). Командная сторона CQRS — это классический DDD: команда загружает агрегат через репозиторий, агрегат проверяет инварианты и регистрирует события; запросная сторона домен обходит.
EDA поставляет транспорт. События, зафиксированные командной стороной, должны попасть к потребителям — проекциям и внешним подписчикам. Это задача событийной инфраструктуры: брокер, подписки, гарантии доставки, outbox для атомарности «данные + событие» — всё это разобрано в статье об EDA. Важное следствие: при доставке at-least-once проекции обязаны быть идемпотентными — повторное событие не должно менять результат (дедупликация по eventId или естественная идемпотентность присваиваний).
Eventual consistency — цена асинхронных проекций. Между фиксацией события и обновлением read-модели есть интервал: миллисекунды при активной подписке, секунды при отставании потребителя. Всё это время данные чтения расходятся с данными записи. Это тот же компромисс, что и в CAP-теореме и BASE: доступность и скорость в ущерб строгой согласованности. Практические следствия:
- Read-your-own-writes. Сразу после
PlaceOrderэкран «мои заказы» может заказа не показать. Решения: возвращать результат команды напрямую из обработчика (минуя read-модель), «приклеивать» пользователя к узлу записи на время сессии или честно показывать индикатор «обрабатывается». - Мониторинг отставания проекций. Лаг проекции — метрика первой важности: застрявший обработчик silently портит данные чтения, пока никто не смотрит.
- Проектирование UX под запаздывание, а не вопреки ему: инварианты, которые обязаны быть строгими (баланс, остаток на складе), проверяются на командной стороне — до фиксации события, а не в проекции.
Наконец, разведём три понятия, которые слишком часто склеиваются в одно слово «CQRS». Это три независимые оси:
| Паттерн | Отвечает на вопрос | Можно ли без остальных |
|---|---|---|
| CQRS | как разделить модели записи и чтения | да: read-модель строится из тех же таблиц, синхронно |
| Event Sourcing | как хранить состояние | да: журнал и fold без отдельных read-моделей (редко, но возможно) |
| EDA | как компоненты обмениваются | да: события могут применяться к проекциям в том же процессе, без брокера |
Комбинации свободны: «CQRS + Event Sourcing + EDA» — канонический стек высоконагруженных систем; «CQRS без Event Sourcing» — самая массовая форма (обычная база плюс проекции); «EDA без обоих» — событийная интеграция сервисов без разделения моделей; «Event Sourcing без CQRS» встречается редко, потому что журналом неудобно отвечать на запросы — проекции приходится заводить так или иначе. Частая ошибка — назвать весь стек одним словом «CQRS» и получить все минусы сразу, купив только часть плюсов.
Плюсы
Независимое масштабирование записи и чтения. Стороны масштабируются отдельно: чтение — репликами, кешами и денормализацией, запись — вертикально и шардированием агрегатов. Всплеск чтения (распродажа, публикация в соцсетях) не трогает командную инфраструктуру.
Полный журнал фактов. Аудит по построению: что произошло, когда и в каком порядке, — без отдельной подсистемы логирования бизнес-операций. Для финансов, биллинга и регуляторики это не «бонус», а требование, которое Event Sourcing закрывает архитектурно.
Машина времени. Состояние на любой момент — fold журнала до нужного события. Разбор инцидентов («что видела система в момент списания»), воспроизведение гонок, аналитика «как было» — всё это чтение того же журнала.
Несколько проекций под разные запросы. Каждому сценарию чтения — своя идеальная форма данных: плоский список, поисковый индекс, витрина аналитики. Перечисленные выше противоречия «одна модель для всех экранов» снимаются раз и навсегда, а смена формы чтения — это новая проекция плюс replay, без миграции данных записи.
Чистая модель записи. Модель записи освобождена от компромиссов чтения: агрегат отражает инварианты и переходы состояний, а не «ещё и поля для экрана». Наоборот, модель чтения освобождена от домена: у неё нет инвариантов, и она может быть простой таблицей. Каждая сторона делает одно дело.
Естественная событийная интеграция. Журнал — готовый поток фактов для других сервисов и аналитики: интеграция через EDA не требует «второго» механизма публикации — события уже есть.
Минусы
Сложность. Вместо одной таблицы появляется подсистема: журнал, проекторы, проекции, реплеи, снапшоты, мониторинг отставания, версионирование. Каждая часть по отдельности понятна; вместе это собственная платформа с собственной эксплуатацией и точками отказа.
Eventual consistency проекций. Расхождение данных чтения и записи — режим работы, а не баг: пользователь видит «обрабатывается» там, где команда уже принята, а поиск отстаёт от витрины. Команда обязана проектировать UX и инварианты под запаздывание; строгая согласованность чтения после записи требует специальных приёмов.
Версионирование событий (schema evolution). События живут годами и не могут быть переписаны: старые записи журнала бессмертны. Добавить поле — легко; изменить семантику или отформатировать поле — значит написать upcasting (трансформацию старых версий в новые на лету) или копирующую миграцию журнала. Неумолимое накопление версий — налог, который платит каждое изменение модели.
Снапшоты как обязательная оптимизация. Долгоживущие агрегаты (клиент за годы, корзина-подписка) без снапшотов складываются недопустимо долго — рано или поздно снапшотинг приходится делать: ещё один компонент с политиками частоты, хранением и инвалидацией.
Отладка и наблюдаемость. Нет «таблицы с текущим состоянием», которую можно открыть и посмотреть: состояние — функция, и его нужно вычислять. Расследование бага превращается в реконструкцию из событий; нужны инструментарий (просмотр потоков, replay, correlation по eventId) и дисциплина трассировки. Стек-трейса «от экрана до данных» больше не существует.
Порог для команды. Паттерны требуют зрелости: понимания DDD, опыта эксплуатации асинхронных систем, культуры работы с контрактами. Команда без этого опыта получит не audit log и масштабирование, а медленную разработку и аварию согласованности — что и наблюдается в большинстве провалов внедрения.
Когда применять
CQRS и Event Sourcing — не серебряная пуля. Это точечные инструменты для выраженной асимметрии и специфических требований; Фаулер прямо советовал рассматривать их последними. Сигналы «за»:
- Высоконагруженное чтение с явной асимметрией — десятки и сотни чтений на запись, разные формы данных для записи и чтения, несколько противоречивых сценариев чтения одних и тех же данных.
- Требования аудита и воспроизводимости — финансы, биллинг, страхование, ставки, документооборот: журнал фактов здесь регуляторное требование, и Event Sourcing закрывает его без «второй системы» аудита.
- Сложный домен с богатой историей состояний — DDD-агрегаты со сложными жизненными циклами, где сама модель естественно событийная (заказы, заявки, договоры).
- Зрелая команда и существующая событийная инфраструктура — уже есть брокер, культура контрактов, опыт эксплуатации.
Сигналы «против»:
| Ситуация | Почему не надо |
|---|---|
| CRUD-приложения, админки, типовые CMS, лендинги | журналу нечего фиксить; одна таблица решает всё |
| Строгая согласованность чтения обязательна для UX | eventual consistency проекций будет постоянным багом |
| Ранняя стадия продукта, модель ещё меняется | версионирование событий добьёт быстрее, чем рынок |
| Маленькая команда без опыта событийных систем | эксплуатационная цена превысит выгоду на порядок |
| Единственное «за» — мода или «на конференции показали» | классический антипаттерн выбора техник без рисков (см. RDD) |
Инкрементальный путь, если сигналы сошлись: начните в монолите с CQRS-lite для самого тяжёлого экрана (read-модель в той же транзакции), отделите самый горячий запрос в асинхронную проекцию, и только когда журнал действительно понадобится — вводите Event Sourcing для одного агрегата, не для всей системы. Решение о каждом таком шаге стоит фиксировать как ADR — с формулировкой риска, который шаг закрывает, и ценой, которую добавляет.
Связанные статьи
- Domain-Driven Design (DDD) — агрегаты как единица журнала, доменные события, репозитории; словарь, на котором говорят CQRS и Event Sourcing.
- Событийно-ориентированная архитектура (EDA) — брокеры, гарантии доставки, идемпотентность и outbox: транспорт, по которому события доходят до проекций.
- Микросервисы — управление данными без общей базы, саги и паттерны распределённых систем, где CQRS стал стандартным приёмом.
- Clean Architecture — как организовать каждую из сторон CQRS: интеракторы с бизнес-правилами на командной, тонкие шлюзы к read-моделям на запросной.
- Слоистая архитектура — асимметрия насыщенности домена «толстый для команд, тонкий для запросов», которую CQRS доводит до явного разделения моделей.
- CAP-теорема — выбор в пользу доступности в ущерб строгой согласованности, стоящий за eventual consistency проекций.
- BASE — итоговая согласованность как принцип проектирования, делающий расхождение read-моделей управляемым.