Read Models и проекции
Read Model — это специально спроектированная таблица/индекс/вид для быстрых чтений под конкретный продуктовый сценарий. Проекция — процесс, который преобразует события/изменения источника в обновления Read Model (обычно idempotent upsert). В связке с CQRS это позволяет разгрузить OLTP-ядро и стабилизировать p95/p99 чтений, контролируя «свежесть».
Главные идеи:- Денормализовать под запрос, а не «универсальную схему».
- Обновлять инкрементально и идемпотентно.
- Явно управлять staleness и порядком.
1) Когда использовать Read Models (и когда — нет)
Подходит:- Частые тяжелые чтения (джоины/агрегации/сортировки) с допустимой задержкой обновления.
- Дашборды, каталоги, лендинги, «топ-N», персональные фиды, поисковые списки.
- Разделение нагрузки: write-ядро — строгое, read-плоскость — быстрая и масштабируемая.
- Операции, требующие строгих инвариантов «на каждую запись» (деньги, уникальности). Там — strong path.
2) Архитектурный контур (словесная схема)
1. Источник изменений: события домена (event sourcing) или CDC из OLTP.
2. Конвейер проекций: парсер → аггрегация/денормализация → idempotent upsert.
3. Read Store: БД/индекс, оптимизированные под запрос (RDBMS, колоночные, поисковые).
4. API/клиент: быстрые SELECT/GET, с атрибутами «as_of / freshness».
3) Проектирование Read Model
Начните с запроса: какие поля, фильтры, сортировки, пагинация, топ-N?
Денормализуйте: храните уже объединенные данные (названия, суммы, статусы).
- Партиционирование: по `tenant_id`, дате, регионе.
- Primary key: бизнес-ключ + временной бакет (например, `(tenant_id, entity_id)` или `(tenant_id, bucket_minute)`).
- Индексы: по частым where/order by.
- TTL/ретеншн: для временных витрин (например, 90 дней).
4) Поток обновлений и идемпотентность
Idempotent upsert — основа стабильности проекций.
Псевдо:sql
-- Projection table
CREATE TABLE read_orders (
tenant_id TEXT,
order_id UUID,
status TEXT,
total NUMERIC(12,2),
customer JSONB,
updated_at TIMESTAMP,
PRIMARY KEY (tenant_id, order_id)
);
-- Idempotent update by event
INSERT INTO read_orders(tenant_id, order_id, status, total, customer, updated_at)
VALUES (:tenant,:id,:status,:total,:customer,:ts)
ON CONFLICT (tenant_id, order_id) DO UPDATE
SET status = EXCLUDED. status,
total = EXCLUDED. total,
customer = COALESCE(EXCLUDED. customer, read_orders. customer),
updated_at = GREATEST(EXCLUDED. updated_at, read_orders. updated_at);
Правила:
- Каждое сообщение несет версию/время; принимаем только «свежее или равное» (idempotency).
- Для агрегатов (счетчики, суммы) — храните state и используйте коммутативные обновления (или CRDT-подходы).
5) Источник изменений: события vs CDC
События (event sourcing): богатая семантика, легко строить разные проекции; важна эволюция схем.
CDC (логическая репликация): просто подключить к существующей БД; потребуется маппинг DML→событий и фильтрация шумовых апдейтов.
- Гарантий доставки (at-least-once) и DLQ для «ядовитых» сообщений.
- Порядка по ключу (partition key = `tenant_id:entity_id`).
6) Порядок, причинность и «свежесть»
Порядок по ключу: события одного объекта должны приходить последовательно; используйте партиционирование и версии.
Причинность (session/causal): чтобы автор видел свои изменения (RYW), передавайте watermark версии в запросах.
Свежесть (bounded staleness): возвращайте `as_of` / `X-Data-Freshness` и держите SLO (например, p95 ≤ 60 c).
7) Инкрементальные агрегаты и топ-N
Пример минутных бакетов продаж:sql
CREATE TABLE read_sales_minute (
tenant_id TEXT,
bucket TIMESTAMP, -- toStartOfMinute revenue NUMERIC(14,2),
orders INT,
PRIMARY KEY (tenant_id, bucket)
);
-- Update by Event
INSERT INTO read_sales_minute(tenant_id, bucket, revenue, orders)
VALUES (:tenant,:bucket,:amount, 1)
ON CONFLICT (tenant_id, bucket) DO UPDATE
SET revenue = read_sales_minute. revenue + EXCLUDED. revenue,
orders = read_sales_minute. orders + 1;
Для топ-N:
- Поддерживайте ранжированную витрину (например, по `revenue DESC`) и обновляйте только изменившиеся позиции (heap/skiplist/limited table).
- Храните «окно» топа (например, 100–1000 строк на сегмент).
8) Поисковые и гео-проекции
Поиск (ES/Opensearch): денормализованный документ, pipeline трансформаций, версия документа = версия источника.
Гео: храните `POINT/LAT,LON`, предварительно агрегируйте тайлы/квадротри.
9) Мульти-тенант и регионы
`tenant_id` обязателен в ключах проекций и событиях.
Fairness: лимитируйте throughput проекций per tenant (WFQ/DRR), чтобы «шумный» не тормозил остальных.
Residency: проекция живет в том же регионе, что и write-ядро; межрегиональные витрины — агрегаты/сводки.
10) Наблюдаемость и SLO
Метрики:- `projection_lag_ms` (источник→витрина), `freshness_age_ms` (с момента последней дельты).
- throughput апдейтов, доля ошибок, DLQ-rate, redrive-success.
- Размер витрин, p95/p99 латентность чтений.
- Теги: `tenant_id`, `entity_id`, `event_id`, `version`, `projection_name`, `attempt`.
- Аннотации: merge-решения, пропуски устаревших версий.
11) Плейбуки (runbooks)
1. Рост лага: проверить коннектор/брокер, увеличить партиции, включить приоритизацию ключевых витрин.
2. Много ошибок схемы: заморозить редрайв, выполнить миграцию схем (backfill), перезапустить с новой версией маппера.
3. Повторные DLQ: уменьшить batch, включить «теневой» обработчик, усилить идемпотентность.
4. Несогласованность витрины: выполнить rebuild витрины из журнала/источника за окно (селективно по tenant/partition).
5. Горячие ключи: ограничить конкуренцию по ключу, добавить локальные очереди, вынести агрегат в отдельную витрину.
12) Полный пересчет (rebuild) и backfill
Подход:- Остановить потребление (или переключить на новую версию витрины).
- Пересчитать пакетами (по партициям/датам/тенантам).
- Включить двухфазный свитч: сначала заполняем `read__v2`, затем атомарно переключаем маршрутизацию чтений.
13) Эволюция схем (версионирование)
`schema_version` в событиях/документах.
Проекция умеет читать несколько версий, миграция «на лету».
Для крупных изменений — новая витрина v2 и канареечный трафик.
14) Безопасность и доступ
Наследуйте RLS/ACL от источника; не делайте витрину шире по доступу, чем исходные данные.
Маскируйте PII в проекциях, не нужных для UX/аналитики.
Аудит редрайвов/пересчетов/ручных правок.
15) Конфигурационный шаблон
yaml projections:
read_orders:
source: kafka. orders. events partition_key: "{tenant_id}:{order_id}"
idempotency: version_ts upsert:
table: read_orders conflict_keys: [tenant_id, order_id]
freshness_slo_ms: 60000 dlq:
topic: orders. events. dlq redrive:
batch: 500 rate_limit_per_sec: 50 read_sales_minute:
source: cdc. orders partition_key: "{tenant_id}:{bucket_minute}"
aggregate: increment retention_days: 90 limits:
per_tenant_parallelism: 4 per_key_serial: true observability:
metrics: [projection_lag_ms, dlq_rate, redrive_success, read_p95_ms]
16) Типичные ошибки
«Одна витрина на все случаи» → тяжелые апдейты и плохие p99.
Отсутствие идемпотентности → дубли/скачки в агрегатах.
Dual-write напрямую в витрину и OLTP → расхождения.
Нулевая видимость свежести → конфликт ожиданий с продуктом.
Rebuild без двухфазного свитча → «дыры» в ответах.
Нет партиционирования/индексов → рост стоимости и латентности.
17) Быстрые рецепты
Каталог/поиск: документная витрина + инкрементальный upsert, lag ≤ 5–15 c, индексы под фильтры.
Дашборды: минутные/часовые бакеты, агрегаты `SUM/COUNT`, p95 свежести ≤ 60 c.
Персональная лента: проекция по пользователю + causal/RYW для автора, fallback на кэш.
Глобальный SaaS: региональные витрины, агрегаты кросс-регионально; fairness per tenant.
18) Чек-лист перед продом
- Витрина спроектирована под конкретный запрос; есть индексы и партиции.
- Источник изменений выбран (события/CDC); гарантии доставки и порядок по ключу.
- Идемпотентный upsert с версиями/временем; защита от «старых» событий.
- SLO свежести определен и отдается в ответах (`as_of/freshness`).
- DLQ и безопасный редрайв настроены; плейбук на rebuild/backfill.
- Ограничения конкуренции (per-key serial) и fairness per tenant.
- Метрики лага/ошибок/latency, алерты на p95/p99 и рост DLQ.
- Версионирование схем и стратегия миграций (v2 + свитч).
- Политики доступа/PII наследованы и проверены.
Заключение
Read Models и проекции — это инженерный ускоритель чтений: вы платите небольшой ценой «свежести» и инфраструктуры стриминга, чтобы получить предсказуемые миллисекунды и разгрузить ядро записей. Проектируйте витрины под запрос, делайте апдейты идемпотентными, измеряйте лаг и явно обещайте свежесть — и ваши API останутся быстрыми даже при росте нагрузки, данных и географии.