Когда применять CQRS в Go-сервисе
Многие начинают с 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() }
// Маркеры для встраивания: неэкспортируемый метод из чужого пакета
// не реализовать, команда получает его через встроенную структуру
type CommandMarker struct{}
type QueryMarker struct{}
func (CommandMarker) isCommand() {}
func (QueryMarker) isQuery() {}
Каждое действие помечается нужным типом:
// core/order/command/confirm_order.go
package command
type ConfirmOrder struct {
cqrs.CommandMarker
OrderID string
}
В коде:
// core/order/query/get_order_summary.go
package query
type GetOrderSummary struct {
cqrs.QueryMarker
OrderID string
}
Неэкспортируемый метод — пакетный замок: реализовать isCommand() может только код самого пакета cqrs. Поэтому команда в пакете command не пишет метод сама (компилятор ответит «does not implement cqrs.Command (unexported method isCommand)»), а встраивает cqrs.CommandMarker. Случайно сделать структуру командой из чужого пакета нельзя, а компилятор различает команды и запросы — их не поменяешь местами.
Что это даёт помимо типовой дисциплины:
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)
ctx = withTx(ctx, tx) // репозиторий берёт транзакцию из контекста, как и на write-стороне
return h.orders.SummaryByID(ctx, q.OrderID)
}
Строка withTx(ctx, tx) обязательна: без неё транзакция открыта зря, а запрос уходит через пул мимо неё — и защита «PostgreSQL сам отклонит запись» не работает.
Раздельные метрики. 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)
}
Читать из базы можно прямо в DTO:
type OrderSummaryDTO struct {
OrderID string
CustomerName string
TotalAmount int64
Status string
ItemCount int
UpdatedAt time.Time
}
Хранилище одно — PostgreSQL. Пока проекция живёт в той же базе и обновляется дёшево, её можно вести той же транзакцией, что и запись, — строго согласованно и без брокера. Синхронизация через 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} разделение остаётся декоративным.
Глубже: сколько стоит read-only транзакциярасширенное
BeginTx с pgx.ReadOnly — это BEGIN READ ONLY и ROLLBACK, два лишних обращения к базе на каждый запрос. На карточке товара под тысячами запросов в секунду это заметно. Дешёвый вариант той же гарантии — отдельный пул для чтения со строкой подключения ?default_transaction_read_only=on (или SET default_transaction_read_only = on в AfterConnect): каждое соединение из него отклоняет запись само, а обработчику не нужно открывать транзакцию вовсе. Такой пул заодно можно направить на реплику. Явная транзакция остаётся там, где нужно несколько согласованных чтений за один снимок.
Коротко
- CQRS — спектр из трёх уровней, не выбор «всё или ничего».
- Маркеры
Command/Queryс read-only транзакцией pgx — бесплатное разделение, применяется в любом нетривиальном сервисе. - Денормализованная таблица в той же БД — когда JOIN'ов пять и больше или агрегация стала дорогой.
- Разные хранилища — только при измеренном соотношении чтений к записям от 10:1, поиске или необходимости масштабировать read независимо.
- Начинать с разделения хранилищ «на вырост» — распространённая ошибка: неизвестно, какой будет реальная нагрузка.
- Эволюция идёт строго снизу вверх: сначала маркеры, потом отдельная таблица, потом отдельное хранилище.
- Маркер из чужого пакета получают встраиванием
cqrs.CommandMarker, а не своим методом; read-only транзакцию репозиторий должен видеть через контекст, иначе она открыта зря.
Что почитать дальше
- Command side в Go — write-handler с маркером
Command. - Query side в Go — read-handler с
Query-маркером иOrderViewRepository. - Read-модель в Go — где хранить и как обновлять денормализованную проекцию.
- Уровень и эволюция — переход между уровнями по метрикам.