Меня подняли на смех за ответ про VIEW. Я поднял MySQL 8.4 и PostgreSQL 17 и померил
На одном собеседовании меня спросили про VIEW. Я ответил честно: в живых проектах они мне почти не попадались; для агрегатов надёжнее держать отдельную таблицу; а сами представления - вещь настолько нишевая, что за карьеру пригождались считанные разы. Разделение прав, долгие миграции, совместимость со старым ПО - вот и весь список. Ответ приняли прохладно. Один из собеседников сказал: "Ничего ты не понимаешь во VIEW" - и все посмеялись.
Прошло много времени. VIEW в моём коде так и не прибавилось, а вопрос остался, а вдруг с тех пор всё изменилось? Движки вышли новые. Поэтому я поднял MySQL 8.4 и PostgreSQL 17, залил одинаковые данные и прогнал основные сценарии один за другим.
Стенд
Docker Compose, MySQL 8.4.11 и PostgreSQL 17.11, схема интернет-магазина: заказы, позиции, платежи, справочник продавцов. Данные детерминированные и побайтово одинаковые в обеих базах - миллион заказов, два миллиона позиций, 780 тысяч платежей, сто продавцов, даты за 21 месяц. Набор представлений зеркальный, простая обёртка, дневной агрегат, каскад из трёх уровней, ловушка с ORDER BY внутри, в PostgreSQL дополнительно материализованная view с уникальным индексом.
Измеряем серверное время, в MySQL корневой actual time из EXPLAIN ANALYZE, а в PostgreSQL Execution Time. Два прогрева, семь замеров, берём медиану. Само измерение увеличивает время мелких запросов в разы, поэтому сравнивать имеет смысл только одинаковые запросы. Сравнивать абсолютные миллисекунды на другой машине не имеет смысла. Две настройки памяти, у PostgreSQL work_mem дефолтные 4 МБ, у MySQL temptable_max_ram 1Гб. Окно запросов везде одинаковое - июнь 2026-го, продавец с ID 42.
Простая view не потребляет дополнительных ресурсов
Обёртка над orders без агрегации, полторы-две миллисекунды в обеих СУБД, разброс в пределах шума, планы совпадают с планами прямого запроса. MySQL сливает определение с запросом (алгоритм MERGE), PostgreSQL разворачивает view правилом перезаписи ещё до планировщика. Оптимизатор просто не видит, что вы обратились к представлению.
Получаем скучный результат, как лучший из возможных. Переиспользуемый фильтр и контракт чтения view ничего не стоит.
Один CAST, и view дороже в тринадцать раз
CREATE VIEW v_merchant_daily AS
SELECT o.merchant_id,
CAST(o.created_at AS DATE) AS day,
COUNT(*) AS orders_cnt,
SUM(o.amount_total) AS revenue
FROM orders o
WHERE o.status IN ('paid', 'shipped', 'completed')
GROUP BY o.merchant_id, CAST(o.created_at AS DATE);
Запрос поверх неё - ровно то, что напишет любой:
SELECT * FROM v_merchant_daily
WHERE merchant_id = 42
AND day >= '2026-06-01' AND day < '2026-07-01'
ORDER BY day;
PostgreSQL отдаёт это за 24.5 мс, а тот же результат прямым запросом к таблице - за 1.85 мс. Разница в тринадцать раз на ровном месте.
Моя первая версия объяснения была такая же, как в половине статей про view: "агрегат считается до фильтра, движок группирует всю таблицу, а потом отбрасывает лишнее". Я открыл план и оказался неправ.
GroupAggregate (actual time=26.702..26.897 rows=30)
-> Sort (rows=469)
-> Bitmap Heap Scan on orders o (actual time=3.661..26.505 rows=469)
Recheck Cond: (merchant_id = 42)
Filter: (status = ANY (...)) AND ((created_at)::date >= '2026-06-01') AND ...
Rows Removed by Filter: 9531
Heap Blocks: exact=8929
Buffers: shared hit=8971
-> Bitmap Index Scan on idx_orders_merchant_created (rows=10000)
Index Cond: (merchant_id = 42)
Никакой агрегации всей таблицы. Оба предиката уехали под GROUP BY, до сканирования, планировщик умеет проталкивать условия по колонкам группировки, и merchant_id, и day ими являются. Полностью агрегируются 469 строк, так же как в прямом запросе.
Разница в другой строчке. Rows Removed by Filter: 9531 и Heap Blocks: exact=8929. Индекс (merchant_id, created_at) в прямом запросе отрабатывает обе колонки и достаёт 469 строк. А через view условие приходит в виде CAST(created_at AS DATE) >= '2026-06-01' - это выражение, а не колонка, и границей диапазона по индексу оно быть не может. Остаётся условие по продавцу: все десять тысяч его заказов за 21 месяц вытаскиваются из кучи по разбросанным страницам, и уже там 9531 строка выбрасывается фильтром.
Итого 8971 обращение к буферам против 440 у прямого запроса.
MySQL на том же месте затрачивает 4.85 мс против 2.16 мс - в два с небольшим раза, а не в тринадцать. Механизм тот же, агрегирующая view в MySQL всегда TEMPTABLE (наличие GROUP BY запрещает MERGE), а условия внутрь проталкивает derived condition pushdown. Спасает MySQL то, что CAST он вычисляет прямо в индексе, index condition pushdown, строки-кандидаты не поднимаются из таблицы вообще, отсекаются на уровне индексных записей.
Подзапрос с тем же текстом дал 26.9 мс, CTE - 24.6 мс. Так что дело не CREATE VIEW, а в семантике, любая конструкция с той же группировкой ведёт себя одинаково.
Проблема агрегирующей view не в том, что она "считает раньше, чем фильтрует". Проблема в том, что наружу она выставляет day, а индекс живёт на created_at. View честно отдаёт колонку, по которой невозможно попасть в индекс, и, делает это "молча", потому что интерфейс представления выглядит как интерфейс таблицы.
С тех пор при виде CREATE VIEW ... GROUP BY в pull request приходиться уточнять, какие колонки в неё выставлены наружу и есть ли под ними индексы. В восьми случаях из десяти этого хватает.
Каскад, который оказался быстрее прямого запроса
Три уровня: дневной агрегат, сумма поверх него, join со справочником наверху. Каждый уровень пересчитывается при каждом обращении. Ожидаем очевидное, чем глубже стопка, тем хуже.
PostgreSQL ожидание подтвердил, 832 мс против 243 мс у однопроходного запроса.
MySQL его опроверг, каскад 1436 мс против 2432 мс у прямого запроса. Стопка из трёх view оказалась почти вдвое быстрее однопроходного запроса, написанного руками.
Причина - форма join. Прямой запрос соединяет orders со справочником до группировки, с вложенным циклом: loops=750000 точечных обращений по первичному ключу. В каскаде join уезжает на самый верх, где после двух агрегаций от 750 тысяч строк остаётся 75. Воронка 750000 → 48000 → 75 видна в плане целиком.
У PostgreSQL в том же каскаде нашлось другое. Прямой запрос он выполняет в два параллельных воркера с одним HashAggregate. Каскад параллелизм теряет, а промежуточные 48 тысяч групп не влезают в work_mem 4 МБ:
HashAggregate (rows=48000)
Planned Partitions: 32 Batches: 33 Memory Usage: 8209kB Disk Usage: 30224kB
Это 30 МБ на диск за одно выполнение. За серию из девяти прогонов счётчик temp_bytes в pg_stat_database вырос на 278 МБ, при том, что запрос возвращает десять строк. На десяти миллионах тот же каскад выливает уже 245 МБ, держа в оперативке 8,3 МБ, - PostgreSQL растёт не по памяти, а по диску.
Дорогой ли каскад - вопрос не глубины, а плана соединения и настроек памяти: поменяйте work_mem, и цифры поедут. План каскада нечитаем. Два вложенных Materialize → Aggregate using temporary table в MySQL, тридцать пять строк вывода в PostgreSQL, и всё это ради десяти строк отчёта. Когда такой отчёт затормозит в проде в четверг вечером, искать, на каком из уровней потерялось время, придётся глазами. Заранее предсказать, поможет каскад или обрушит систему, не выйдет - только проверять. И проверять на боевых объёмах, на тестовой базе планировщик покажет совсем другую картину.
ORDER BY внутри view
Классика кодовой базы: CREATE VIEW ... ORDER BY created_at DESC, "чтобы точно было отсортировано". LIMIT 10 поверх такой view обе СУБД отдают мгновенно, сотые доли миллисекунды, обе читают индекс задом наперёд и берут десять записей. С фильтром сверху PostgreSQL справляется за 0.17 мс, MySQL - за 4.18 мс, и в обоих планах сортировки нет вообще, индекс уже даёт нужный порядок.
Цифры тут неинтересные. Интересна семантика, стандарт SQL не гарантирует порядок строк, которые отдаёт view, и документация MySQL говорит это прямым текстом. Сегодня план сложился удачно и данные выглядят отсортированными. Завтра оптимизатор пересчитает статистику, выберет другой доступ, и код, молча полагавшийся на порядок, начнёт отдавать "случайную" десятку. Такой баг не падает, не логируется и обнаруживается по жалобе пользователя через полгода.
Материализованные view и вопрос про кеш
Нативные материализованные view есть только в PostgreSQL. Чтение из mv_merchant_daily с уникальным индексом показывают 0.24 мс против 24.5 мс у живого агрегата, то есть в сто раз быстрее.
Цена на другой стороне. REFRESH на миллионе заказов занимает 1146 мс, и он всегда полный, инкрементального обновления в PostgreSQL нет. После вставок, задевших несколько сотен строк, REFRESH занял те же 1200 мс - дельта не влияет ни на что. Пишется он на диск ровно так же, как каскад, те же ~30 МБ временных файлов за выполнение.
Стократную разницу легко списать на кеш - "движок запомнил ответ". Но кеша результатов не существует ни в одной из двух СУБД. MySQL убрал Query Cache в 8.0, в PostgreSQL его никогда не было, кешируются только страницы данных.
Проверка простая, тридцать повторов одного запроса через агрегирующую view подряд. Медиана MySQL 4.37 мс при максимуме 5.95, PostgreSQL 21.7 мс при максимуме 25.2. "Плоская" серия, каждый прогон считает заново.
Счётчики говорят то же самое иначе. В PostgreSQL тяжёлый агрегат обслуживается целиком из прогретого пула 9456 попаданий, ноль обращений к диску, 23 мс. Чтение материализованной view это сотня попаданий, 0.24 мс. Оба запроса читают одинаково прогретые страницы и различаются в сто раз, разница чисто вычислительная. У MySQL картина в терминах InnoDB, за прогон ноль физических чтений, 5883 логических из буферного пула и Created_tmp_tables +7, временные таблицы рождаются и умирают вместе с каждым выполнением.
Материализованная view быстрее не потому, что "кешируется", а потому, что вычислять уже нечего.
Что чтение view делает с записью
Пятнадцатисекундные окна вставок пачками по восемьсот строк. Базовая линия - только вставки. Второй сценарий добавляет двух фоновых читателей, непрерывно гоняющих агрегирующую view.
Скорость записи просела на 7% в MySQL и на 16% в PostgreSQL. Читатели при этом получали около семидесяти запросов в секунду с медианой 7 мс.
Контрольный сценарий заменил живую view сводной таблицей, которую писатель поддерживает инкрементальным upsert'ом по затронутым группам. Вставки не просели вообще: 24000 строк против 22400 у базовой линии в MySQL, 20800 против 20000 в PostgreSQL - плюс 7% и плюс 4%, то есть чистый шум фоновой нагрузки хоста. Консистентность снапшота сверил отдельно.
Сама по себе view записи не мешает. Мешает постоянный читатель тяжёлого агрегата, он делит ресурсы с писателем и за каждое своё чтение расплачивается полным пересчётом. Поддержка сводной таблицы на стороне писателя не стоит ничего.
Цена зависит не от запроса, а от объёма
Те же сценарии на десяти тысячах заказов, потом на миллионе, потом на десяти миллионах.
| Заказов | MySQL, view/direct | PostgreSQL, view/direct |
|---|---|---|
| 10 000 | 1.6× | 2.7× |
| 1 000 000 | 2.3× | 10.7× |
| 10 000 000 | 2.5× | 50.4× |
MySQL держится ровно на всём диапазоне, index condition pushdown работает при любом объёме, лишние строки отсекаются в индексе. А у PostgreSQL отставание растёт вместе с данными.
Механически это тот же CAST, view всегда читает все заказы продавца, а прямой запрос - только заказы за июнь. При окне данных это ровно 21-кратная разница во входе, и она не зависит от объёма. А измеренный разрыв растёт с 2.7× до 50×, то есть обгоняет её вдвое с лишним. Планов на десяти миллионах я не снимал, поэтому объяснения у меня нет, есть гипотеза - сто тысяч случайных обращений в кучу перестают попадать в прогретый пул, и к вычислениям добавляется реальный ввод-вывод.
Проверять это я не стал: на решение, которое из этого следует, ответ уже не влияет.
Потому что рядом стоит третья стратегия.
Чтение из сводной таблицы, которую поддерживает пишущая сторона, не зависит от объёма вообще: 0.02–0.20 мс и на десяти тысячах, и на десяти миллионах. На верхней границе это в 1470 раз быстрее живой view в MySQL и в 4847 раз - в PostgreSQL.
Поддержка таблицы не бесплатна, полный пересчёт на десяти миллионах занимает 19 секунд в MySQL и 7 секунд в PostgreSQL. Для свежести "раз в сутки" это ничего не значит, подходит любой вариант, хоть REFRESH, хоть rebuild. Для свежести "каждую секунду" материализованная view выбывает уже на миллионе, один REFRESH длится дольше интервала между ними.
До десятков тысяч строк выбор между живой view и сводной таблицей, вопрос вкуса, и спорить о нём глупо. Ближе к миллиону это становится вопросом архитектуры, а за миллионом выбора уже нет.
Сколько это в мегабайтах
Претензия к представлениям, которую я слышал чаще прочих, звучит как "они жрут ресурсы". Время я померил, а память нет, и это был пропуск в рассуждении, агрегирующая view создаёт временную таблицу, а временная таблица где-то живёт.
Считал рабочую память запроса, а не буферный пул, тот выделен заранее и от способа чтения не зависит совсем. В MySQL это прирост памяти треда соединения из performance_schema, в PostgreSQL память узлов плана из EXPLAIN (ANALYZE, MEMORY) плюс то, что ушло на диск.
Простая view не стоит ничего и здесь. 75 КБ против 33 КБ у прямого запроса на миллионе, разница - накладные на разбор определения, и с объёмом она не растёт. В PostgreSQL в плане нет ни одного рабочего узла, считать нечего.
Агрегирующая view в MySQL стоит ровно мегабайт. Не "мегабайт на миллионе и десять на десяти миллионах", а мегабайт всегда, пока проталкивается предикат. Если не проталкивается, в таблицу ложится весь результат view, выключил проталкивание хинтом на том же запросе, и, 15 535 КБ вместо 1262, 12,8 секунды вместо 59 миллисекунд. Это блок аллокатора TempTable, он выделяется целиком под любую временную таблицу, хоть на тридцать строк результата. Прямой агрегат тратит те же 1082–1181 КБ.
Дальше пошло то, чего я не ждал. Каскад из трёх view - 13 706 КБ на миллионе и те же 13 706 КБ на десяти миллионах. Я решил, что сломался замер, и полез проверять. Замер не сломался, память группировки определяется числом групп, а не числом строк. Комбинаций "продавец × день" в моих данных 48 000, семьдесят пять продавцов на 640 дней, и это потолок, который не двигается, сколько заказов ни залей.
Рекорд серии поставил запрос вообще без единой view. Тот самый однопроходный отчёт, который в MySQL оказался вдвое медленнее каскада, на десяти миллионах занял 526 МБ и единственный во всей серии свалился во временную таблицу на диске, он соединяет заказы со справочником до группировки, и семь с половиной миллионов строк материализуются целиком. Каскад на тех же данных - 13,7 МБ, в тридцать девять раз меньше.
Чтение из сводной таблицы - 24 КБ на любом объёме и ноль временных таблиц. Готовому снапшоту нечего вычислять, значит, и держать в памяти нечего.
Разница между стратегиями проходит по времени и по спиллу на диск, а не по мегабайтам.
Симптомы проблем с VIEW
Если тяжёлая view уже живёт у вас в проде, выглядит это так, процессор и временные объекты растут без внятной причины, а отчёты деградируют не плавно, а ступенькой. Смотреть - Created_tmp_tables и Created_tmp_disk_tables в MySQL, temp_bytes в pg_stat_database у PostgreSQL. Дальше EXPLAIN ANALYZE по подозреваемому и поиск view в "горячем" пути через performance_schema или pg_stat_statements.
Итог
- Простая view действительно не потребляет ресурсы, оба движка смотрят сквозь неё.
- Без MERGE, TEMPTABLE и правил перезаписи не объяснить ни тринадцатикратную разницу на агрегате, ни пятидесятикратную на масштабе.
- В MySQL каскадные view могут рассматриваться, как элемент оптимизации.
- Агрегирующая view в "горячем" пути, плохой подход, её стоимость растёт быстрее данных, а у PostgreSQL растёт кратно.
ORDER BYвнутри view порождает тихие баги вместо удобства.- Материализованная view окупается только там, где чтений сильно больше записей и лаг данных никого не расстраивает.
- Сводная таблица с инкрементальной поддержкой дешевле на порядки и от объёма не зависит, поэтому агрегаты у меня часто живут отдельными таблицами.
- Потребление ресурсов агрегирующей view в колонках, которые выставляются наружу. Индекс лежит на соседней колонке, и дотянуться до него из запроса уже нельзя. Формулировка звучит мельче, а на практике решает больше, она подсказывает, что чинить.
- По потребляемой памяти view в современных движках всё хорошо и не вызывает особого беспокойства, как раньше.
Где view полезно применять:
- переиспользуемый фильтр и стабильный контракт чтения;
- разделение прав - с оговорками про security_invoker и
security_barrierв PostgreSQL,SQL SECURITYв MySQL; - мягкая миграция, когда старое приложение читает старый интерфейс поверх новой схемы;
- legacy, который проще обернуть, чем переписать;
- дашборды на материализованной view при редкой записи.
Список ниш, который я назвал на том собеседовании, не изменился ни на пункт. Изменилось другое, за каждым пунктом теперь стоит план запроса, а не привычка.