В обычном приложении одна таблица — и для записи, и для чтения. Это удобно пока данных мало. Но когда страница заказов собирает информацию из шести таблиц через пять JOIN'ов, и это происходит тысячи раз в секунду, база начинает задыхаться. Именно здесь CQRS предлагает отдельный «взгляд» на данные, заточенный только под чтение.
Что такое read-model
Представьте, что у вас есть склад (write-сторона) и витрина магазина (read-model). На складе данные хранятся в нормализованном виде — каждая сущность в своей таблице. На витрине всё уже разложено по полочкам так, как удобно покупателю: один предмет содержит всё нужное, ничего не надо искать в других местах.
Read-model — это денормализованное представление данных, где информация уже сложена в форму, удобную конечному потребителю: один SELECT без JOIN'ов возвращает готовый объект для UI или API.
Цена этого удобства: данные на «витрине» немного отстают от «склада» (eventual consistency, обычно 100ms–1s). Но выигрыш — несравнимо меньше нагрузки на базу и в разы быстрее ответ.
Где хранить read-model
Выбор хранилища зависит от того, как данные будут читаться. Не «у нас уже есть Redis, туда и положим», а «какой паттерн чтения нам нужен».
PG-таблица — первый шаг
Денормализованная таблица в той же PostgreSQL — почти всегда правильное начало. Никакой новой инфраструктуры, привычные транзакции и индексы, обновление через outbox.
Например, вместо того чтобы каждый раз JOIN'ить order, order_item и customer, создаём одну таблицу со всем нужным:
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 нужно для идемпотентного обновления — если одно и то же событие придёт дважды, второй UPDATE не применится.
Materialized view — для тяжёлых агрегаций
Когда нужна сводка вроде «оборот по продуктам за последний месяц», и пересчитывать её каждый раз дорого:
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 MATERIALIZED VIEW CONCURRENTLY каждые 5 минут) или по событию. Запросы к view работают быстро — данные уже посчитаны.
Redis — для горячих ключей
Когда данные читаются на каждый запрос пользователя и нужна минимальная задержка. Например, проекция «пользователь → его текущий тарифный план»:
SET customer:42:plan {"plan":"PRO","expires_at":"2026-12-01"} EX 3600
Потребитель событий обновляет ключ при каждом изменении подписки. Важная тонкость: здесь Redis — источник ответа, а не кеш-ускоритель. Это разные роли: кеш можно сбросить и перечитать из БД, read-model в Redis — это и есть основное хранилище данного представления.
ElasticSearch — для поиска
Полнотекстовый поиск по описаниям, фильтры по 20+ атрибутам с релевантностью — реляционные базы плохо с этим справляются. ElasticSearch хранит готовый документ:
{
"product_id": 12345,
"name": "Pixel 9 Pro 256GB",
"category_path": ["Электроника", "Смартфоны", "Google"],
"price": 99990,
"in_stock": true,
"rating": 4.7,
"description": "..."
}
Потребители событий ProductCreated, ProductPriceChanged, StockUpdated обновляют документ. Запрос GET /products?q=pixel&min_rating=4.5 идёт напрямую в ES.
Схема read-model не зависит от write-стороны
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 ← посчитано из order_item
)
Что это даёт:
- Чтение без JOIN.
SELECT * FROM order_summary WHERE order_id = ?вместо трёх JOIN'ов. - Индексы под конкретные запросы. Не нужно идти на компромисс между нуждами чтения и записи — у каждой стороны свои индексы.
- Простой маппинг в коде. Read-DTO совпадает с read-схемой один в один.
Цена: если изменилось имя клиента, это изменение должно дойти до order_summary тоже — через событие и потребителя. Это нормальная плата за быстрое чтение.
Обновление через события
Read-model не обновляется в той же транзакции, что и запись. Только через outbox + Kafka + потребитель событий.
1. command-handler меняет Order, сохраняет,
регистрирует OrderConfirmed → outbox-таблица (атомарно)
2. outbox-relay публикует событие в Kafka
3. read-side потребитель ловит OrderConfirmed
4. UPDATE order_summary SET status = 'CONFIRMED', confirmed_at = ... WHERE order_id = ?
Задержка обычно 100ms–1s в нормальном режиме. При проблемах с Kafka может быть больше — это ожидаемо и нормально. UI должен это понимать.
Почему не обновлять синхронно в той же транзакции:
- Read-model теряет независимость. Изменение схемы
order_summaryначинает блокировать write-транзакции. - Если write-транзакция откатится — read-model уже могла измениться (особенно если read-store в другой базе).
- Для разных баз синхронный одновременный апдейт потребует двухфазного подтверждения (2PC) — сложного протокола, который несёт больше проблем, чем решает.
Подробнее о механизме доставки событий — в Синхронизация read-model через события.
Read-model всегда можно восстановить
Это важный принцип: для любой read-model должен существовать скрипт, который заново строит проекцию из write-стороны. Если потеряли данные в Redis или нужно перелить данные в новый ElasticSearch-индекс — скрипт проходит по всем агрегатам и собирает проекцию с нуля:
@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();
}
}
}
Когда это нужно:
- Авария. Redis-кластер упал, ElasticSearch-индекс потерян, произошла миграция.
- Новое хранилище. Решили добавить ElasticSearch — он пустой, нужно залить исторические данные.
- Изменение схемы read-model. Добавили поле в
order_summary— для старых записей оно пустое, скрипт дозаполнит.
Если скрипта восстановления нет — read-model фактически стала единственным источником правды, а это уже неправильно.
Частые ошибки
Бизнес-логика в read-таблице. Read-model — это проекция, не место для бизнес-правил. Добавлять CHECK-ограничения с бизнес-инвариантами в read-таблицу опасно: если write-сторона создала данные по бизнес-причинам, которые не проходят проверку в read-таблице, потребитель событий упадёт с ошибкой. Инварианты живут в агрегатах, read-модель просто хранит то, что пришло.
Read-model как единственный источник правды. Если данные есть только в read-model и нет write-стороны, из которой их можно восстановить — это уже не CQRS, а просто две несогласованные системы. Источник правды всегда один — write-side агрегаты.
Обратный поток данных. Поток идёт в одну сторону: write → события → read. Read-model не должна вносить изменения в write-сторону. Если read-сторона обнаружила несогласованность — нужно залогировать или перезапустить rebuild для этой записи, но не писать напрямую в write-таблицы.
Синхронное обновление read-model в write-транзакции. Это связывает две стороны: изменение схемы read-таблицы начинает влиять на write-производительность. Правило простое: только через события.
Коротко
- Read-model — денормализованная проекция данных, оптимизированная под конкретные запросы чтения. Один SELECT, без JOIN'ов.
- Хранилище выбирают под паттерн чтения: PG-таблица для табличных запросов, materialized view для тяжёлых агрегаций, Redis для горячих ключей, ElasticSearch для полнотекстового поиска.
- Схема read-model независима от write-схемы — это нормально и правильно.
- Обновляется только через события (outbox + Kafka + потребитель), не синхронно в write-транзакции.
- Задержка обновления 100ms–1s — это не баг, а ожидаемое поведение (eventual consistency).
- Для каждой read-model должен быть скрипт восстановления из write-стороны.
- Read-model — проекция, не источник правды. Источник правды — write-side агрегаты.
Что почитать дальше
- Синхронизация read-model через события — как outbox + Kafka доставляет события до read-model.
- Query side в CQRS — как query-handler читает из read-model.
- Уровень и эволюция CQRS — когда вводить отдельную read-model.