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 ForceCQRS как архитектурный паттерн; связка с DDD
2011Фаулер, заметка CQRSТрезвая оценка: «полезный приём в конкретных местах, не архитектура целиком»
2010-еEventStoreDB, Axon, Apache KafkaЖурналы и проекции стали рядовой инфраструктурой
2018Ричардсон, Microservices PatternsCQRS и проекции как стандартный приём управления данными в микросервисах

Стоит сразу развести частые смешения. 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)
1OrderCreatedorderId: 10427, customerId: "C-42"
2ItemAddedsku: "SKU-0042", qty: 3, price: 4330
3ItemAddedsku: "SKU-0117", qty: 1, price: 2980
4OrderConfirmedconfirmedAt: 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, лендингижурналу нечего фиксить; одна таблица решает всё
Строгая согласованность чтения обязательна для UXeventual 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-моделей управляемым.