23. CQRS. Разделение чтения и записи
В традиционных CRUD-приложениях (Create, Read, Update, Delete) разработчики почти всегда используют одну и ту же модель данных и для записи, и для чтения.
На этапе прототипа это кажется верхом простоты: есть таблица orders, есть структура type Order struct, и один и тот же SQL-запрос обслуживает и создание корзины, и формирование дашборда аналитики.
Однако по мере роста кодовой базы и нагрузки эта идиллия превращается в кошмар оптимизации:
- Запись требует максимальной нормализации (3-я нормальная форма), строгих внешних ключей (Foreign Keys) и гранулярных ACID-блокировок для защиты инвариантов.
- Чтение, напротив, требует денормализации данных, широких таблиц (плоских View), массивных композитных индексов и объединения десятков таблиц через тяжелые
JOIN.
Попытка заставить одну структуру данных и одну базу данных обслуживать оба противоположных вектора приводит к патовой ситуации: индексы для чтения критически замедляют запись, а транзакции записи блокируют быстрые пользовательские выборки.
Архитектурный паттерн Command Query Responsibility Segregation (CQRS — Разделение ответственности за команды и запросы) предлагает элегантный выход: полностью физически и логически разделить модели изменения данных и модели отображения данных.
В этой главе мы исследуем паттерн CQRS в экосистеме Go. Мы разберём, как организовать структуры команд и запросов, как построить асинхронную проекцию через события брокера, как оптимизировать чтение с помощью sync.Pool и батчинга pgx.Batch, и как управлять конечной согласованностью (Eventual Consistency) в распределённых системах.
Что такое CQRS
Концепция CQRS была сформулирована Грегом Янгом (Greg Young) как развитие принципа CQS (Command Query Separation) Бертрана Мейера.
Классическое правило CQS гласит:
«Вопрос не должен менять ответ. Метод, возвращающий значение, не должен иметь побочных эффектов, а метод, мутирующий состояние, не должен возвращать данные».
CQRS переносит этот принцип с уровня отдельных функций на уровень макроархитектуры всей системы. Система разделяется на две суверенные половины:
- Command Side (Модель записи / Write Model):
Принимает команды (намерения мутации:CreateOrderCommand,UpdateProfile). Проверяет бизнес-инварианты агрегата, сохраняет транзакционные изменения и возвращает клиенту только статус операции и идентификатор созданного ресурса (никаких тяжелых доменных структур!). - Query Side (Модель чтения / Read Model):
Принимает запросы (GetOrderHistory,SearchUsers). Возвращает денормализованные структуры отображения (DTO / Views), идеально подогнанные под конкретный интерфейс пользователя (UI) или API мобильного клиента. Здесь нет никакой бизнес-логики и валидации — только максимально быстрая отдача байтов из кэша или оптимизированных таблиц.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Client["Клиент"]:::entry
subgraph Cmd["Command side - Запись"]
CmdH["Command Handler"]:::process
WriteDB["База данных<br>Write Model"]:::process
end
subgraph Qry["Query side - Чтение"]
QryH["Query Handler"]:::process
ReadDB["База данных<br>Read Model"]:::process
end
Client -->|"Команда"| CmdH
Client -->|"Запрос"| QryH
CmdH --> WriteDB
QryH --> ReadDB
CmdH -.->|"Событие<br>OrderCreated"| ReadDB
Зачем разделять чтение и запись
Посмотрим на конфликт требований сквозь призму системного инжиниринга:
- Разные формы данных:
При оформлении заказа доменной модели важны сущностиOrder,OrderLineи правила списания скидок. Но когда пользователь открывает страницу истории заказов, ему нужны: имя клиента, статус доставки от курьерской службы, превью первого товара и общее число позиций. В классической БД это требует 5JOIN-ов. В CQRS модель чтения хранит плоский предрассчитанный документOrderView. - Асимметрия нагрузки (Read/Write Ratio):
В интернет-магазинах, новостных порталах и соцсетях соотношение чтений к записям составляет 100:1 или даже 1000:1. В единой модели вам приходится масштабировать всё приложение целиком. В CQRS вы можете выделить 2 пода для записи и 50 реплик для чтения! - Борьба за дисковые блокировки:
Длинный аналитический SQL-запросSELECT ... SUM(price)в PostgreSQL захватывает shared-блокировки и вытесняет кэш страниц операционной системы, тормозя быстрые точечные вставкиINSERT INTO orders. - Оптимизация индексов:
Каждый дополнительный B-Tree индекс ускоряет поискSELECT, но замедляет каждую вставкуINSERTи обновлениеUPDATE, так как СУБД обязана перестраивать дерево на диске.
Простейшая реализация CQRS на Go
В языке Go разделение ответственности начинается с явного разграничения пакетов и интерфейсов.
Сторона записи (Command Side)
Команда — это структура параметров. Обработчик команды проверяет инварианты, сохраняет агрегат в репозиторий записи и генерирует событие:
// core/order/command.go - Command side
package order
import (
"context"
"errors"
)
type CreateOrderCommand struct {
UserID string
Items []OrderItem
}
type WriteRepository interface {
Save(ctx context.Context, order *Order) error
}
type EventBus interface {
Publish(ctx context.Context, event any) error
}
type CommandHandler struct {
repo WriteRepository
eventBus EventBus
}
func NewCommandHandler(repo WriteRepository, bus EventBus) *CommandHandler {
return &CommandHandler{repo: repo, eventBus: bus}
}
func (h *CommandHandler) CreateOrder(ctx context.Context, cmd CreateOrderCommand) (string, error) {
if len(cmd.Items) == 0 {
return "", errors.New("empty order items")
}
// 1. Создаем агрегат и защищаем инварианты
order := NewOrder(cmd.UserID, cmd.Items)
// 2. Персистим в транзакционную базу данных
if err := h.repo.Save(ctx, order); err != nil {
return "", err
}
// 3. Публикуем доменное событие для проекторов модели чтения
_ = h.eventBus.Publish(ctx, OrderCreated{
OrderID: order.ID,
UserID: cmd.UserID,
Items: cmd.Items,
})
// Команда возвращает ТОЛЬКО идентификатор, но не весь агрегат!
return order.ID, nil
}
Сторона чтения (Query Side)
Запрос — это параметры фильтрации. Обработчик обращается к оптимизированному хранилищу чтения и возвращает готовую модель представления OrderView:
// core/order/query.go - Query side
package order
import "context"
type GetOrderQuery struct {
OrderID string
}
type OrderView struct {
OrderID string `json:"order_id"`
UserName string `json:"user_name"`
Status string `json:"status"`
Total float64 `json:"total"`
ItemsCount int `json:"items_count"`
LastComment string `json:"last_comment"`
}
type ReadRepository interface {
FindByID(ctx context.Context, id string) (*OrderView, error)
}
type QueryHandler struct {
readRepo ReadRepository
}
func NewQueryHandler(repo ReadRepository) *QueryHandler {
return &QueryHandler{readRepo: repo}
}
func (h *QueryHandler) GetOrder(ctx context.Context, q GetOrderQuery) (*OrderView, error) {
// Никаких тяжелых вычислений - просто отдача готового кэша/документа!
return h.readRepo.FindByID(ctx, q.OrderID)
}
Разделение моделей данных
Истинная мощь CQRS проявляется, когда модели чтения и записи разделяются на уровне физических хранилищ:
1. Write Model (PostgreSQL — строгая 3НФ нормализация):
CREATE TABLE orders (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
status VARCHAR(20) NOT NULL,
total_amount DECIMAL NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
2. Read Model (Денормализованная таблица в PostgreSQL, Redis, Elasticsearch или MongoDB):
-- Полностью плоская таблица, исключающая любые JOIN-операции при чтении
CREATE TABLE order_views (
order_id UUID PRIMARY KEY,
user_name TEXT,
status TEXT,
total DECIMAL,
items_count INT,
last_comment TEXT
);
При чтении запрос SELECT * FROM order_views WHERE order_id = $1 выполняется за доли миллисекунды, читая единственную непрерывную дисковую страницу.
Синхронизация моделей: синхронная и асинхронная
Как изменения из Write Model попадают в Read Model?
1. Синхронная синхронизация (В рамках одной транзакции)
Команда одновременно обновляет и Write Model, и Read Model в рамках единой ACID-транзакции с помощью SaveWithTx:
func (h *CommandHandler) CreateOrder(ctx context.Context, cmd CreateOrderCommand) (string, error) {
tx, _ := h.db.Begin(ctx)
defer tx.Rollback(ctx)
order := NewOrder(cmd.UserID, cmd.Items)
h.writeRepo.SaveWithTx(ctx, tx, order)
// Синхронно пишем в проекцию
view := NewOrderView(order)
h.readRepo.SaveWithTx(ctx, tx, view)
return order.ID, tx.Commit(ctx)
}
Плюс: Немедленная согласованность (Strong Consistency).
Минус: Запись замедляется, оба хранилища обязаны поддерживать единую транзакцию (обычно это одна и та же СУБД).
2. Асинхронная синхронизация через проекторы (Рекомендуемый подход)
Команда сохраняет данные в Write DB и публикует событие в брокер (например, Kafka или NATS JetStream). Специальный компонент — Проектор (Projector) — асинхронно вычитывает события и обновляет Read Model:
// Query side: Проектор обновляет модель чтения
type OrderProjector struct {
readRepo ReadRepository
}
func (p *OrderProjector) OnOrderCreated(ctx context.Context, event OrderCreated) error {
view := NewOrderView(event)
return p.readRepo.Upsert(ctx, view)
}
Mechanical Sympathy: CQRS и Go
Как архитектура CQRS влияет на кремний, ядра процессора и память?
1. Разделение пулов горутин и соединений
Команды и запросы обладают совершенно разным профилем нагрузки. Для Query side мы выделяем массивный пул соединений к репликам чтения через библиотеку pgxpool (github.com/jackc/pgx/v5/pgxpool). Для горячих ключей мы используем in-memory кэширование без блокировок — потокобезопасный sync.Map или библиотеки с сегментированной памятью вроде bigcache, предотвращающие накладные расходы на сборщик мусора.
Влияние на GC
В высоконагруженных Query-сервисах при 100 000 RPS постоянное выделение памяти под структуры ответов OrderView нагружает аллокатор Go. Применение пула объектов sync.Pool позволяет переиспользовать экземпляры структур, исключая лишние походы в кучу:
var viewPool = sync.Pool{
New: func() any { return &OrderView{} },
}
func (h *QueryHandler) GetOrder(ctx context.Context, q GetOrderQuery) (*OrderView, error) {
view := viewPool.Get().(*OrderView)
// Очищаем поля структуры перед повторным использованием
*view = OrderView{}
defer viewPool.Put(view)
// Заполняем поля из базы данных без новых аллокаций
err := h.db.QueryRowContext(ctx, "SELECT ...").Scan(&view.OrderID, &view.UserName, &view.Total)
return view, err
}
Системные вызовы и батчинг
Когда проектор слушает поток событий из Kafka, обновление Read DB по одному событию порождает слишком много сетевых системных вызовов к СУБД. В Go с помощью pgx.Batch события упаковываются в пачки и отправляются в едином системном вызове SendBatch:
func (p *OrderProjector) OnEvents(ctx context.Context, events []OrderCreated) error {
batch := &pgx.Batch{}
for _, ev := range events {
batch.Queue(`
INSERT INTO order_views (order_id, user_id, status, total)
VALUES ($1, $2, $3, $4)
ON CONFLICT (order_id) DO UPDATE SET status = EXCLUDED.status`,
ev.OrderID, ev.UserID, "created", ev.Total)
}
br := p.pool.SendBatch(ctx, batch)
return br.Close()
}
Связь с Event Sourcing
Паттерны CQRS и Event Sourcing (24. Event Sourcing. Хранение событий вместо состояния) великолепно дополняют друг друга, но концептуально независимы:
- Можно использовать CQRS с классическими таблицами в PostgreSQL на стороне записи.
- Можно использовать Event Sourcing без разделения моделей чтения.
Однако их синергия идеальна: если Write Model хранит историю событий, то Read Models — это просто вторичные проекции, которые можно в любой момент пересоздать с нуля, проиграв лог событий Kafka заново!
Когда применять CQRS
Оправдано:
- Системы со сверхвысоким дисбалансом чтений и записей (каталоги товаров, социальные сети, медиаплатформы).
- Домены, где форма данных при редактировании кардинально отличается от формы отображения.
- Необходимость полнотекстового поиска (Elasticsearch/OpenSearch) наряду со строгой транзакционной БД для платежей.
- Необходимость независимого масштабирования команд разработки: команда витрины пишет свои проекторы, не трогая доменную логику заказов.
Избыточно:
- Простые сервисы с типовым CRUD, где затраты на синхронизацию моделей превысят любую выгоду.
Антипаттерны и типичные ошибки
- CQRS ради резюме (Overengineering): Внедрение разделения моделей в проект, обслуживающий 5 запросов в секунду.
- Общая структура для команд и запросов: Если вы используете один и тот же
struct Orderи вCommandHandler, и вQueryHandler, вы убили смысл паттерна. Модель чтения обязана быть самостоятельной и оптимизированной под клиента. - Игнорирование лага репликации: Надежда на то, что «Kafka доставит мгновенно». Если ваш фронтенд падает с ошибкой 404, когда заказ ещё не долетел до проектора — вы сломали пользовательский опыт.
Итог
CQRS — это мощнейшее архитектурное оружие, разрушающее компромисс между нормализацией записи и денормализацией чтения. Вы получаете максимальную скорость вставок на Write Side и мгновенные выборки без JOIN на Query Side.
Теперь, разобравшись с разделением моделей, мы готовы подойти к высшей ступени работы с данными — полному отказу от хранения текущего состояния в пользу непрерывного потока фактов: 24. Event Sourcing. Хранение событий вместо состояния.