Когда смотришь на проект с CQRS в первый раз, легко запутаться: одни команды, другие запросы, отдельные обработчики, иногда ещё и разные базы данных. Зачем всё это?
Ответ зависит от масштаба задачи. CQRS — это спектр решений, а не одно конкретное. На одном конце — просто разные типы для чтения и записи. На другом — физически разные базы данных, синхронизированные через события. Между ними есть промежуточный вариант. Выбирать нужно ту точку, где выгода перекрывает сложность — и не прыгать сразу в конец.
Что такое CQRS простыми словами
Обычно сервис работает с одной моделью данных: одни и те же классы используются и когда записываем заказ, и когда показываем список заказов клиенту. Это удобно пока нагрузка невелика.
Проблема начинается когда читаем гораздо чаще чем пишем, или когда структура данных для чтения совсем не похожа на структуру для записи. Сохранить заказ — значит записать агрегат с бизнес-правилами. Показать список заказов клиенту — значит собрать данные из шести таблиц, посчитать итоги и отдать плоский JSON.
CQRS (Command Query Responsibility Segregation) — разделение ответственности между командами и запросами. Команды изменяют данные, запросы только читают. У них могут быть разные классы, разные обработчики, разные настройки транзакций и даже разные хранилища.
Три уровня: начинай с простого
CQRS не обязательно означает две базы данных. Есть три уровня сложности, и большинству проектов достаточно первого или второго.
Уровень 1 — маркеры без разделения хранилищ
Самый простой вид: никакой дополнительной инфраструктуры, только разные типы для команд и запросов.
public record ConfirmOrderCommand(Long orderId, String idempotencyKey)
implements UseCaseCommand<Order> {}
public record GetOrderSummaryQuery(Long orderId)
implements UseCaseQuery<OrderSummary> {}
Команды и запросы ходят в одну и ту же базу данных через одни и те же репозитории. Разница только в типах и настройках транзакций: обработчики запросов помечены @Transactional(readOnly = true), обработчики команд — нет.
Это даёт три реальных преимущества:
- Компилятор различает чтение и запись. Нельзя случайно передать команду туда где ожидается запрос.
- Отдельные настройки транзакций.
readOnly = trueвключает оптимизации в PostgreSQL и защищает от случайного изменения данных при чтении. - Метрики разделены по смыслу. Время выполнения команд и запросов считается отдельно — легко понять, что именно тормозит.
Этот уровень не стоит ничего дополнительно, и его стоит применять практически всегда.
Уровень 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 обновляется при каждом изменении заказа через события внутри того же сервиса.
Когда это оправдано:
- запросы объединяют пять и более таблиц;
- нужны тяжёлые группировки и подсчёты по миллионам строк;
- структура данных для отображения сильно отличается от структуры для записи.
База при этом остаётся одна — это важно. Два отдельных хранилища означают синхронизацию, возможное рассогласование, двойное администрирование. До этого стоит доходить только при реальной необходимости.
Уровень 3 — разные хранилища для чтения и записи
Самый сложный вариант. Оправдан только когда ситуация измерена и одна из проблем действительно есть:
Чтений на порядок больше чем записей. Типичный пример: один заказ создаётся, но затем десятки раз читается в разных сценариях — история, поиск, аналитика, уведомления. При соотношении 10:1 и выше нагрузка на чтение начинает диктовать архитектуру.
Нужна принципиально другая структура для поиска. Полнотекстовый поиск по описаниям, фильтры по двадцати параметрам, ранжирование по релевантности — это не то, для чего создан PostgreSQL. ElasticSearch с инвертированным индексом справится на порядок лучше.
Нагрузка на чтение мешает записи. PostgreSQL отлично пишет, но если тысячи запросов на чтение конкурируют с операциями записи — страдают и те и другие. Отдельное хранилище для чтения снимает эту конкуренцию.
Как это выглядит на практике:
- Запись идёт в PostgreSQL с полным агрегатом заказа.
- При изменении заказа публикуется событие через outbox.
- Kafka-потребитель обновляет денормализованный документ в ElasticSearch.
POST /ordersобрабатывается write-обработчиком через PostgreSQL.GET /orders?q=...обрабатывается query-обработчиком через ElasticSearch.
Это дорогая инфраструктура: два хранилища, два мониторинга, возможное временное рассогласование данных, процедуры восстановления при сбоях. Окупается только когда реплика PostgreSQL и кеш уже не справляются.
Частые ошибки
Разворачивать full CQRS с двумя базами на старте нового проекта. Это прежде всего неизвестность: ты ещё не знаешь, какими будут реальные паттерны нагрузки. Команды тратят месяцы на поддержку ElasticSearch и расследование рассогласований — а реальная нагрузка оказывается 500 запросов в день. Начинай с маркеров, измеряй, эволюционируй.
Разделять хранилища «потому что будет много чтения». Предположение без измерений — не причина. Реальная причина — конкретная измеренная проблема: время ответа на 95-м процентиле пробило допустимый предел, CPU PostgreSQL постоянно на 80% именно от чтения, бизнес добавил задачи которые реляционная база принципиально не тянет.
До этих порогов репликация PostgreSQL и кеш покрывают большинство случаев. Разделение хранилищ — последний шаг, не первый.
Коротко
- CQRS — это спектр, а не единственное конкретное решение. Выбирай уровень под реальную задачу.
- Уровень 1 (маркеры) — разные типы для команд и запросов,
readOnly = trueна запросах, одна база. Не стоит ничего дополнительно, применяй всегда. - Уровень 2 (read-таблица) — денормализованная таблица в той же базе. Оправдан при тяжёлых запросах с пятью и более объединениями или сложными группировками.
- Уровень 3 (разные хранилища) — PostgreSQL для записи плюс ElasticSearch или Redis для чтения. Только при измеренных проблемах: соотношение чтений к записям 10:1 и выше, принципиально разная структура, запись деградирует из-за читателей.
- Эволюция снизу вверх: начинай с маркеров, добавляй сложность когда метрики показывают реальную боль.
- Начинать сразу с двух баз — добавить синхронизацию, рассогласование и двойное администрирование без измеренной причины.
Что почитать дальше
- Command side в CQRS — как устроен write-обработчик.
- Query side в CQRS — read-обработчик и отдельный репозиторий для проекций.
- Read-model в CQRS — где хранить и как обновлять денормализованную проекцию.