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→sobyty і фільтрація шумових апдейтів.
- Гарантій доставки (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'( istochnik→vitrina),'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 залишаться швидкими навіть при зростанні навантаження, даних і географії.