VIEW — это сохранённый SQL-запрос, который выполняется при каждом обращении. MATERIALIZED VIEW — это VIEW, чей результат сохранён физически на диске и обновляется по команде. По сути — закэшированный snapshot тяжёлого запроса.
Эта статья — когда MV оправдан, как обновлять без блокировки, чем отличается от Read Model в коде. Правила пронумерованы кодами PG-MV-NNN.
1. Когда MV оправдан
PG-MV-001 — MV подходит для:
- Тяжёлые агрегации, которые читают часто, а изменения видны с задержкой ОК (отчёты, dashboards).
- Сложные join'ы по нескольким таблицам, где данные не критично актуальны.
- Pre-computed search-индексы (см. FTS).
- Денормализация для read-heavy нагрузки.
PG-MV-002 — MV не подходит:
- Real-time данные (refresh имеет латентность).
- Часто меняющиеся данные, где cost refresh > выигрыш чтения.
- Простые запросы без агрегаций — обычный VIEW или индекс.
2. Базовый паттерн
PG-MV-010 — Создание:
CREATE MATERIALIZED VIEW order_stats_mv AS
SELECT
customer_id,
count(*) AS orders_count,
sum(total_amount) AS total_spent,
max(created_at) AS last_order_at
FROM order_doc
WHERE status != 'CANCELLED'
GROUP BY customer_id
WITH DATA; -- (default) — сразу заполнить данными
WITH NO DATA — создать пустой, заполнить позже через REFRESH. Полезно для миграций.
PG-MV-011 — Запрос — как к обычной таблице:
SELECT * FROM order_stats_mv WHERE customer_id = 42;
PG-MV-012 — Индексы на MV — обязательны для частых WHERE:
CREATE INDEX ix_order_stats_customer ON order_stats_mv (customer_id);
CREATE UNIQUE INDEX uk_order_stats_customer ON order_stats_mv (customer_id);
-- UNIQUE-индекс нужен для CONCURRENTLY refresh (см. §4)
3. Стратегии refresh
PG-MV-020 — Три варианта обновления:
REFRESH MATERIALIZED VIEW (полный, блокирующий)
REFRESH MATERIALIZED VIEW order_stats_mv;
- Перевычисляет всё с нуля.
- Берёт
ACCESS EXCLUSIVElock — блокирует SELECT во время refresh. - На большой MV (миллионы строк) — минуты блокировки.
PG-MV-021 — Используй только если:
- MV маленькая (< 100K строк).
- Refresh идёт в окне обслуживания.
- Нет онлайн-чтения во время refresh.
REFRESH MATERIALIZED VIEW CONCURRENTLY (без блокировки)
REFRESH MATERIALIZED VIEW CONCURRENTLY order_stats_mv;
- Не блокирует SELECT.
- Использует UNIQUE-индекс для diff'а — обновляет только изменившиеся строки.
- Требует UNIQUE-индекс на MV (любая колонка/комбинация уникальная).
- Медленнее обычного REFRESH (требует diff'а).
PG-MV-022 — CONCURRENTLY — стандартный выбор для прод-MV
Инкрементальный refresh (через триггеры или ручной)
PG-MV-023 — PG не поддерживает инкрементальный refresh из коробки
Когда нужен — варианты:
- Расширение
pg_ivm(incremental view maintenance). - Свой паттерн через триггеры на исходных таблицах + ручной UPDATE MV.
- TimescaleDB continuous aggregates (если уже в стеке).
4. Когда refresh — крон, когда — реактивно
PG-MV-030 — Cron / @Scheduled — для отчётов и дашбордов:
@Scheduled(fixedDelay = 300_000) // каждые 5 минут
public void refreshOrderStats() {
jdbc.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY order_stats_mv");
}
Латентность 5 минут — приемлемо для аналитики.
PG-MV-031 — Реактивно через триггер — когда задержка критична:
CREATE OR REPLACE FUNCTION refresh_order_stats()
RETURNS trigger AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY order_stats_mv;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_order_doc_refresh_stats
AFTER INSERT OR UPDATE OR DELETE ON order_doc
FOR EACH STATEMENT EXECUTE FUNCTION refresh_order_stats();
PG-MV-032 — Триггер на каждое изменение — ОЧЕНЬ дорого
Refresh всей MV на каждый INSERT уничтожает throughput. Используй только когда:
- Изменения редкие.
- MV маленькая.
- Альтернативы нет.
PG-MV-033 — Лучше — debouncing через Redis/cron:
@Component
class OrderStatsRefresh {
@EventListener
public void onOrderChanged(OrderChangedEvent e) {
redis.set("mv:order_stats:dirty", "1");
}
@Scheduled(fixedDelay = 60_000)
public void refreshIfDirty() {
if (redis.delete("mv:order_stats:dirty")) {
jdbc.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY order_stats_mv");
}
}
}
Refresh не чаще раза в минуту, и только если что-то изменилось.
5. MV vs Read Model в коде
PG-MV-040 — MV — это read model в БД. Read model в коде — отдельная таблица + код, обновляющий её
| MV | Read Model в коде | |
|---|---|---|
| Логика обновления | SQL | Java code |
| Гранулярность | вся MV | можно по строкам |
| Латентность | секунды-минуты (refresh) | микросекунды (event-driven) |
| Сложность для разраба | низкая (один SQL) | высокая (eventual consistency) |
| Подходит для | отчёты, агрегации | CQRS write/read разделение |
PG-MV-041 — Простое правило выбора:
- Запрос —
SELECT ... GROUP BYсложный с JOIN'ами, читается часто, обновлять можно с задержкой → MV. - Read model нужна с минимальной задержкой, обновляется поэлементно при изменениях → отдельная таблица + event handler.
См. Уровень 2 UCP — Read Model в коде через UseCaseQuery.
6. Антипаттерны
PG-MV-080 — REFRESH MATERIALIZED VIEW (без CONCURRENTLY) на проде
— блокирует всех читателей.
PG-MV-081 — Триггер AFTER ANY с REFRESH
на горячей таблице — каждый INSERT триггерит full refresh.
PG-MV-082 — MV без UNIQUE индекса
— нельзя CONCURRENTLY refresh, остаётся только blocking.
PG-MV-083 — MV для real-time данных
— латентность refresh не покрывает требования к свежести.
PG-MV-084 — MV вместо нормального индекса
— иногда «давай закэшируем SELECT WHERE foo = ?» решается обычным индексом по foo.
Чек-лист на ревью
- [ ] MV оправдана: тяжёлая агрегация, частое чтение, латентность приемлема.
- [ ] Есть UNIQUE-индекс на MV (для CONCURRENTLY refresh).
- [ ]
REFRESH MATERIALIZED VIEW CONCURRENTLY, не блокирующий вариант. - [ ] Refresh вынесен в
@Scheduledили debounced trigger, не на каждый INSERT. - [ ] Стратегия refresh: cron для аналитики, debounced для near-real-time.
- [ ] Альтернатива (Read Model в коде) рассмотрена и отвергнута по конкретной причине.
Связанные
- Типы индексов — индексы на MV.
- Уровень 2 UCP — Read Model в коде vs MV.