Materialized views в SQL: гайд с примерами
Обычный VIEW vs materialized VIEW
Обычное представление (VIEW) — это сохранённый SQL-запрос. При обращении к нему база каждый раз выполняет запрос заново. Это удобно для инкапсуляции логики, но не даёт выигрыша в производительности.
Материализованное представление (MATERIALIZED VIEW) — это таблица, в которую один раз записан результат запроса. При обращении к ней база просто читает данные, как из обычной таблицы. Быстро, но данные могут быть устаревшими — их нужно обновлять.
Разница важная для аналитических дашбордов. Обычная VIEW с агрегацией на 100 миллионов строк будет тормозить на каждом открытии дашборда. Материализованная — читается за секунды, но обновляется, скажем, раз в час.
Когда это имеет смысл
Три типичных сценария, где материализованные представления оправданы:
Тяжёлые агрегаты для дашбордов. Daily GMV по 5 миллионам транзакций за 12 месяцев — классический случай. Считать на каждом открытии дашборда — 30 секунд ожидания. Материализовать раз в ночь — 0.5 секунды.
Сложные JOIN-ы с несколькими таблицами. Когорта пользователей с JOIN на события, заказы, платежи. Запрос может выполняться минуты. Материализация сводит это к миллисекундам.
Данные, которые редко меняются. Справочники, нормализованные словари. Не нужно пересчитывать каждую минуту.
А когда не стоит использовать:
Данные, которые должны быть свежими в реальном времени. Материализация всегда имеет lag.
Простые запросы, которые и так быстрые. Overhead материализации не оправдан.
Часто меняющиеся source-таблицы. Если source обновляется каждую минуту, материализация раз в час будет постоянно устаревшей.
Синтаксис в PostgreSQL
Создание материализованного представления:
CREATE MATERIALIZED VIEW mart_daily_gmv AS
SELECT
DATE_TRUNC('day', created_at)::DATE AS day,
SUM(amount) AS gmv,
COUNT(DISTINCT user_id) AS buyers,
AVG(amount) AS avg_check
FROM orders
WHERE status = 'paid'
GROUP BY 1;
-- Индекс на этом
CREATE INDEX ON mart_daily_gmv (day);Это обычный SELECT, обёрнутый в CREATE MATERIALIZED VIEW. При выполнении запрос прогоняется, результат сохраняется.
Теперь дашборды могут запрашивать SELECT * FROM mart_daily_gmv WHERE day >= CURRENT_DATE - INTERVAL '30 day' — это быстрее чем агрегация на сырых orders.
Обновление: REFRESH
Материализованное представление само не обновляется. Данные в нём замороженные на момент последнего refresh. Чтобы пересчитать:
REFRESH MATERIALIZED VIEW mart_daily_gmv;Эта команда заново выполняет SELECT и перезаписывает содержимое. Долго — зависит от размера данных.
Есть важный нюанс: стандартный REFRESH блокирует чтения на время пересчёта. Для production это обычно неприемлемо.
Решение — CONCURRENTLY:
REFRESH MATERIALIZED VIEW CONCURRENTLY mart_daily_gmv;Эта команда не блокирует чтения. Но требует уникального индекса на представлении. Работает медленнее, зато без downtime для пользователей.
Стратегии refresh
Как часто обновлять — зависит от требований бизнеса.
По расписанию через Airflow / cron. Самый распространённый случай. Раз в час, раз в день, раз в неделю. Airflow DAG вызывает REFRESH MATERIALIZED VIEW после того, как ETL заливает свежие данные в source.
После триггера. Специальный триггер на source-таблицах запускает REFRESH при каждом INSERT. Не рекомендуется — REFRESH долгий, триггер будет блокировать каждый INSERT.
По требованию. Пользователь нажимает «обновить дашборд» — запускается REFRESH. Подходит для отчётов, которые смотрят редко.
Incremental refresh. PostgreSQL не поддерживает инкрементальное обновление «из коробки». Только full refresh. Но есть обходные пути — писать свою логику обновления через процедуры, которые пересчитывают только нужные части. Сложно, но эффективно для огромных представлений.
Разобраться, как правильно проектировать ETL и витрины данных — важная часть middle-роли. В тренажёре Карьерник есть задачи на архитектуру данных и оптимизацию аналитических запросов.
Materialized view vs обычная таблица
Спорный вопрос: чем материализованное представление лучше обычной таблицы, которую вы обновляете SQL-скриптом?
Формально — не особо. Обе хранят предрассчитанные данные. Разница:
Материализованное представление — часть DDL, определение SELECT живёт в схеме БД. Видно через \d+, понятно откуда берутся данные.
Обычная таблица — просто хранилище. Логика заполнения живёт в скрипте Airflow или dbt.
На практике в современных аналитических стеках чаще используют обычные таблицы + dbt-модели. Они лучше версионируются в git, имеют встроенные тесты, легче отлаживаются.
Материализованные представления остаются хороши для простых случаев или в PostgreSQL-ориентированных проектах без dbt.
Пример: ежедневные метрики продукта
Типичная витрина для продуктовой аналитики:
CREATE MATERIALIZED VIEW mart_daily_product_metrics AS
WITH active_users AS (
SELECT
event_time::DATE AS day,
COUNT(DISTINCT user_id) AS dau
FROM events
WHERE event_name = 'app_open'
GROUP BY 1
),
purchases AS (
SELECT
created_at::DATE AS day,
COUNT(*) AS orders_count,
SUM(amount) AS revenue,
COUNT(DISTINCT user_id) AS paying_users
FROM orders
WHERE status = 'paid'
GROUP BY 1
),
new_users AS (
SELECT
registered_at::DATE AS day,
COUNT(*) AS new_signups
FROM users
GROUP BY 1
)
SELECT
COALESCE(a.day, p.day, n.day) AS day,
COALESCE(a.dau, 0) AS dau,
COALESCE(p.orders_count, 0) AS orders,
COALESCE(p.revenue, 0) AS revenue,
COALESCE(p.paying_users, 0) AS paying_users,
COALESCE(n.new_signups, 0) AS new_signups
FROM active_users a
FULL OUTER JOIN purchases p USING (day)
FULL OUTER JOIN new_users n USING (day);
-- Индекс для быстрого фильтра по дате
CREATE UNIQUE INDEX ON mart_daily_product_metrics (day);Три CTE с разной логикой, объединённые FULL OUTER JOIN. На сырых данных каждое открытие дашборда считалось бы минуты. В материализованном виде — мгновенно.
Обновление раз в день:
-- Airflow task после ETL
REFRESH MATERIALIZED VIEW CONCURRENTLY mart_daily_product_metrics;Ограничения
В PostgreSQL материализованные представления не могут:
Запрашивать другие материализованные представления в зависимостях, которые Postgres понимает для каскадного refresh. Нужно вручную организовывать порядок.
Использовать функции с VOLATILE маркировкой — например, NOW() или RANDOM(). Точнее, могут, но после refresh эти значения замораживаются.
Иметь first-class инкрементальный refresh. Только full.
Автоматически обновляться. Нужен внешний триггер (Airflow, cron).
Troubleshooting
Частые проблемы и решения.
REFRESH медленный. Смотрите EXPLAIN ANALYZE самого SELECT. Проблема обычно не в самом REFRESH, а в медленном запросе. Оптимизируйте его — добавьте индексы на source-таблицах, упростите JOIN.
REFRESH блокирует приложение. Используйте CONCURRENTLY вместо обычного REFRESH. Требует уникального индекса на MV.
Данные устарели, но REFRESH не запускается. Проверьте Airflow: DAG упал или отключен. Мониторьте SLA на обновление MV.
Размер MV растёт. Если выросло количество строк в source — ожидаемо. Если MV в разы больше, чем результат её SELECT — возможно, есть баг в определении.
Читайте также
FAQ
Materialized view или dbt-модель?
В современных стеках чаще dbt-модель как обычная таблица. Она версионируется в git, имеет тесты, документацию. MV — для старых Postgres-ориентированных проектов.
Можно ли строить MV на MV?
Да, технически. Но каскадный refresh надо делать вручную: сначала refresh базовой MV, потом той, что зависит от неё.
Как узнать, когда последний раз обновлялась MV?
Postgres не хранит эту информацию напрямую. Обычно добавляют колонку refreshed_at в MV (через SELECT NOW() as refreshed_at) или отдельную таблицу-лог refresh-ов.
REFRESH CONCURRENTLY всегда лучше?
Почти всегда, но медленнее обычного REFRESH (создаёт временную таблицу, сравнивает, применяет). Для маленьких MV или если пользователи не смотрят ночью — можно использовать обычный REFRESH.