← назад к разделу

В обычном приложении одна таблица — и для записи, и для чтения. Это удобно пока данных мало. Но когда страница заказов собирает информацию из шести таблиц через пять 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.