Многие начинают с CQRS не потому, что им это нужно, а потому что читали про него и он звучит серьёзно. В итоге — два хранилища, Kafka, синхронизация, и сервис с тремя запросами в сутки. Это дорого без пользы.
CQRS — паттерн с ценой: отдельные интерфейсы, синхронизация состояния, eventual consistency. Применять его стоит тогда, когда выгода реально покрывает эту цену.
Хорошая новость: CQRS — это не всё или ничего. Между «один репозиторий для всего» и «два независимых хранилища» есть промежуточные варианты. Разберём их по порядку.
Три уровня: маркеры, денормализация, разные хранилища
Представьте шкалу. На одном конце — обычный сервис без разделения чтения и записи. На другом — полностью раздельные хранилища с Kafka между ними. Между крайностями — три промежуточные точки.
| Уровень | Что разделено | Когда применять |
|---|---|---|
| Маркеры | Типы Command/Query, read-only транзакция pgx | Всегда, начиная с нетривиального сервиса |
| Read-проекция | Отдельная таблица в той же БД, отдельный репозиторий | JOIN'ов 5+, тяжёлые агрегации |
| Разные хранилища | PG для записи, ElasticSearch или Redis для чтения | Соотношение чтений к записям от 10:1 и выше |
Движение идёт строго снизу вверх. Сначала маркеры — потом отдельная таблица — потом отдельное хранилище. Прыгать сразу на верхний уровень «на вырост» — распространённая ошибка.
Маркеры Command и Query — бесплатное разделение
Это самый простой уровень. Вы вводите два пустых интерфейса с неэкспортируемыми методами:
// core/cqrs/cqrs.go
package cqrs
type Command interface{ isCommand() }
type Query interface{ isQuery() }
Каждое действие помечается нужным типом:
// core/order/command/confirm_order.go
package command
type ConfirmOrder struct {
OrderID string
}
func (ConfirmOrder) isCommand() {}
// core/order/query/get_order_summary.go
package query
type GetOrderSummary struct {
OrderID string
}
func (GetOrderSummary) isQuery() {}
Неэкспортируемый метод — пакетный замок. Тип из другого пакета не реализует этот интерфейс случайно: только типы в пакете cqrs могут это сделать явно. Компилятор различает команды и запросы, их не поменяешь местами.
Что это даёт помимо типовой дисциплины:
Read-only транзакция. Query-handler открывает транзакцию pgx с режимом ReadOnly. PostgreSQL сам отклонит любой INSERT или UPDATE — без дополнительных проверок в коде.
func (h *GetOrderSummaryHandler) Handle(ctx context.Context, q query.GetOrderSummary) (view.OrderSummaryDTO, error) {
tx, err := h.db.BeginTx(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly})
if err != nil {
return view.OrderSummaryDTO{}, fmt.Errorf("begin read tx: %w", err)
}
defer tx.Rollback(ctx)
return h.orders.SummaryByID(ctx, q.OrderID)
}
Раздельные метрики. app_command_duration{handler="ConfirmOrder"} и app_query_duration{handler="GetOrderSummary"} — разные временные ряды. Легко понять, что именно медленно.
На этом уровне читают и пишут в один и тот же репозиторий. Никакой дополнительной инфраструктуры:
type OrderRepository interface {
ByID(ctx context.Context, id string) (*Order, error)
SummaryByID(ctx context.Context, id string) (view.OrderSummaryDTO, error)
Save(ctx context.Context, o *Order) error
}
Денормализованная таблица — когда JOIN'ов слишком много
Маркеры помогают разграничить ответственность. Но если каждый read-запрос собирает данные из пяти JOIN'ов — это начинает давить на базу.
Триггер для перехода на следующий уровень — один из:
- Типичный запрос собирает данные из пяти и более таблиц.
- Агрегация по миллионам строк (
GROUP BYс датами, суммами) занимает секунду. - Интерфейс хочет
customer_name,total_items,last_statusодним запросом — и собирать это каждый раз накладно.
Решение: отдельная таблица с денормализованными данными. Один SELECT по индексу — вместо цепочки JOIN'ов.
CREATE TABLE order_summary (
order_id uuid PRIMARY KEY,
customer_id uuid NOT NULL,
customer_name text NOT NULL,
status text NOT NULL,
item_count int NOT NULL,
total_amount bigint NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX ix_order_summary_customer ON order_summary (customer_id, updated_at DESC);
Под эту таблицу выделяется отдельный интерфейс:
type OrderRepository interface {
ByID(ctx context.Context, id string) (*Order, error)
Save(ctx context.Context, o *Order) error
}
type OrderViewRepository interface {
SummaryByID(ctx context.Context, id string) (view.OrderSummaryDTO, error)
ListByCustomer(ctx context.Context, customerID string, page Pagination) ([]view.OrderSummaryDTO, error)
}
type OrderSummaryDTO struct {
OrderID string
CustomerName string
TotalAmount int64
Status string
ItemCount int
UpdatedAt time.Time
}
Хранилище одно — PostgreSQL. Синхронизация write → read идёт через outbox + Kafka внутри сервиса. Это серьёзно проще, чем два физических хранилища.
Разные хранилища — только при измеренной проблеме
Полное разделение хранилищ оправдано в четырёх ситуациях. Именно при наличии одной из них — не «вдруг понадобится».
Соотношение чтений к записям от 10:1. Типичный интернет-магазин: один заказ — десять и более показов в истории, поиске, аналитике. При таком соотношении нагрузка чтения диктует архитектуру.
Принципиально другая структура данных. Полнотекстовый поиск по описаниям товаров с двадцатью фасетами и ранжированием — это не реляционная задача. ElasticSearch с инвертированным индексом эффективнее PostgreSQL с pg_trgm на порядок.
Нагрузка чтения превышает throughput основной базы. PostgreSQL выдерживает несколько тысяч записей в секунду, но пятьдесят тысяч чтений того же объёма требует отдельной read-инфраструктуры.
Нужно масштабировать чтение независимо. Write-база не должна страдать от read-нагрузки; Redis cluster или ElasticSearch масштабируется горизонтально.
Как выглядит роутинг при full CQRS:
// edge/order_handler.go — chi router
r.Post("/orders", h.createOrder) // запись → PG
r.Get("/orders", h.searchOrders) // чтение → ElasticSearch
r.Get("/orders/{id}", h.getOrderSummary) // чтение → read-модель в PG
Это дорогая инфраструктура: два хранилища, мониторинг обоих, восстановление при рассинхронизации, утилиты перестройки индекса. Окупается только тогда, когда read-replica и кеш уже не справляются.
Частые ошибки
Full CQRS «на вырост» для нового сервиса. Команда тратит месяц на ElasticSearch, Kafka-consumer и утилиту перестройки. Через полгода — триста заявок в день, три запроса в секунду. Реальный объём покрылся бы одним PostgreSQL с маркерами. Вместо этого: расследования рассинхронизации, поддержка двух хранилищ, дежурства по Kafka lag.
Правило: начинайте с маркеров, измеряйте p95 latency и нагрузку на PostgreSQL, переходите на следующий уровень когда метрики пробивают SLA.
«У нас будет много чтения». Это не причина для разделения хранилищ. Причина — измеренная боль: p95 latency растёт линейно с числом записей и пробил SLA; CPU PostgreSQL на 80%+ от read-нагрузки; бизнес добавил функционал, который реляционная база не тянет.
До этого порога read-replica PostgreSQL и кеш покрывают большинство случаев.
Маркеры без read-only транзакции. Смысл маркера Query — не только типовая дисциплина, но и enforcement через транзакцию. Без pgx.TxOptions{AccessMode: pgx.ReadOnly} разделение остаётся декоративным.
Коротко
- CQRS — спектр из трёх уровней, не выбор «всё или ничего».
- Маркеры
Command/Queryс read-only транзакцией pgx — бесплатное разделение, применяется в любом нетривиальном сервисе. - Денормализованная таблица в той же БД — когда JOIN'ов пять и больше или агрегация стала дорогой.
- Разные хранилища — только при измеренном соотношении чтений к записям от 10:1, поиске или необходимости масштабировать read независимо.
- Начинать с разделения хранилищ «на вырост» — распространённая ошибка: неизвестно, какой будет реальная нагрузка.
- Эволюция идёт строго снизу вверх: сначала маркеры, потом отдельная таблица, потом отдельное хранилище.
Что почитать дальше
- Command side в Go — write-handler с маркером
Command. - Query side в Go — read-handler с
Query-маркером иOrderViewRepository. - Read-модель в Go — где хранить и как обновлять денормализованную проекцию.
- Уровень и эволюция — переход между уровнями по метрикам.