Опирается на правила:
R-CQRS-WHEN-1…R-CQRS-WHEN-3иR-CQRS-WHEN-X1…R-CQRS-WHEN-X2из CQRS Style Guide → раздел 1. Когда CQRS оправдан.
Важно знать
- CQRS — паттерн с ценой: отдельные handler-интерфейсы, отдельная синхронизация, eventual consistency. Применяем когда выгода покрывает цену, не «потому что красиво».
- Lightweight CQRS (маркеры
Command/Queryбез разделения хранилищ) — бесплатное разделение, обязательно начиная с Уровня 2.- В Go маркер — пустой интерфейс с неэкспортируемым методом (
isCommand()/isQuery()); пакетный замок гарантирует, что посторонний тип не реализует его случайно.- Read-only транзакция pgx (
pgx.TxOptions{AccessMode: pgx.ReadOnly}) — enforcement lightweight CQRS: runtime падает на попытке write в read-only tx, без дополнительных проверок.- Денормализованная read-таблица в той же БД — middle-ground: один PostgreSQL, отдельный
OrderViewRepository, синхронизация через outbox +segmentio/kafka-goвнутри сервиса.- Full CQRS с разделением хранилищ (PG + Redis / ElasticSearch / read-DB) — только при read:write ratio ≥ 10:1, принципиально иной структуре read-проекции или необходимости масштабировать read независимо от write.
- Full CQRS «just in case» для нового сервиса без явной нагрузочной проблемы — карго-культ. Стартуем с lightweight, эволюционируем по метрикам.
- Разделение баз без измеренной боли добавляет sync complexity, eventual consistency и инфра-стоимость. «У нас будет много чтения» — не достаточная причина.
CQRS (Command Query Responsibility Segregation) — разделение модели записи и модели чтения. В минимальной форме — два маркерных интерфейса и read-only транзакция pgx. В максимальной — два физически разных хранилища с синхронизацией через Kafka. Между крайностями — спектр, и выбор точки зависит от нагрузки, не от моды. Статья раскрывает раздел 1 Go CQRS Style Guide.
Три уровня CQRS
R-CQRS-WHEN-1..3 описывают три точки спектра — все три валидны, но в разных контекстах:
| Уровень | Что разделено | Когда применять |
|---|---|---|
| Lightweight (маркеры) | Command/Query-интерфейсы; read-методы в pgx.ReadOnly-транзакции | Уровень 2+ — всегда |
| Read-projection в той же БД | Отдельная таблица order_summary, отдельный OrderViewRepository | JOIN'ы 5+ таблиц, тяжёлые GROUP BY |
| Full split | Разные хранилища (PG + ElasticSearch / Redis / read-DB) | read:write ≥ 10:1, поиск / аналитика |
Эволюция — строго снизу вверх: сначала маркеры, потом отдельная таблица, потом отдельное хранилище. Назад движение обычно говорит о неверном первоначальном решении.
Уровень 1: Lightweight на маркерах — обязательно с Уровня 2
R-CQRS-WHEN-1: на Уровне 2 маркерное разделение обязательно. Это не стоит ничего и даёт enforcement через типы и pgx.
// core/cqrs/cqrs.go
package cqrs
type Command interface{ isCommand() }
type Query interface{ isQuery() }
// core/order/command/confirm_order.go
package command
import "core/cqrs"
type ConfirmOrder struct {
OrderID string
}
func (ConfirmOrder) isCommand() {}
// core/order/query/get_order_summary.go
package query
import "core/cqrs"
type GetOrderSummary struct {
OrderID string
}
func (GetOrderSummary) isQuery() {}
Неэкспортируемый метод (isCommand, isQuery) — пакетный замок: только пакет cqrs может неявно реализовать интерфейс, посторонний тип снаружи не пройдёт.
Что даёт разделение:
- Компилятор различает read и write.
ConfirmOrderHandlerиGetOrderSummaryHandler— разные типы, их не поменяешь местами. - Read-only транзакция pgx на query-handler-е — не аннотация, а runtime-гарантия. PostgreSQL отклонит любой
INSERT/UPDATEв read-only tx без дополнительных проверок в коде. - Валидация разная. Command —
go-playground/validatorс бизнес-ограничениями; query — обычно толькоmin/maxна page/size. - Метрики легче.
app_command_duration{handler="ConfirmOrder"}≠app_query_duration{handler="GetOrderSummary"}— RED-метрики разделимы по семантике, не нужен общий bucket «запросов».
Read и write при этом ходят в один и тот же OrderRepository. Никакой дополнительной инфраструктуры — только дисциплина в типах и транзакционных опциях.
// Уровень 2: один репозиторий, две стратегии транзакции
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
}
// Query handler: read-only транзакция pgx
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)
}
Уровень 2: Денормализованная read-таблица в той же БД — middle-ground
R-CQRS-WHEN-3: когда query становится дорогим даже на read-replica, выделяем read-projection в отдельную таблицу той же БД. Не отдельное хранилище — до этого ещё рано.
Триггеры для перехода:
- 5+ JOIN'ов в типичном query. Например,
OrderSummaryсобирается изorders,order_items,products,customers,payments— каждый запрос грузит CPU и память. - Тяжёлые aggregation'ы.
GROUP BY customer_id, DATE(created_at)по миллионам строк для аналитики дашборда в Sber Business — каждая загрузка стоит секунду CPU. - Несовпадение структуры. UI хочет
customer_name,total_items,last_status_changeодним запросом — и собирать это каждый раз лишняя работа. Денормализуем.
Реализация: отдельная таблица order_summary с денормализованными полями, отдельный OrderViewRepository-интерфейс, sqlc генерирует query-файлы под него независимо от write-стороны:
-- read-side: независимая таблица, без FK к write-схеме
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);
// Уровень 3: отдельный интерфейс для read-проекции (R-CQRS-TIER-3)
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)
}
// core/order/dto/view/order_summary.go
package view
type OrderSummaryDTO struct {
OrderID string
CustomerName string
TotalAmount int64
Status string
ItemCount int
UpdatedAt time.Time
}
GET /customers/{id}/orders теперь — один SELECT по индексу, без JOIN'ов. Синхронизация write → read — через outbox + segmentio/kafka-go (см. Sync через события).
Уровень 3: Full CQRS с разделением хранилищ — только при метриках
R-CQRS-WHEN-2: full CQRS оправдан только при измеренной проблеме одного из четырёх типов:
- Read:write ratio ≥ 10:1. Типичный e-commerce: 1 заказ → 10+ показов карточки в разных сценариях (история, поиск, аналитика, чек, push). При таком соотношении read-нагрузка диктует архитектуру.
- Принципиально иная структура. Full-text search по описаниям
Product, фильтры по 20 фасетам, ранжирование по релевантности — это не реляционная задача. ElasticSearch с inverted index решает её на порядок эффективнее, чем PostgreSQL сpg_trgm. - Read-нагрузка превышает throughput write-DB. PostgreSQL выдерживает 5k write/s, но 50k read/s того же объёма данных требует отдельной read-инфраструктуры.
- Независимое масштабирование read. Write-DB не должна страдать от read-нагрузки; Redis cluster / ElasticSearch масштабируется горизонтально, PostgreSQL — нет.
Инфраструктура full CQRS:
- Write: PostgreSQL с агрегатом
Order,FOR UPDATEна load, outbox-таблица. - Outbox-relay:
segmentio/kafka-goproducer публикуетorder.confirmed,order.shipped. - Read: ElasticSearch индекс
ordersс денормализованной структурой; consumer обновляет документ при каждом событии. - API:
POST /orders→ command handler в PG;GET /orders?q=...→ query handler в ElasticSearch.
// edge/order_handler.go — chi router
r.Post("/orders", h.createOrder) // write → PG
r.Get("/orders", h.searchOrders) // read → ElasticSearch
r.Get("/orders/{id}", h.getOrderSummary) // read → order_summary (PG read-model)
Это дорогая инфраструктура: два хранилища, мониторинг обоих, eventual consistency, rebuild-утилиты, сценарии восстановления. Окупается только когда read-replica + кеш уже не справляются.
Что запрещено
Full CQRS «just in case» для нового сервиса
R-CQRS-WHEN-X1: новый сервис стартует с lightweight маркеров, не с разделения хранилищ. Ты не знаешь, какие будут реальные паттерны нагрузки.
// Новый сервис управления заявками в Customer.
// Команда потратила месяц на ElasticSearch + Kafka consumer + rebuild-утилиту.
// Спустя полгода: 300 заявок в день, read-нагрузка 3 req/s.
// Реальный объём покрывался бы одним PG + lightweight маркерами.
// Вместо этого: расследования рассинхронизации, поддержка двух хранилищ,
// on-call по Kafka lag — без бизнес-выгоды.
Делай наоборот: lightweight на старте, измеряй p95 latency и CPU PostgreSQL, эволюционируй когда метрики пробьют SLA.
Разделение баз без явной причины
R-CQRS-WHEN-X2: «у нас будет много чтения» — не причина. Должна быть измеренная боль:
- p95 query latency растёт линейно с числом записей и пробил SLA.
- CPU PostgreSQL на 80%+ от read-нагрузки, write деградирует.
- Бизнес добавил функционал (поиск по тексту, аналитическая витрина), который реляционка не тянет.
До этого порога read-replica PostgreSQL и кеш покрывают 90% случаев. CQRS-сплит — последний шаг, не первый.
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Full CQRS «just in case» для нового сервиса | R-CQRS-WHEN-X1 | lightweight маркеры на старте, эволюция по метрикам |
| Разделение баз без измеренной причины | R-CQRS-WHEN-X2 | read-replica + кеш до явной боли |
| Lightweight маркеры пропущены на Уровне 2 | R-CQRS-WHEN-1 | Command/Query-интерфейсы с isCommand()/isQuery() |
Уровень 3 без <X>ViewRepository | R-CQRS-TIER-X2 | отдельный интерфейс под read-проекцию |
Маркеры без pgx.ReadOnly на query | R-CQRS-TIER-X1 | pgx.TxOptions{AccessMode: pgx.ReadOnly} — enforcement обязателен |
Куда дальше
- CQRS → раздел 1. Когда CQRS оправдан — нормативные формулировки
R-CQRS-WHEN-*. - Command side — как устроен write-handler с маркером
CommandиUnitOfWork. - Query side — read-handler с
Query-маркером иOrderViewRepository. - Read-model — где хранить и как обновлять денормализованную проекцию.
- Sync через события — outbox +
segmentio/kafka-go, idempotent consumer. - Уровень и эволюция — переход Tier 2 → 3 → 4 по метрикам.