← назад к разделу

Многие начинают с 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 — где хранить и как обновлять денормализованную проекцию.
  • Уровень и эволюция — переход между уровнями по метрикам.