Опирается на правила:
R-CQRS-RM-1…R-CQRS-RM-4иR-CQRS-RM-X1…R-CQRS-RM-X3из CQRS Style Guide → раздел 4. Read-model.
Важно знать
- Read-model — данные в форме, удобной для чтения: денормализованные, pre-aggregated, индексированные под конкретные запросы.
- Хранилище — то, что лучше всего подходит под нагрузку: PG-таблица, materialized view, Redis, ElasticSearch. Не одно «универсальное».
- Schema read-model независима от write-схемы. Один атрибут может появиться в нескольких read-DTO под разными именами и в разных формах.
- Обновляется через события, не synchronously в write-транзакции. Eventual consistency 100ms–1s — норма.
- Read-model восстановима из write-side. Если потеряли — есть rebuild-скрипт, который проходит по агрегатам и заново строит проекцию.
- Read-model — это проекция, не источник истины. Source of truth — всегда write-side агрегаты.
- Никакой бизнес-логики в read-model (триггеры с CHECK-инвариантами бизнес-правил). Логика — в агрегатах.
- Никакого bidirectional sync. Поток данных: write → events → read. Обратно — никогда.
Read-model — это денормализованное представление, в котором данные уже сложены так, как их хочет конечный потребитель: один SELECT без join'ов возвращает готовый объект для UI / API. Цена — eventual consistency и дополнительная инфраструктура; выгода — порядки разницы в latency и пропускной способности. Раскрытие раздела 4 гайда.
Где хранить read-model
R-CQRS-RM-1: выбор хранилища — функция от паттерна чтения. Не «у нас Redis, в него и положим».
| Паттерн чтения | Хранилище | Почему |
|---|---|---|
| Tabular query с пагинацией, фильтром, сортировкой | Денормализованная PG-таблица | Реляционка хорошо умеет такой workload, transactional sync через outbox |
| Тяжёлые aggregations (GROUP BY миллионов строк) | PG materialized view | Pre-computed, refresh по расписанию или по событию |
| Key-lookup hot-keys (по ID, по короткому ключу) | Redis | Sub-millisecond latency, horizontal scaling |
| Full-text search, multi-field фильтры с relevance | ElasticSearch / OpenSearch | Inverted index, ranking, faceted search |
PG-таблица — дефолт
Денормализованная таблица в той же СУБД — почти всегда первый шаг. Никакой новой инфраструктуры, синхронизация через outbox + локальный consumer.
CREATE TABLE order_summary (
order_id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
status TEXT NOT NULL,
item_count INTEGER NOT NULL,
total_amount NUMERIC(19,4) NOT NULL,
currency TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
confirmed_at TIMESTAMPTZ,
shipped_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL,
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX ix_os_customer ON order_summary (customer_id, created_at DESC);
CREATE INDEX ix_os_status_date ON order_summary (status, created_at DESC);
Поле version — для idempotent UPDATE (см. Sync via events).
PG materialized view — для тяжёлых aggregations
Когда нужна сводка вроде «оборот по продуктам за последний месяц», и пересчитывать её каждый раз — дорого:
CREATE MATERIALIZED VIEW product_revenue_daily AS
SELECT
p.product_id,
p.name,
DATE(oi.created_at) AS day,
SUM(oi.quantity * oi.unit_price) AS revenue,
COUNT(DISTINCT o.id) AS order_count
FROM order_item oi
JOIN product p ON p.product_id = oi.product_id
JOIN "order" o ON o.id = oi.order_id
WHERE o.status IN ('CONFIRMED', 'SHIPPED', 'DELIVERED')
GROUP BY p.product_id, p.name, DATE(oi.created_at);
CREATE UNIQUE INDEX ux_prd_pk ON product_revenue_daily (product_id, day);
Refresh — по расписанию (REFRESH MATERIALIZED VIEW CONCURRENTLY каждые 5 минут) или по событию OrderConfirmed. Подробно — в PG → Materialized views (когда статья появится).
Redis — для hot-key lookup
Например, проекция Customer → ActiveSubscriptionPlan — читается на каждый запрос пользователя:
SET customer:42:plan {"plan":"PRO","expires_at":"2026-12-01"} EX 3600
Consumer на SubscriptionUpdated обновляет ключ. См. также Caching Style Guide — разница с обычным кешем в том, что read-model в Redis является источником ответа, не fallback'ом.
ElasticSearch — для search
Полнотекстовый поиск по описаниям Product, фильтры по 20+ атрибутам с relevance — реляционка плохо умеет. Document:
{
"product_id": 12345,
"name": "Pixel 9 Pro 256GB",
"category_path": ["Электроника", "Смартфоны", "Google"],
"price": 99990,
"in_stock": true,
"rating": 4.7,
"description": "..."
}
Consumer на ProductCreated, ProductPriceChanged, StockUpdated обновляет документ. Запрос GET /products?q=pixel&min_rating=4.5 идёт прямо в ES.
Schema независимая от write-стороны
R-CQRS-RM-2: read-схема может — и часто должна — отличаться от write-схемы. Денормализация — её главное оружие.
write-схема: read-схема (order_summary):
order(id, customer_id, status) order_summary(
order_item(order_id, ..., qty) order_id,
customer(id, name, email) customer_name, ← денормализовано из customer
customer_email, ← денормализовано из customer
status,
item_count ← pre-computed из order_item
)
Что выигрывается:
- GET без JOIN.
SELECT * FROM order_summary WHERE order_id = ?вместо трёх join'ов. - Индексы под запросы read-стороны. Не нужно делать compromise на write-таблице — там свои индексы, тут свои.
- Read-DTO в коде — read-схема в БД. Один-в-один маппинг, без сложного маппера.
Цена — обновление customer_name теперь должно прийти не только в customer, но и в order_summary через consumer. Это плата за чтение, и она обычно стоит того.
Обновление через события — eventual consistency
R-CQRS-RM-3: read-model не обновляется в write-транзакции. Только через outbox + Kafka + consumer.
1. command-handler меняет Order, сохраняет, регистрирует OrderConfirmed → outbox-таблица
2. outbox-relay (SKIP LOCKED loop) публикует событие в Kafka
3. read-side consumer (этого же сервиса или другого) ловит OrderConfirmed
4. UPDATE order_summary SET status = 'CONFIRMED', confirmed_at = ... WHERE order_id = ?
Latency обычно 100ms–1s в стационарном режиме. При деградации Kafka может быть больше — это архитектурно ожидаемо. UI должен это понимать (см. Sync via events → eventual consistency).
Почему не synchronous UPDATE в той же транзакции:
- Read-model теряет decoupling. ALTER на
order_summaryблокирует write-транзакции. - При rollback
orderоткатывается, аorder_summaryуже могла измениться (если consumer внешний). - Cross-DB synchronous sync невозможен в принципе без 2PC, который запрещён (см. Distributed Patterns).
Подробно — в Sync via events.
Read-model восстановима из write-side
R-CQRS-RM-4: для read-model должен существовать скрипт rebuild'а, который проходит по агрегатам write-side и заново строит проекцию.
@Component
@RequiredArgsConstructor
public class OrderSummaryRebuilder {
private final OrderRepository orderRepository;
private final OrderSummaryRepository orderSummaryRepository;
public void rebuildAll() {
long lastId = 0;
int batchSize = 1000;
while (true) {
List<Order> batch = orderRepository.findAllAfter(lastId, batchSize);
if (batch.isEmpty()) break;
List<OrderSummaryRow> rows = batch.stream().map(this::toSummaryRow).toList();
orderSummaryRepository.upsertBatch(rows);
lastId = batch.getLast().id().value();
log.info("rebuild progress: lastId={}", lastId);
}
}
}
Этот скрипт пригождается в трёх сценариях:
- Disaster recovery. Read-model потеряна (отказ Redis cluster, drop в ElasticSearch, миграция).
- Bootstrap нового read-store. Решили добавить ElasticSearch — он пустой, нужно загнать в него existing-данные.
- Структурная миграция read-схемы. Добавили новое поле в
order_summary— для старых записей оно пустое, rebuild дозаполнит.
Без rebuild-скрипта read-model становится первичным хранилищем — что нарушает следующее правило.
Что запрещено
Read-model с бизнес-логикой
R-CQRS-RM-X1: read-model — это проекция. Никаких CHECK-constraint бизнес-правил, никаких триггеров с инвариантами.
-- ПЛОХО — бизнес-инвариант в read-таблице
ALTER TABLE order_summary ADD CONSTRAINT check_total_positive
CHECK (total_amount > 0);
-- А если write-side создал заказ с нулём по бизнес-причине (refund),
-- consumer попытается обновить order_summary и упадёт. Read-инфраструктура
-- держит write-state заложником своих ограничений — это бомба.
Корректно: инвариант total_amount > 0 — в агрегате Order.confirm(). Read-таблица просто хранит то, что пришло.
Read-model как source-of-truth
R-CQRS-RM-X2: если данные есть только в read-model и не восстановимы из write-side — это уже не CQRS, это две разные системы без согласованного источника правды.
ПЛОХО:
- Read-model в ElasticSearch
- При update заказа меняется ES напрямую (через separate writer)
- В PG `order` хранится urzная half-структура
- Какой источник правды? ES? PG? Никто не знает.
Корректно: всегда есть один источник правды — write-side агрегаты. Read-model — производная.
Bidirectional sync
R-CQRS-RM-X3: поток данных идёт в одну сторону — write → events → read. Обратное направление (read меняет write) — запрещено.
ПЛОХО:
GET /customers/{id}/summary
→ читает из ElasticSearch
→ видит inconsistency, дописывает в PostgreSQL через UPDATE
Теперь read меняет write. Два источника правды, race-conditions,
невозможность tracing «откуда это значение пришло».
Корректно: read обнаружил inconsistency → залогировать → дернуть rebuild для этой записи (или просто проигнорировать, если eventual всё равно догонит). Никаких write из read-handler.
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Read-model с бизнес-логикой (CHECK / триггер инварианта) | R-CQRS-RM-X1 | Логика в агрегате; read-model — только проекция |
| Read-model как единственный источник правды | R-CQRS-RM-X2 | Source of truth — write-side; read восстановим |
| Bidirectional sync (read → write) | R-CQRS-RM-X3 | Одно направление: write → events → read |
| Synchronous UPDATE read-model в write-транзакции | R-CQRS-SYNC-X1 | Outbox + Kafka + consumer |
| Read-model без rebuild-скрипта | R-CQRS-RM-4 | Rebuilder, проходящий по агрегатам |
Use того же <X>Repository и для read, и для write при event-driven | R-CQRS-TIER-X2 | Отдельный <X>ViewRepository |
Куда дальше
- CQRS → раздел 4. Read-model — нормативные формулировки
R-CQRS-RM-*. - Sync via events — как outbox + Kafka доставляет события до read-model.
- Query side — как query-handler читает из read-model.
- Уровень и эволюция — когда переходить к event-driven read-model.
- Caching Style Guide — разница между кешем (fallback) и read-model (источник ответа).
- Kafka Style Guide — outbox publishing pattern, retry, DLQ.
- Distributed Patterns Style Guide — почему 2PC запрещён и нужна eventual consistency.