CQRS в распределенных системах

Оглавление

  1. Введение
  2. Традиционный подход: единая модель данных и ее проблемы
  3. Альтернативные подходы
  4. Что такое CQRS: суть паттерна и принцип работы
  5. Пример использования CQRS
  6. Реализация CQRS на Spring Boot
  7. Преимущества и недостатки CQRS
  8. Заключение

Введение

Command-Query Responsibility Segregation (CQRS) – это архитектурный паттерн, предлагающий раздельное ведение операций модификации данных (команд) и операций чтения данных (запросов). Идея возникла из принципа Command-Query Separation (CQS) Бертрана Мейера, где каждый метод либо команда (изменяет состояние), либо запрос (возвращает данные), но не совмещает и то и другое. CQRS развивает эту идею на уровне всей архитектуры приложения: мы используем отдельную модель для обновления (write model) и отдельную модель для чтения (read model) данных. Таким образом, пишущие операции (команды) и читающие операции (запросы) обрабатываются разными компонентами, что позволяет оптимизировать их независимо друг от друга. Этот паттерн был впервые описан Грегом Янгом и получил распространение в контексте предметно-ориентированного проектирования (DDD) и событийно-ориентированных систем.

Почему может понадобиться подобное разделение? По мере роста сложности приложения и объема данных традиционный унифицированный подход начинает сталкиваться с ограничениями. В этом обзоре мы разберем, с какими проблемами сталкивается монолитная архитектура с единой моделью данных, какие существуют альтернативные подходы (Active Record, Transaction Script и др.) для обработки бизнес-логики, и какие неудобства возникают при их использовании. Затем мы детально рассмотрим, как паттерн CQRS решает эти проблемы, обсуждая его архитектуру, плюсы и минусы. Мы также приведем пример реализации CQRS на Spring Boot с использованием Java и PostgreSQL – с элементами кода и диаграммами (диаграмма последовательности и Container-диаграмма по модели C4) для лучшего понимания. Статья рассчитана на опытных Java-разработчиков (middle+), стремящихся углубиться в инженерные детали CQRS.

Традиционный подход: единая модель данных и ее проблемы

В традиционной архитектуре приложения используется единая предметная модель и единственное хранилище данных для операций и чтения, и изменения (CRUD – Create, Read, Update, Delete). Такой монолитный подход прост в реализации и хорошо подходит для базовых операций с данными. Например, классическая трехслойная архитектура (контроллер – сервис – база данных) может напрямую использовать ORM-модель объектов как для обновления, так и для выборки данных. Однако с ростом системы такой унифицированный подход начинает буксовать по нескольким причинам:

  • Разный формат данных для чтения и записи. Модель данных, оптимизированная под запись, часто содержит поля и связи, не нужные при чтении. Например, для обновления сущности могут требоваться множество связанных объектов и валидаций, тогда как для отображения в UI нужен только агрегированный DTO с небольшим набором полей. Единая модель вынуждает либо перегружать чтение лишней информацией, либо усложнять код выборки нужных проекций.
  • Блокировки и снижение производительности. Единая база данных испытывает конкуренцию между транзакциями чтения и записи, что приводит к блокировкам (lock contention) и взаимному влиянию нагрузок. Чем больше приложение, тем сложнее оптимизировать сразу и сложные запросы (которые могут требовать сложных JOIN’ов и нагружают БД), и частые записи (требующие быстрой фиксации транзакций). В результате ни чтение, ни запись не работают оптимально – наблюдаются проблемы с производительностью при росте нагрузки. Например, множество JOIN’ов для сборки DTO замедляют чтение, а индексы, добавленные для ускорения запросов, могут замедлять вставку/обновление данных.
  • Осложнения с масштабированием. Если в системе чтений гораздо больше, чем записей, то единую модель сложно масштабировать под такие асимметричные нагрузки. Нельзя, например, просто так вынести только часть запросов на реплику, поскольку та же схема БД обслуживает и записи. При разделении же моделей появляется возможность масштабировать чтение и запись отдельно (горизонтально масштабировать только чтение, не трогая контур записи, или наоборот).
  • Сложность в управлении правами доступа. В единой модели сложно изолировать операции изменения от операций чтения с точки зрения безопасности. Например, если у объекта есть метод получения данных и метод изменения, они находятся в одном классе/сервисе. Это повышает риск, что кто-то получит доступ не только к чтению, но и к изменениям сущности. Разделив ответственность, легче применять раздельные политики безопасности – например, давать большинству пользователей только права на чтение определенных проекций, а право выполнения команд ограничить системой или административными ролями.
  • Усложнение модели предметной области. Попытка одной объектной моделью охватить и все бизнес-правила изменения, и все варианты отображения данных приводит к усложнению дизайна. Модель разрастается, пытаясь “делать все и сразу”, из-за чего ее поддерживаемость падает. Возникает либо дублирование логики (отдельно для разных случаев чтения), либо нарушение принципа единственной ответственности. Как отмечает Мартин Фаулер, единая концептуальная модель, обслуживающая одновременно команды и запросы, в сложных доменах “не делает хорошо ни то, ни другое”.

Таким образом, монолитный CRUD-подход начинает испытывать трудности на серьезных проектах. Разработчики ищут способы упростить и разгрузить систему: кто-то добавляет кэширование, кто-то заводит реплики базы данных только для чтения (read replicas), кто-то придумывает специальные денормализованные представления (например, материализованные представления, view-таблицы) под нужды отчетов. Эти методы частично решают проблему нагрузки на чтение, но не устраняют принципиально архитектурную сложность единой модели. Например, реплики синхронизируют те же самые данные и не помогают избавиться от лишних полей или сложных связей при запросах; материализованные представления нужно вручную поддерживать в актуальном состоянии (через триггеры, расписание или код), что вносит дополнительную сложность. В итоге встает вопрос: может ли существовать иной подход к проектированию, избегающий указанных проблем? Ниже рассмотрим альтернативные шаблоны проектирования бизнес-логики, применяемые до CQRS, и их ограничения.

Альтернативные подходы: Active Record, Transaction Script, Domain Model

Прежде чем перейти к CQRS, важно понять, какие подходы используются для организации бизнес-логики в традиционных приложениях. В классической книге Мартина Фаулера Patterns of Enterprise Application Architecture описаны несколько паттернов, которые предшествуют CQRS и DDD-подходу:

  • Transaction Script. Этот паттерн организует всю бизнес-логику в процедурах (скриптах) транзакций, где каждая процедура последовательно выполняет необходимые шаги для конкретного запроса из UI. Проще говоря, для каждого действия пользователя пишется отдельная функция/метод, который может вызывать запросы к базе данных (напрямую или через простой DAO) и выполнять вычисления. Transaction Script славится простотой и прямолинейностью – его легко понять и реализовать для небольших приложений, где логика относительно проста и линейна. Например, в приложении управления товарами функция calculateDiscount(productId) может просто вытащить товар из БД и посчитать скидку процедурно.
    Плюсы: минимальный барьер для входа, четкая последовательность шагов, часто отсутствие сложного слоя объектов.
    Минусы: при разрастании логики вы рискуете получить “спагетти”-код из процедур, трудно повторно использовать код и поддерживать масштабируемость. В сложных сценариях Transaction Script перестает справляться – логика дублируется между скриптами, трудно обеспечить целостность данных, нет четкой структуры.
  • Active Record. Паттерн Active Record представляет собой активную запись – объект, который инкапсулирует как данные сущности, так и методы для работы с БД. Проще говоря, каждый объект соответствует строке таблицы и умеет сам себя загружать/сохранять. Такой объект содержит поля, соответствующие колонкам, и доменную логику, работающую с ними, плюс методы типа save(), findById() и т.п. Active Record широко известен по ORM-фреймворкам (яркий пример – ActiveRecord в Ruby on Rails).
    Плюсы: удобство – разработчик оперирует объектами, а не сырыми запросами; быстрое создание CRUD операций; естественное соответствие между кодом и таблицами (обычно 1 класс = 1 таблица).
    Минусы: Active Record начинает страдать при усложнении доменной логики. Жесткое сопряжение данных и доступа к БД приводит к тому, что изменить поведение или перенести логику сложно – любое изменение затрагивает и бизнес-логику, и слой хранения сразу. Кроме того, Active Record часто ведет к “анемичной” модели – когда объекты хоть и существуют, но бизнес-правила все равно реализованы процедурно где-то снаружи, потому что боятся помещать сложную логику прямо в методы Active Record (так как они уже отвечают и за сохранение). В итоге при усложнении требований Active Record может превратиться в препятствие, затрудняющее гибкое развитие системы.
  • Domain Model (Объектная модель предметной области). Domain Model – это богатая объектная модель, которая отражает реальные сущности предметной области, объединяя данные и поведение в единой иерархии объектов. В отличие от Active Record, domain model в чистом виде не обязывает объекты знать о базе данных – обычно для работы с БД вводится отдельный слой репозиториев. Бизнес-логику выносят в методы объектов, соблюдая инварианты и правила предметной области.
    Плюсы: Domain Model прекрасно подходит для сложных систем – он способствует инкапсуляции сложных бизнес-правил и развити ю системы без ломки существующего кода. Появляются понятия агрегатов, фабрик, репозиториев – все это снижает связность кода, облегчает тестирование.
    Минусы: Высокий порог входа и сложность. Для небольших задач такая модель выглядит избыточной (“стрельба из пушки по воробьям”) – ее сложнее понять новичкам, требуется продумать много классов и связей сразу. Кроме того, domain model ориентирована на модификацию состояния, на процессы в бизнесе. Она не всегда эффективна для операций чтения, особенно если они требуют сборки данных, не совпадающей со структурой объектов.

Важно отметить, что CQRS не заменяет указанные паттерны напрямую, а скорее развивается на базе богатой Domain Model, дополняя ее разделением моделей. Например, в сложных DDD-системах мы все равно проектируем предметную область как набор агрегатов (Domain Model) для командной стороны. Но без CQRS эта же модель пыталась бы обслуживать и запросы, сталкиваясь с проблемами, описанными ранее: неоптимальность для чтения и излишняя сложность в простых случаях запроса. Если вы строите простое CRUD-приложение, тесная связь между операциями чтения и записи – не проблема, и вам не нужен ни богатый Domain Model, ни CQRS. Но чем сложнее ваш предметный домен, тем вероятнее, что понадобятся и Domain Model, и CQRS.

Другими альтернативами для улучшения производительности чтения в монолитной архитектуре являются уже упомянутые репликация и кеширование. Например, Read Replica – это выделенная копия базы данных, на которую с основной (master) базы реплицируются изменения, и к которой приложение шлет только операции SELECT. Это разгружает основную базу. Однако реплики все еще используют ту же схему данных, что и основная БД, и потому не решают проблемы усложненных запросов или лишних данных. К тому же реплики вводят отставание данных (лаг репликации) – то есть тоже формируют eventual consistency, но на уровне БД, не давая гибкости в преобразовании структуры данных. Кеширование (например, Redis-cache для часто читаемых результатов) тоже может ускорить чтение, но требует инвалидировать/обновлять кеш при записях, что добавляет сложности и риска устаревших данных. В итоге эти методы могут применяться в сочетании с CQRS, но сами по себе не устраняют основной проблемы – единой модели.

Таким образом, при определенном масштабе сложности система может выиграть от более радикального разделения: держать две разные модели данных – одну строго для команд, другую для запросов. Эту идею и воплощает CQRS.

Что такое CQRS: суть паттерна и принцип работы

CQRS (Command-Query Responsibility Segregation) дословно означает “разделение ответственности команд и запросов”. На практике это означает, что модель системы разделена на два потока: командный (запись) и запросный (чтение), каждый из которых имеет свою собственную структуру данных и логику обслуживания. Командная сторона отвечает только за модификацию данных (создание, изменение, удаление) и инкапсулирует всю бизнес-логику, правила валидации, проверку инвариантов и т.д. Запросная сторона отвечает только за предоставление данных в нужном формате, без бизнес-логики, и оптимизирована для быстрых выборок (например, простые проекции, объединенные представления).

Главная идея: вы можете использовать одну модель для операций обновления информации, и другую модель – для чтения информации. Такая декомпозиция позволяет каждой модели эволюционировать независимо и быть лучше приспособленной к своей задаче. Совместное использование одной модели для чтения и записи в сложных доменах ведет к тому, что модель перегружена и плохо справляется с обеими задачами, поэтому разделение приносит выигрыш.

Важно подчеркнуть: CQRS – это не про “каждый класс дважды”, а про разделение концепций. Чаще всего командная и запросная части физически разделяются: это могут быть разные модули внутри одного приложения, разные сервисы (микросервисы) или хотя бы разные слои, обращающиеся к разным хранилищам. Например, командная модель может быть реализована на основе объектной Domain Model с использованием ORM для записи в реляционную базу, а запросная – как набор простых SQL-вью или документная база, держащая денормализованные проекции данных для быстрых SELECT’ов.

При реализации CQRS возникает вопрос согласованности данных между этими моделями. Чаще всего используется подход Eventual Consistency (оконечная согласованность): когда происходят изменения на стороне команд, система с некоторой задержкой обновляет модель чтения. Это означает, что сразу после совершения команды запросы могут еще некоторое время возвращать “старые” данные, пока обновления не применились к read-модели. В правильно спроектированных системах такой лаг невелик и приемлем (например, обновление проекций за доли секунды). Необходимо учитывать этот аспект при разработке UI/UX (например, уведомлять пользователя, что данные обновятся через мгновение) и при разработке логики (бизнес-процесс не должен сразу же повторно читать то, что только что записал, из read-слоя, или должен уметь обработать отсутствие свежих данных).

Как поддерживается синхронизация между write-моделью и read-моделью? Есть несколько вариантов:

  • Единое хранилище, разные представления. В простейшем случае командный и запросный код работают с одной БД, но, например, используют разные таблицы или вьюхи. Команда сохраняет нормализованные данные, а затем (в рамках той же транзакции или сразу после) обновляет специальную таблицу-представление, удобную для чтения. Такой подход обеспечивает сильную согласованность (запросы сразу видят изменения), но по сути снижает изоляцию – write-слой знает про структуру read-слоя. Это скорее частичная реализация CQRS внутри монолита.
  • Разные базы данных. Более характерный для “классического” CQRS подход – командная и запросная модели имеют свои хранилища, могут быть даже разных типов (например, PostgreSQL для записей и Elasticsearch или MongoDB для запросов). После успешной обработки команды, изменения из write-DB передаются в read-DB. Чаще всего для этого применяют события (event-driven подход): агрегат на стороне записи после выполнения операции порождает событие (например, OrderPlaced), которое передается через шину сообщений/очередь и обрабатывается компонентом, обновляющим модель чтения (например, добавляет запись в таблицу представлений или обновляет кеш). Этот вариант полностью разделяет нагрузки: командная БД может фокусироваться на транзакционной целостности, а запросная – на быстрых индексированных выборках. Но он и сложнее – потребуется решить задачу доставки и обработки сообщений, а также мириться с eventual consistency.
  • Чтение из реплики основной БД. Отдельный частный случай – когда для запросов используется реплика основной базы. Формально это тоже вариант CQRS (read side отделена и может масштабироваться независимо). Однако схема данных при этом одна и та же, а задержка ограничена механизмом репликации. Плюс: минимум изменений в коде (ORM может просто читать с реплики). Минус: нет гибкости в структуре read-модели – она столь же нормализована, как write-модель, и сложные запросы по-прежнему нужны. Поэтому часто реплики используют вместе с более явным CQRS-подходом, как часть стратегии масштабирования.

Ниже представлена базовая архитектура системы с CQRS, где командная и запросная части разнесены по разным компонентам.

Рис. 2: Архитектура паттерна CQRS с раздельными хранилищами данных

Как видно из диаграммы, CQRS-шаблон накладывает определенные требования к реализации: необходимо надежно передавать события изменений, следить за синхронизацией данных и обрабатывать ошибки рассинхронизации. Сообщения между write- и read-частями могут передаваться через брокеры сообщений (Kafka, RabbitMQ и др.) или через механизмы стриминга изменений базы данных (например, PostgreSQL Logical Decoding). Важно, что транзакция записи в основную базу и отправка события в очередь не происходят автоматически в одном атомарном контексте – разработчик должен сам гарантировать, что либо и данные сохранены, и событие отправлено, либо в случае сбоя несостоявшиеся события не нарушат целостность. Часто для этого применяют шаблон Outbox – событие сначала записывается в специальную таблицу “исходящих событий” в той же транзакции, что и основные данные, а затем фоновой обработкой отправляется во внешний брокер; если отправка подтвердилась – событие отмечается выполненным. Также нужно учитывать сценарии повторной доставки и дубли – потребитель (read-модель) должен уметь идемпотентно обрабатывать одно и то же событие несколько раз. Эти нюансы повышают сложность, но решаются шаблонами проектирования (Outbox, Idempotent Receiver и пр.).

Однако CQRS не обязывает использовать асинхронные сообщения – в простых случаях можно обновлять read-модель синхронно. Например, внутри одного сервиса после коммита транзакции на запись вызывать обновление проекции для чтения. Выбор способа синхронизации – часть инженерного компромисса между сложностью и требованиями к согласованности.

Подытоживая, CQRS-паттерн решает проблемы традиционной модели следующим образом:

  • Оптимизация под разные нагрузки. Write-модель изолирована и может строго обеспечивать консистентность и сложные бизнес-правила, не заботясь о тяжелых JOIN для отчетов. Read-модель, напротив, хранит данные в формате, удобном для отображения (может дублировать и денормализовать информацию), не опасаясь нарушить инварианты – все равно источник истинных данных находится на стороне записи. Это убирает конфликт интересов при дизайне схемы данных.
  • Повышение производительности и масштабируемости. Поскольку чтение и запись разделены физически, их можно масштабировать независимо. Например, можно поднять много экземпляров сервиса запросов и реплик read-базы, чтобы обслуживать тысячи одновременных пользователей, в то время как сервис команд может работать в единственном экземпляре (чтобы соблюдать последовательность событий). Также, read-модель может быть на другой технологии (NoSQL для быстрых ключевых запросов, поисковый индекс, графовая база для связей и т.п.), что повышает общую производительность системы.
  • Изоляция побочных эффектов. Т.к. модель чтения не меняет данных, к ней могут быть применены агрессивные кэширующие стратегии, репликация в множество копий и прочие способы ускорения – это не влияет на транзакционную целостность данных системы. С другой стороны, write-модель можно держать минимально распределенной и хорошо контролируемой для обеспечения строгой консистентности важной информации (например, денежные операции проводятся только через командный сервис).
  • Проще управление безопасностью и разграничением прав. Мы явно знаем, что изменять данные могут только команды, поэтому на уровне сервисов или API можно разделить права: одни эндпойнты только на чтение, другие только на запись. Проще проводить аудит изменений – каждую команду можно логировать или валидировать отдельно. Read-модель может отдавать обезличенные или агрегированные данные без риска утечки чего-то, что не предназначено для клиента, поскольку бизнес-логика туда просто не попадает.
  • Упрощение запросной части. Компонент, отвечающий за запросы, становится очень простым: фактически это слой проекций. Он либо сразу читает заранее подготовленное представление из БД (например, одну таблицу вместо пяти связанных), либо выполняет примитивные операции агрегирования. В нем нет сложной бизнес-логики, никакой валидации, только трансформация данных к виду, нужному потребителю. Это значительно облегчает поддержку клиентской части и позволяет разным командам (DevOps, фронтенд, аналитики) эволюционировать слой представления данных независимо от ядра бизнес-логики.

Естественно, внедрение CQRS – это увеличение сложности архитектуры. Нужно помнить, что применять этот паттерн имеет смысл только когда выгоды перевесят затраты. Мартин Фаулер отмечал, что большинство систем прекрасно укладываются в CRUD-модель и не должны избыточно усложняться CQRS без веской причины. Признаки, что CQRS оправдан: очень нагруженное по чтению приложение (как социальные сети, аналитические панели), сложная предметная область с множеством правил и процессов, требующая богатой доменной модели, или необходимость разных моделей хранения (например, сочетание SQL и NoSQL). Если же система простая – дополнительный “вычислительный барьер” CQRS может внести больше проблем, чем решить.

Пример использования CQRS

Чтобы лучше понять взаимодействие компонентов в CQRS, рассмотрим последовательность действий при выполнении бизнес-операции. Предположим, клиент отправляет запрос на изменение – например, “подтвердить заказ” в системе электронной коммерции. Эта операция меняет состояние заказа и должна отразиться в данных, доступных для чтения (например, чтобы статус заказа обновился на пользовательской странице). Ниже представлена упрощенная диаграмма последовательности для такого сценария:

Рис. 1: Диаграмма последовательности обработки команд и запросов в CQRS.

На диаграмме выше важен момент: разделение времени исполнения командной и запросной части. Команда обычно выполняется синхронно по отношению к пользователю (например, пользователь получил подтверждение, что заказ принят в обработку), а вот обновление модели чтения – асинхронно. Это видно по разрыву между (5) и (6): клиентский запрос за состоянием может произойти в любой момент после команды, и если он произойдет слишком рано, система может вернуть устаревшие данные. В таких случаях часто внедряют механизмы повторного опроса или уведомления (например, push-уведомление/WebSocket от сервера, когда проекция обновлена), либо блокируют некоторые действия на UI, пока новый статус не вступит в силу. Как уже отмечалось, eventual consistency – плата за раздельное масштабирование, и инженерам приходится ее учитывать в дизайне взаимодействия.

Заметим, что CQRS не диктует, как именно организовать код – например, будет ли команда ConfirmOrder представлена отдельным классом, и как именно публиковать событие. Эти детали могут зависеть от выбранного фреймворка или стека технологий. Далее мы рассмотрим пример реализации CQRS на Java с Spring Boot, иллюстрируя один из подходов.

Реализация CQRS на Spring Boot (Java + PostgreSQL)

Реализация CQRS может быть выполнена как “вручную”, так и с помощью специализированных фреймворков (например, Axon Framework для Java, Lagom для Scala/Java, библиотек на .NET и т.д.). Рассмотрим общую структуру, как это может выглядеть средствами Spring Boot с использованием Java и реляционной базы данных PostgreSQL. Предположим, у нас имеется предметная область заказов (Order) и мы хотим применить CQRS-подход.

1. Модель домена и команда (Write side). На стороне записи мы проектируем Domain Model – например, класс OrderAggregate, являющийся агрегатом (Aggregate Root) в терминологии DDD. Этот класс инкапсулирует бизнес-логику изменений заказа:

// Агрегат заказа (пишущая модель)
public class OrderAggregate {
    @Id
    private UUID id;
    private OrderStatus status;
    private String customer;
    private List<OrderItem> items;
    
    // Бизнес-методы:
    public void confirm() {
        if (this.status != OrderStatus.NEW) {
            throw new IllegalStateException("Order already processed");
        }
        this.status = OrderStatus.CONFIRMED;
        // Возможно, добавить доменное событие
        DomainEvent event = new OrderConfirmedEvent(this.id);
        DomainEvents.raise(event);
    }
    
    public void cancel(String reason) {
        // логика отмены заказа...
        this.status = OrderStatus.CANCELED;
        DomainEvents.raise(new OrderCanceledEvent(this.id, reason));
    }
    
    // ...конструкторы, геттеры...
}

В приведенном примере у OrderAggregate есть метод confirm(), который меняет статус заказа и публикует событие OrderConfirmedEvent. Можно реализовать механизм DomainEvents статически (например, на уровне текущего потока сохранять события для последующей публикации), либо использовать аннотации фреймворка (например, Axon @EventSourcingHandler, @Aggregate и т.п.). Событие OrderConfirmedEvent – простой объект с полями, описывающими произошедшее (как минимум, идентификатор заказа и отметка времени).

Команды можно представить явными классами-командами или вызывать методы агрегата напрямую. Один из подходов – использовать шаблон Command Handler: отдельный сервис, который получает команду и выполняет действие:

// Команда на подтверждение заказа
public class ConfirmOrderCommand {
    private final UUID orderId;
    public ConfirmOrderCommand(UUID orderId) { this.orderId = orderId; }
    public UUID getOrderId() { return orderId; }
}

// Обработчик команды (например, Spring Service)
@Service
public class OrderCommandService {
    @Autowired
    private OrderRepository orderRepository;
    @Autowired
    private MessageBroker messageBroker;
    
    public void handle(ConfirmOrderCommand cmd) {
        // Загружаем агрегат из БД
        OrderAggregate order = orderRepository.findById(cmd.getOrderId())
                .orElseThrow(() -> new NotFoundException("Order not found"));
        // Выполняем бизнес-логику
        order.confirm();
        orderRepository.save(order);  // сохраняем изменения в Write DB
        // Публикуем событие в шину (напр., Kafka)
        messageBroker.publish(new OrderConfirmedEvent(cmd.getOrderId()));
    }
}

Здесь OrderRepository – это репозиторий (например, на основе Spring Data JPA), связанный с Write DB (реляционной), который хранит заказы в нормализованной структуре (например, таблицы orders, order_items и т.д.). После изменения агрегата мы сохраняем его состояние и отправляем событие через некий messageBroker – абстракция поверх Kafka, RabbitMQ или другого механизма доставки событий. Отправка события вынесена за пределы транзакции сохранения (что упрощает пример; в реальном коде тут важно обеспечить гарантию доставки – см. обсуждение Outbox выше).

2. Модель чтения и обработка событий (Read side). На стороне чтения у нас может быть свой репозиторий или DAO для другой базы данных (или другой схемы в той же БД). Допустим, мы используем ту же PostgreSQL, но с отдельной схемой или таблицей order_view, где хранятся заказы в удобном для UI виде – например, сразу со списком товаров в формате JSON, с именем клиента и статусом в одной таблице (денормализовано). Мы создаем обработчик события, который обновит эту таблицу:

// Представление заказа для чтения (проекционная модель)
@Entity
@Table(name="order_view")
public class OrderView {
    @Id
    private UUID orderId;
    private String customer;
    private String status;
    private String itemsJson;  // денормализованный список товаров
    // getters/setters...
}

@Repository
public interface OrderViewRepository extends JpaRepository<OrderView, UUID> {}

@Service
public class OrderViewUpdater {
    @Autowired
    private OrderViewRepository orderViewRepo;
    
    @Transactional  // транзакция в Read DB
    @EventListener   // Spring аннотация для подписки на событие
    public void on(OrderConfirmedEvent event) {
        // Обновляем проекцию: находим OrderView по ID и меняем статус
        orderViewRepo.findById(event.getOrderId()).ifPresent(view -> {
            view.setStatus("CONFIRMED");
            orderViewRepo.save(view);
        });
    }
}

В данном примере мы используем Spring EventListener, который в рамках самого же приложения подпишется на публикацию OrderConfirmedEvent. Предположим, что messageBroker.publish(...) в CommandService реализован через Spring ApplicationEventPublisher – тогда это событие может быть поймано в том же приложении. В реальной распределенной системе, скорее всего, события уходят во внешнюю очередь Kafka, а на стороне чтения работает отдельный сервис, подписанный на эту очередь (через @KafkaListener, например). В любом случае задача обработчика – получить уведомление о произошедшем изменении и внести соответствующие правки в модель чтения. После вызова orderViewRepo.save(view) в нашей таблице order_view статус заказа обновится на “CONFIRMED”.

3. Запросы к read-модели. Теперь, когда клиент запрашивает данные, он обращается не к агрегату OrderAggregate через сервис с бизнес-логикой, а к простому репозиторию/контроллеру, который читает OrderView. Например, в контроллере:

@RestController
@RequestMapping("/api/orders")
public class OrderQueryController {
    @Autowired
    private OrderViewRepository orderViewRepo;
    
    @GetMapping("/{id}")
    public ResponseEntity<OrderViewDto> getOrder(@PathVariable UUID id) {
        OrderView view = orderViewRepo.findById(id)
             .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        // Преобразуем Entity в DTO для отдачи наружу
        OrderViewDto dto = new OrderViewDto(view.getOrderId(), view.getCustomer(),
                                            view.getStatus(), view.getItemsJson());
        return ResponseEntity.ok(dto);
    }
}

Контроллер запросов очень простой: он не знает ничего о бизнес-правилах, он просто тянет готовую проекцию. Объект OrderViewDto – DTO с полями, нужными фронтенду (идентификатор, имя клиента, статус, список товаров). Заметьте, здесь нет сложной логики и агрегации – все сделано заранее при формировании order_view.

Особенности реализации на Spring Boot: Spring облегчает разработку CQRS-систем за счет инфраструктуры событий (аннотации @EventListener), транзакционности (@Transactional для согласованного обновления моделей) и возможности подключения разных источников данных. Часто для разделения write- и read-слоев делают два DataSource (например, primary – write, secondary – read) с разными конфигурациями, и репозитории привязывают к нужному. В нашем случае, если write и read находятся в одной БД (но разные таблицы/схемы), можно обойтись одним DataSource. Если это разные базы, на Spring Boot настраиваются две фабрики сущностей (EntityManagerFactory) с разными Entity для каждой. Также можно использовать Spring Integration, Axon Framework или даже простые JMS-сообщения для связи между контекстами. Выбор технических средств зависит от требований к надежности и сложности: для минимальной задержки и простоты можно выполнять update проекций синхронно (вручную вызывая OrderViewUpdater из CommandService после сохранения заказа). Но такой прямой вызов нарушает изоляцию – поэтому чаще выбирают либо асинхронное событие через брокер, либо паттерн Outbox с периодической отправкой, чтобы decouple(write,read).

Обработка ошибок и транзакций. Рассмотрим коротко, что происходит если на этапе обновления read-модели произошла ошибка (например, база недоступна). Так как у нас раздельные хранилища, транзакция на записи уже зафиксирована – откатывать ее нельзя (иначе мы потеряем бизнес-событие). Поэтому очень важно проектировать систему с устойчивостью к проблемам синхронизации: допустим, если OrderConfirmedEvent не удалось применить, система должна либо повторить попытку (retry), либо пометить проекцию как потенциально устаревшую. Хорошей практикой является идемпотентность обработчиков событий – чтобы одно и то же событие можно было применить повторно без побочных эффектов, если первая попытка не удалась. В промышленном сценарии для этого ведут лог обработки событий (например, в Read DB таблица с номерами обработанных сообщений, чтобы не применить дважды) и используют механизмы повторной доставки сообщений на уровне брокера.

Преимущества и недостатки CQRS

Подведем итоги с инженерной точки зрения – какие плюсы дает CQRS-подход и какие минусы следует учитывать.

Преимущества:

  • Независимое масштабирование и оптимизация. Как уже отмечалось, раздельные модели позволяют масштабировать чтение и запись отдельно под свои типы нагрузок. Например, read-модель легко реплицировать в несколько узлов, поскольку она только читает (конфликты отсутствуют), а write-модель можно запускать в режиме мастер-реплика с одним узлом, отвечающим за изменения, что избавляет от конфликтов при записи. Также, схему данных под чтение можно оптимизировать индексами, денормализацией, кэшем без страха повредить транзакционность записей.
  • Производительность запросов. Хранение материализованных проекций сильно ускоряет сложные запросы. Вместо множества JOIN или сложных вычислений при каждом обращении, система один раз при изменении пересчитывает нужное представление и сохраняет его. Запрос сводится к простому чтению уже подготовленных данных (по сути – как чтение из кеша, только персистентного). Это особенно полезно, когда чтение многократно превосходит по частоте обновление – классическая ситуация для веб-приложений (читать каталоги, профили, ленты новостей нужно часто, а обновляются они редко относительно количества просмотров).
  • Изоляция и сложная бизнес-логика. Командная модель (Domain Model) может быть максимально насыщена бизнес-правилами, не оглядываясь на то, как эти данные будут выдаваться наружу. Это упрощает внедрение сложных процессов и соблюдение инвариантов – все проверяется при выполнении команд. Обратная сторона – read-модель освобождена от этих правил и может быть примитивной. Такое разделение ответственности делает код чище и понятнее для поддержки. Новые разработчики могут легче разобраться: один модуль занимается чисто бизнес-логикой, другой – преобразованием данных для UI.
  • Гибкость выбора технологий. Поскольку write и read части слабо связаны (только через события), ничто не мешает их реализовать на разных технологиях хранения. Например, в командной части использовать PostgreSQL для надежных транзакций и соблюдения ссылочной целостности, а в части чтения использовать ElasticSearch или Solr для текстового поиска, Redis для часто запрашиваемых счетчиков, графовую базу для социальных связей и т.п. – все это параллельно, держа несколько проекций одного и того же основного источника данных. Таким образом, каждый вид данных хранится там, где оптимально для запросов. CQRS архитектурно это позволяет, тогда как в монолите зачастую приходилось бы выбирать что-то одно для всех целей.
  • Упрощение управления изменениями и версионированием. Когда требования изменяются, CQRS облегчает эволюцию схемы. Можно менять read-модель без затрагивания write (например, добавить новую проекцию для нового вида отчета – просто подписаться на события и создавать другую таблицу), и наоборот – поменять внутреннюю структуру агрегатов, не ломающую внешние DTO. При очень больших изменениях можно даже держать параллельно две версии read-модели (новую и старую) и постепенно переключать запросы, поскольку они получают данные из событий от общего источника. Это повышает устойчивость системы к изменениям требований.
  • Совместимость с Event Sourcing для максимальной выгоды. CQRS отлично сочетается с паттерном Event Sourcing, когда состояние write-модели хранится не прямо, а в виде журнала событий. В связке Event Sourcing + CQRS получается, что сами события – это и есть база для чтения. Read-модели могут просто проецировать события в любое представление. Если нужно новое представление – достаточно проиграть накопленные события заново в другом порядке. Такая архитектура дает мощные возможности (история изменений, аудит, восстановление состояния на любой момент). Однако и без полного event sourcing, даже традиционная реляционная модель выгоду получает – события можно генерировать на основе изменений состояние агрегата. Примечание: event sourcing усложняет реализацию, поэтому применять его стоит, когда действительно нужен журнал событий; CQRS же можно применять и с обычными таблицами (события будут эфемерными, только для обновления проекций).

Недостатки:

  • Усложнение архитектуры и кода. Внедрение CQRS привносит значительную дополнительную сложность. Вам нужно поддерживать две версии каждой сущности (на запись и на чтение), организовывать обмен сообщениями, следить за синхронизацией. Количество классов и таблиц растет. Для команды, незнакомой с CQRS/DDD, порог входа повышается – требуется время, чтобы понять разделение моделей и потоков данных. Ошибки становятся менее тривиальными – надо диагностировать, где потерялось событие или почему проекция не обновилась. Если система небольшая, то, возможно, проще остаться на CRUD-модели, чем бороться с сложностями CQRS.
  • Eventual Consistency (отсутствие мгновенной согласованности). Как подробно обсуждалось, при разделении хранилищ возникает окно несинхронности: данные на чтение не мгновенно отражают результат записи. Для некоторых доменов это неприемлемо (например, банковские счета – баланс должен быть точным сразу после транзакции). Или требует усложнения UI/UX. Разработчикам приходится писать дополнительный код для обработки таких ситуаций – от банального предупреждения “обновление данных…” до сложных компенсирующих транзакций. Если бизнес не готов мириться с временной рассинхронизацией, CQRS может не подойти. В транзакционных системах иногда выбирают гибрид: для критически важных данных запросы могут обращаться все же к основной базе (жертвуя скоростью), а менее критичные идут через проекции.
  • Трудности отладки и тестирования. Разделенная асинхронная система сложнее в отладке – если что-то пошло не так, нужно проверять и командный лог, и брокер сообщений, и состояние проекций. Появляется целый класс ошибок, связанных с распределенностью: дубликаты сообщений, потерянные сообщения, несовместимость версий событий при обновлении системы, и пр. Писать автоматические тесты тоже труднее – нужно либо поднимать окружающую инфраструктуру (базы, брокеры) либо мокировать несколько уровней. Без грамотной стратегии мониторинга и логирования эксплуатировать такую систему сложно, особенно поначалу.
  • Увеличение ресурсов. Разные модели часто означают дублирование данных. Хранимых данных может стать в разы больше (например, та же информация в нормализованном виде и несколько денормализованных проекций). Нужно больше места на диске, больше оперативной памяти для кешей, возможно, больше сервисов в инфраструктуре (отдельный сервис чтения, отдельный брокер и т.д.). Для крупных систем это не проблема, но для маленьких – накладные расходы. Кроме того, временное дублирование данных может приводить к тому, что данные надо защищать в двух местах (например, PII данные пользователя, хранящиеся и в агрегатах, и в проекциях, нужно не забыть удалить везде по запросу на удаление).
  • Необходимость продуманного дизайна событий и проекций. По мере развития системы набор событий, которые генерирует write-модель, может сильно расшириться. Эти события – своеобразный публичный контракт между write и read. Менять их нужно осторожно, поддерживая совместимость. Проекции тоже должны эволюционировать вместе с потребностями. В некотором смысле, сложность управлять схемой никуда не исчезает, она трансформируется: вместо одной сложной схемы у нас много более простых, но нужно следить за их согласованностью логики. Нужна дисциплина разработки, чтобы не превращать проект в хаос из несвязанных данных.

В целом, CQRS оправдывает себя в ситуациях, где без него сложно добиться требуемых характеристик системы: высокой отзывчивости интерфейса при сложных запросах, масштабирования под большие потоки чтения, строгого соблюдения сложных бизнес-правил при конкурирующих изменениях. Многие успешные проекты применяют этот паттерн – от финансовых систем (где через CQRS+Event Sourcing делают учет и аудит) до высоконагруженных веб-приложений (социальные сети, маркетплейсы с миллионами товаров, где проекции позволяют быстро собирать ленты и подборки). При этом, CQRS не исключает использование упомянутых ранее подходов – он часто строится поверх богатой Domain Model, может сочетаться с Active Record (например, на уровне микросервисов, где один сервис выступает read-моделью для другого) и т.д. В архитектуре микросервисов сам принцип CQRS зачастую проявляется естественно: одни сервисы становятся источниками данных (write), а другие – материализуют их в удобную форму для клиентов (read). Даже в монолите разработчики могут выделить отдельный модуль для обработчиков запросов (query handlers), возвращающих DTO, а командную логику сконцентрировать в другом модуле – это будет логической реализацией CQRS без разделения на разные базы.

Заключение

Паттерн CQRS представляет собой мощный инструмент архитектурного проектирования, позволяющий достичь высокой производительности, масштабируемости и четкой организации кода в сложных системах. Разделив чтение и запись, мы решаем проблемы перегруженных моделей, снижаем конкуренцию за ресурсы и получаем свободу оптимизации каждого аспекта системы в отдельности. Однако эта свобода приходит вместе с ростом сложности – поэтому CQRS применим не везде. Для middle-разработчиков, знакомых с Spring и DDD, освоение CQRS – шаг к уровню senior/архитектора, требующий понимания распределенных паттернов, брокеров сообщений, eventual consistency и прочих продвинутых концепций.

В этой статье мы погрузились в инженерные детали CQRS: от мотивации и сравнения с традиционными подходами (Transaction Script, Active Record, Domain Model) до реализации на практике с Spring Boot + Java + PostgreSQL. Ключевые моменты, которые следует вынести:

  • CQRS не заменяет базовые принципы проектирования, а надстраивается над ними для решения конкретных проблем растущих систем.
  • Проблематика монолитного CRUD-ориентированного подхода – в том, что одна модель не может быть одновременно идеальной для частых записей и для сложных отчетов, а разделение моделей устраняет этот конфликт.
  • Альтернативы вроде репликации и кешей могут частично помочь, но не дают той степени независимости и оптимизации, что обеспечивает полноценное разделение на командную и запросную модели.
  • Реализация CQRS требует аккуратности: нужно обеспечить надежную доставку событий, продумать схему данных проекций, учесть задержки. Инструменты, такие как фреймворки (Axon) или паттерны (Outbox, Saga), могут в этом помочь, но тоже требуют изучения.
  • Диаграммы (C4, последовательности) наглядно показывают, как данные текут в CQRS-системе: понимание этих потоков критично при отладке и развитии системы.

CQRS часто упоминают вместе с модными архитектурными трендами (микросервисы, Event Sourcing, Event-driven Architecture). Практикующему инженеру важно понимать, что это не панацея, а средство решить определенный класс задач. Использовать CQRS имеет смысл, когда система доросла до проблем, которые он решает: высокая нагрузка на чтение, сложные доменные инварианты, разные схемы данных для разных клиентов и т.д.. Если же таких требований нет – классические упрощенные подходы могут быть эффективнее.

В итоге, освоив CQRS, вы получаете еще один инструмент в архитектурном арсенале, позволяющий писать масштабируемые и поддерживаемые приложения. Главное – применять его осмотрительно и к месту, помня о сопутствующих сложностях. Когда все сделано правильно, CQRS раскрывает свою силу: система под нагрузкой ведет себя устойчиво, данные представлены в оптимальном виде, а код разбит по зонам ответственности, что упрощает дальнейшую разработку. Такие результаты оправдывают вложенные усилия в сложных проектах, чему реальным примером служат многие современные высоконагруженные сервисы, успешно использующие этот паттерн.

Loading