Когда смотришь на проект с CQRS в первый раз, легко запутаться: одни команды, другие запросы, отдельные обработчики, иногда ещё и разные базы данных. Зачем всё это?
Ответ зависит от масштаба задачи. CQRS — это спектр решений, а не одно конкретное. На одном конце — просто разные типы для чтения и записи. На другом — физически разные базы данных, синхронизированные через события. Между ними есть промежуточный вариант. Выбирать нужно ту точку, где выгода перекрывает сложность — и не прыгать сразу в конец.
Что такое CQRS простыми словами
Обычно сервис работает с одной моделью данных: одни и те же классы используются и когда записываем заказ, и когда показываем список заказов клиенту. Это удобно пока нагрузка невелика.
Проблема начинается когда читаем гораздо чаще чем пишем, или когда структура данных для чтения совсем не похожа на структуру для записи. Сохранить заказ — значит записать агрегат с бизнес-правилами. Показать список заказов клиенту — значит собрать данные из шести таблиц, посчитать итоги и отдать плоский JSON.
CQRS (Command Query Responsibility Segregation) — разделение ответственности между командами и запросами. Команды изменяют данные, запросы только читают. У них могут быть разные классы, разные обработчики, разные настройки транзакций и даже разные хранилища.
Три ступени: начинай с простого
CQRS не обязательно означает две базы данных. Есть три ступени сложности, и большинству проектов достаточно первой или второй. Нумерованную шкалу зрелости (от «CQRS вообще нет» до event-driven read-model) ведёт отдельная статья — Уровень и эволюция CQRS; здесь ступени названы по сути, чтобы номера не путались.
Три ступени снизу вверх: каждая следующая добавляет инфраструктуру, справа написано, чем именно за неё платят.
Ступень 1 — маркеры без разделения хранилищ
Компилятор не отличает чтение от записи: getOrder и confirmOrder для него одинаковые вызовы сервиса, метрики по ним склеены, а readOnly на запросах забывают. Первый уровень чинит именно это, и без всякой инфраструктуры: только разные типы для команд и запросов.
public record ConfirmOrderCommand(Long orderId, String idempotencyKey)
implements UseCaseCommand<Order> {}
public record GetOrderSummaryQuery(Long orderId)
implements UseCaseQuery<OrderSummary> {}
Команды и запросы ходят в одну и ту же базу данных через одни и те же репозитории. Разница только в типах и настройках транзакций: обработчики запросов помечены @Transactional(readOnly = true), обработчики команд — нет.
Это даёт три реальных преимущества:
- Компилятор различает чтение и запись. Нельзя случайно передать команду туда где ожидается запрос.
- Отдельные настройки транзакций.
readOnly = trueне ускоряетSELECT— он открывает транзакцию только на чтение: попытка записи в ней падает с ошибкой, Hibernate перестаёт сверять объекты на изменения, а сам запрос можно обслужить на горячей реплике. - Метрики разделены по смыслу. Время выполнения команд и запросов считается отдельно — легко понять, что именно тормозит.
Эта ступень не стоит ничего дополнительно, и применять её стоит практически всегда.
Ступень 2 — отдельная read-таблица в той же базе
Когда запросы становятся тяжёлыми, но уходить в другую базу ещё рано.
Типичная ситуация: страница истории заказов клиента собирает данные из шести таблиц — заказ, позиции, товары, покупатель, оплата, доставка. Каждый запрос нагружает CPU и занимает сотни миллисекунд.
Решение — денормализованная таблица только для чтения:
CREATE TABLE order_summary (
order_id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
customer_name TEXT NOT NULL,
status TEXT NOT NULL,
item_count INTEGER NOT NULL,
total_amount NUMERIC(19,4) NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX ix_order_summary_customer ON order_summary(customer_id, created_at DESC);
Теперь запрос истории заказов — один SELECT по индексу, без объединений таблиц. Таблица order_summary обновляется при каждом изменении заказа через события внутри того же сервиса.
Типы здесь выбраны под задачу. NUMERIC(19,4) хранит деньги точно: у чисел с плавающей точкой дробная часть округляется, и сумма заказа расходится с суммой позиций на копейки. TIMESTAMPTZ хранит момент времени однозначно: значение приводится к UTC при записи и разворачивается в зону читателя при чтении, поэтому заказ, созданный в Красноярске, не съезжает на пять часов в отчёте из Москвы. Индекс (customer_id, created_at DESC) повторяет сам запрос: сначала отбор по клиенту, затем готовый порядок по дате, так что отдельная сортировка базе не нужна.
И вот здесь — самое важное место этой ступени, потому что именно тут реальные сервисы ломаются. «Через события внутри того же сервиса» означает конкретную вещь: витрина лежит в той же базе, значит, её обновление идёт в той же транзакции, что запись, и никакого брокера не нужно.
@Transactional
public void handle(ConfirmOrderCommand command) {
Order order = orders.byId(command.orderId(), FOR_UPDATE).orElseThrow();
order.confirm(clock.instant());
orders.save(order); // нормализованные таблицы
summaries.upsert(order); // денормализованная витрина — та же транзакция
}
Две записи, одна транзакция, ноль отложенности: витрина обновлена ровно тогда, когда обновлён заказ, и рассогласование невозможно по построению. Это и есть главное отличие ступени 2 от ступени 3, и его стоит проговорить, потому что соседняя статья про витрину чтения требует таблицу исходящих и брокер — но она про случай, когда витрина в другом хранилище. Для одной базы это избыточно: брокер там добавляет отложенность и три новых способа сломаться, не давая ничего взамен.
Если обновлять витрину в том же обработчике неудобно (обновлений много, они разбросаны), подойдёт событие внутри процесса — слушатель, вызываемый после фиксации транзакции… и вот это уже ошибка: после фиксации витрина обновится вне транзакции, и падение между фиксацией и обновлением оставит расхождение. Правильная форма для одной базы — слушатель, работающий внутри той же транзакции (в Spring это @TransactionalEventListener(phase = BEFORE_COMMIT)), или прямой вызов, как в коде выше. Проще — прямой вызов.
Цена ступени 2, которую легко не заметить на фоне ступени 3:
- Дублирование данных. Одни и те же значения лежат в двух местах; при изменении структуры менять надо оба.
- Каждая запись стала дороже. Транзакция теперь обновляет и нормализованные таблицы, и витрину — это лишние блокировки и лишнее время на записи. При интенсивной записи это заметно.
- Второй источник расхождений. Забыли обновить витрину в одном из обработчиков (а обработчиков со временем становится десять) — витрина тихо разошлась. Лечится тем, что обновление витрины идёт в одном месте для всех команд, а не в каждой.
- Нужен скрипт перестроения. Он нужен и здесь: после изменения структуры витрины или обнаруженной ошибки её надо пересобрать из нормализованных таблиц. Один запрос, но он должен существовать и быть проверенным.
- Схема витрины требует миграций с дозаполнением, как любая таблица.
Итого ступень 2 не бесплатна, но её цена — в разы меньше ступени 3: нет брокера, нет потребителей, нет отложенности, нет второго хранилища. Именно поэтому большинство проектов на ней и останавливается.
Когда это оправдано:
- запросы объединяют пять и более таблиц (про число — ниже);
- нужны тяжёлые группировки и подсчёты по миллионам строк;
- структура данных для отображения сильно отличается от структуры для записи.
Откуда взялись «пять таблиц». Само число не проблема: база спокойно объединяет и десять, если данных мало. Проблема в том, что с ростом числа объединений планировщик перестаёт находить хороший план. У PostgreSQL есть порог (по умолчанию 8 таблиц, настройка join_collapse_limit), после которого он не перебирает все порядки соединения, а идёт по порядку из запроса — и легко выбирает план в разы хуже оптимального. Плюс с каждым объединением растёт ошибка в оценке числа строк: неверная оценка на втором шаге умножается к пятому, и выбирается неподходящий способ соединения. Результат виден не как «медленно всегда», а как нестабильное время ответа: тот же запрос работает 50 мс на одних параметрах и 3 секунды на других, а после обновления статистики план меняется сам.
Поэтому правильная формулировка признака — не «пять таблиц», а «время ответа на 95-м процентиле нестабильно и пробивает вашу планку, и в плане видны неверные оценки». Пять таблиц — это порог, после которого такое начинает случаться; если у вас семь таблиц и стабильные 20 мс, витрина не нужна. И обратное: два объединения по огромным таблицам с группировкой могут потребовать витрины раньше, чем пять по маленьким.
База при этом остаётся одна — это важно. Два отдельных хранилища означают синхронизацию, возможное рассогласование, двойное администрирование. До этого стоит доходить только при реальной необходимости.
Ступень 3 — разные хранилища для чтения и записи
Самый сложный вариант. Оправдан только когда ситуация измерена и одна из проблем действительно есть:
Чтений на порядок больше чем записей. Типичный пример: один заказ создаётся, но затем десятки раз читается в разных сценариях — история, поиск, аналитика, уведомления. При соотношении 10:1 и выше нагрузка на чтение начинает диктовать архитектуру.
Нужна принципиально другая структура для поиска. Полнотекстовый поиск PostgreSQL умеет сам: tsvector с индексом GIN, нечёткое совпадение через pg_trgm. Этого хватает очень надолго. Но когда фильтров два десятка, к ним добавляется своё ранжирование по релевантности, а документов — десятки миллионов, инвертированный индекс ElasticSearch справляется заметно лучше.
Нагрузка на чтение мешает записи. PostgreSQL отлично пишет, но если тысячи запросов на чтение конкурируют с операциями записи — страдают и те и другие. Отдельное хранилище для чтения снимает эту конкуренцию.
Как это выглядит на практике:
- Запись идёт в PostgreSQL с полным агрегатом заказа.
- При изменении заказа публикуется событие через outbox.
- Kafka-потребитель обновляет денормализованный документ в ElasticSearch.
POST /ordersобрабатывается write-обработчиком через PostgreSQL.GET /orders?q=...обрабатывается query-обработчиком через ElasticSearch.
Это дорогая инфраструктура: два хранилища, два мониторинга, возможное временное рассогласование данных, процедуры восстановления при сбоях. Окупается только когда реплика PostgreSQL и кеш уже не справляются. Важная оговорка: реплика — не способ избежать отставания. Она тоже догоняет мастер с задержкой, так что читать с неё — это ровно та же отложенная согласованность, просто без отдельной проекции.
Третью ступень оправдывает измеренная причина, а не ожидание роста: достаточно любого из трёх признаков, подтверждённого числами.
Что делать, когда проблема есть, а причины для ступени 3 нет
Между «терпеть» и «поднять поисковый движок» лежит набор приёмов, которые закрывают большинство случаев дешевле. Их стоит перебрать до разговора о третьей ступени — в таком порядке.
1. Починить запрос и индексы. Скучно и работает чаще всего: покрывающий индекс (все нужные колонки в индексе, чтобы не ходить в таблицу), составной индекс под конкретную сортировку и фильтр, переписанный запрос без лишних объединений. Порядок выигрыша — разы и десятки раз, цена — часы работы. Признак, что это ваш случай: в плане видно чтение таблицы там, где ожидался индекс.
2. Материализованное представление. Тот же денормализованный результат, но считает и хранит его база: CREATE MATERIALIZED VIEW плюс индексы по нему. Обновляется целиком командой REFRESH ... CONCURRENTLY (без блокировки чтений) — то есть подходит, когда данные могут отставать на минуты и объём позволяет пересчитывать. Цена — почти нулевая по коду: ни обработчиков, ни синхронизации. Ограничение: нельзя обновить частично, и на больших объёмах пересчёт занимает минуты.
3. Своя денормализованная таблица — то есть ступень 2. Если отставание на минуты не подходит, а объём велик для полного пересчёта, то же самое делается своей таблицей с точечным обновлением. Это и есть ступень 2, и она закрывает большую часть того, за чем идут на ступень 3.
4. Секционирование и архив. Часто проблема не в форме данных, а в их количестве: таблица на 300 миллионов строк, из которых 99 % — прошлые годы. Разделение по времени и вынос старого в архив ускоряет запросы по свежим данным в разы, ничего не меняя в коде. Это первое, что стоит проверить на больших таблицах.
5. Реплика для чтения. Тяжёлые отчёты уходят на копию, и конкуренция с записью исчезает. Дешево (одна настройка подключения), и с той же отложенностью, что у любой проекции, — только неуправляемой.
6. Кеш ответов. Для запросов, которые повторяются и терпят устаревание на минуту. Помогает мгновенно, но не решает проблему плохого запроса: первый промах всё равно стоит те же 3 секунды.
К третьей ступени переходят, когда перечисленное пройдено и измерено: индексы в порядке, представление не успевает пересчитываться, своя витрина уже есть, а фильтров всё равно двадцать и нужен поиск по тексту с ранжированием. Тогда отдельное хранилище — обоснованный шаг; до этого — самый дорогой способ решить задачу, которую решает индекс.
Как выходят обратно
Движение только вверх — неполная картина: третья ступень иногда оказывается лишней (нагрузка не выросла, требования изменились, поиск переехал в другой сервис), и упрощение — реальная операция. Порядок обратный развёртыванию, и важно то, что каждый шаг обратим.
- Переключить чтение на сторону записи — по одному запросу, за флагом, сравнивая результаты и время ответа. Витрина в это время продолжает обновляться: если что-то пошло не так, флаг возвращают.
- Убедиться, что читателей не осталось. Метрика обращений к витрине по каждому запросу: неделя с нулём — можно двигаться дальше. Здесь обычно и обнаруживаются неизвестные читатели.
- Проверить, кто ещё подписан на события. Это самый частый сюрприз: на ваши события за год подписались другие сервисы. Их надо найти (по группам потребителей в брокере) и договориться — либо события остаются, либо соседи переезжают. Отключить поток, не спросив, — гарантированная авария у соседей.
- Остановить потребителя проекции, оставив витрину нетронутой. Обратимо: включили обратно, потребитель догнал отставание.
- Удалить витрину и хранилище — последним, после того как всё выше прожило без нарушений хотя бы пару недель. С резервной копией.
- Убрать код и инфраструктуру: потребитель, настройки, мониторинг, права. Здесь же отвечают на вопрос, остаётся ли таблица исходящих: если события нужны кому-то ещё — остаётся.
Что обычно не убирают, и это нормально: разделение команд и запросов в коде (ступень 1 и 2) — оно ничего не стоит и полезно само по себе. Упрощение означает отказ от отдельного хранилища и потребителей, а не возврат к одному сервису на всё.
Частые ошибки
Разворачивать full CQRS с двумя базами на старте нового проекта. Это прежде всего неизвестность: ты ещё не знаешь, какими будут реальные паттерны нагрузки. Команды тратят месяцы на поддержку ElasticSearch и расследование рассогласований — а реальная нагрузка оказывается 500 запросов в день. Начинай с маркеров, измеряй, эволюционируй.
Разделять хранилища «потому что будет много чтения». Предположение без измерений — не причина. Реальная причина — конкретная измеренная проблема: время ответа на 95-м процентиле пробило допустимый предел, CPU PostgreSQL постоянно на 80% именно от чтения, бизнес добавил задачи которые реляционная база принципиально не тянет.
До этих порогов репликация PostgreSQL и кеш покрывают большинство случаев. Разделение хранилищ — последний шаг, не первый.
Коротко
- CQRS — это спектр, а не единственное конкретное решение. Выбирай ступень под реальную задачу. Ступень 1 (маркеры) — разные типы для команд и запросов,
readOnly = trueна запросах, одна база. Не стоит ничего дополнительно, применяй всегда. - Ступень 2 (read-таблица) — денормализованная таблица в той же базе. Оправдан при тяжёлых запросах с пятью и более объединениями или сложными группировками.
- Ступень 3 (разные хранилища) — PostgreSQL для записи плюс ElasticSearch или Redis для чтения. Только при измеренных проблемах: соотношение чтений к записям 10:1 и выше, принципиально разная структура, запись деградирует из-за читателей.
- Эволюция снизу вверх: начинай с маркеров, добавляй сложность когда метрики показывают реальную боль.
- Начинать сразу с двух баз — это синхронизация, рассогласование и двойное администрирование без измеренной причины.
- На ступени 2 витрина лежит в той же базе и обновляется в той же транзакции, что запись: брокер здесь избыточен, а слушатель после фиксации — ошибка, дающая расхождение.
- Цена ступени 2 есть: дублирование, более дорогая запись, второй источник расхождений, скрипт перестроения и миграции витрины — но она в разы меньше цены третьей ступени.
- «Пять таблиц» — не про число, а про то, что планировщик перестаёт находить хороший план: признак — нестабильное время на 95-м процентиле и неверные оценки в плане.
- До третьей ступени перебирают индексы и переписанный запрос, материализованное представление, свою витрину, секционирование с архивом, реплику и кеш ответов.
- Упрощение — реальная операция обратимыми шагами: переключить чтение за флагом, убедиться в отсутствии читателей, найти подписчиков событий, остановить потребителя, и только потом удалять витрину.
Что почитать дальше
- Command side в CQRS — как устроен write-обработчик.
- Query side в CQRS — read-обработчик и отдельный репозиторий для проекций.
- Read-model в CQRS — где хранить и как обновлять денормализованную проекцию.