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 EXCLUSIVE lock — блокирует 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 в коде — отдельная таблица + код, обновляющий её

MVRead Model в коде
Логика обновленияSQLJava 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-080REFRESH 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.