Один CAST, и view в PostgreSQL дороже в тринадцать раз. Я поднял MySQL 8.4 и PostgreSQL 17 и всё померил
На одном собеседовании меня спросили про VIEW. Ответил честно: в живых проектах они мне почти не попадались, для агрегатов надёжнее держать отдельную таблицу. Один из собеседников сказал: "Ничего ты не понимаешь во VIEW" - и все посмеялись.
Прошло время, VIEW в моём коде так и не прибавилось. Но вопрос остался: а вдруг с тех пор всё изменилось? Движки вышли новые. Поэтому я поднял MySQL 8.4.11 и PostgreSQL 17.11, залил побайтово одинаковые данные — миллион заказов, два миллиона позиций, 780 тысяч платежей — и прогнал основные сценарии один за другим.
Ниже — что из этого вышло. Стенд на Docker Compose открыт, все цифры воспроизводимы github.com/alex-frolov/mysql-postgresql-view-test.
Стенд и методика
Схема интернет-магазина: заказы, позиции, платежи, справочник продавцов. Даты за 21 месяц, окно запросов везде одно - июнь 2026-го, продавец с ID 42. Измерял серверное время, корневой `actual time` из `EXPLAIN ANALYZE` у MySQL, `Execution Time` у PostgreSQL. Два прогрева, семь замеров, медиана. Сравнивать абсолютные миллисекунды на другой машине смысла нет, важны только отношения внутри одинаковых запросов.
Простая view бесплатна
Обёртка над `orders` без агрегации - полторы-две миллисекунды в обеих СУБД, разброс в пределах шума, планы совпадают с прямыми запросами. MySQL сливает определение с запросом (алгоритм MERGE), PostgreSQL разворачивает view правилом перезаписи ещё до планировщика. Оптимизатор просто не видит, что вы обратились к представлению.
Переиспользуемый фильтр и стабильный контракт чтения ничего не стоят.
Один CAST — и минус индекс
Агрегирующая view дневной выручки:
Запрос поверх неё это ровно то, что напишет любой, фильтр по продавцу и по `day`, окно за июнь.
PostgreSQL отдаёт результат за 24.5 мс. Тот же результат прямым запросом к таблице — за 1.85 мс. Разница в тринадцать раз на ровном месте.
Первая версия объяснения была стандартной, "агрегат считается до фильтра, движок группирует всю таблицу". Открыл план и оказался неправ. Никакой агрегации всей таблицы, оба предиката проталкиваются под группировку, агрегируются те же 469 строк, что и в прямом запросе.
Разница в другой строчке плана: `Rows Removed by Filter: 9531`. Прямой запрос использует индекс `(merchant_id, created_at)` по обеим колонкам. А через view условие приходит как `CAST(created_at AS DATE) >= '2026-06-01'` — выражение вместо колонки, границей диапазона по индексу оно быть не может. Остаётся условие по продавцу, десять тысяч его заказов вытаскиваются из кучи по разбросанным страницам, и уже там фильтр выбрасывает 9531 строку. Итог, 8971 обращение к буферам против 440.
MySQL на том же месте тратит 4.85 мс против 2.16 мс это в два с лишим раза больше. Агрегирующая view там всегда TEMPTABLE, но `CAST` MySQL вычисляет прямо в индексе 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, прямой запрос делает 750000 точечных обращений по первичному ключу до группировки, а в каскаде join уезжает наверх, где от 750 тысяч строк после двух агрегаций остаётся 75.
У PostgreSQL каскад теряет параллелизм, а промежуточные 48 тысяч групп не влезают в `work_mem` 4 МБ — 30 МБ на диск за одно выполнение. За девять прогонов счётчик `temp_bytes` вырос на 278 МБ при том, что отчёт возвращает десять строк.
Дорогой ли каскад — вопрос не глубины, а формы join и настроек памяти. Предсказать заранее не выйдет — только проверять, и на боевых объёмах.
ORDER BY внутри view — тихий баг
`CREATE VIEW ... ORDER BY created_at DESC`, "чтобы точно было отсортировано". Обе СУБД отдадут `LIMIT 10` поверх такой view мгновенно, читая индекс задом наперёд. Но стандарт SQL не гарантирует порядок строк, которые отдаёт view, — документация MySQL говорит это прямым текстом. Сегодня план сложился удачно. Завтра оптимизатор пересчитает статистику — и код, полагавшийся на порядок, начнёт отдавать случайную десятку. Такой баг не падает, не логируется и обнаруживается по жалобе пользователя через полгода.
Материализованные view и вопрос про кеш
Чтение из материализованной view с уникальным индексом — 0.24 мс против 24.5 мс у живого агрегата: в сто раз быстрее. Цена на другой стороне: `REFRESH` на миллионе заказов занимает 1146 мс и всегда полный — инкрементального обновления в PostgreSQL нет.
Стократную разницу легко списать на кеш. Но кеша результатов не существует ни в одной из двух СУБД: MySQL убрал Query Cache в 8.0, в PostgreSQL его никогда не было. Проверка: тридцать повторов одного запроса подряд дают "плоскую" серию - медиана MySQL 4.37 мс, PostgreSQL 21.7 мс, каждый прогон считает заново. Материализованная view быстрее не потому, что "кешируется", а потому, что вычислять уже нечего.
Что чтение view делает с записью
Пятнадцатисекундные окна вставок пачками по восемьсот строк. С двумя фоновыми читателями, непрерывно гоняющими агрегирующую view, скорость записи просела на 7% в MySQL и на 16% в PostgreSQL. Контрольный сценарий заменил живую view сводной таблицей, которую писатель поддерживает инкрементальным upsert'ом, вставки не просели вообще: плюс 7% и плюс 4% к базовой линии, чистый шум фоновой нагрузки хоста.
Сама по себе view записи не мешает. Мешает постоянный читатель тяжёлого агрегата.
Цена зависит не от запроса, а от объёма
MySQL, view/direct
- 10 000 заказов - 1.6×
- 1 000 000 заказов - 2.3×
- 10 000 000 заказов - 2.5×
PostgreSQL, view/direct
- 10 000 заказов - 2.7×
- 1 000 000 заказов - 10.7×
- 10 000 000 заказов - 50.4×
MySQL держится ровно на всём диапазоне - index condition pushdown работает при любом объёме. Разрыв PostgreSQL растёт вместе с данными. А чтение из сводной таблицы, которую поддерживает пишущая сторона, от объёма не зависит вообще: 0.02 - 0.20 мс и на десяти тысячах, и на десяти миллионах. На верхней границе это в 1470 раз быстрее живой view в MySQL и в 4847 раз - в PostgreSQL.
До десятков тысяч строк выбор между живой view и сводной таблицей - вопрос вкуса. Ближе к миллиону это вопрос архитектуры, а за миллионом выбора уже нет.
Симптомы проблем с VIEW
Если тяжёлая view живёт в проде: процессор и временные объекты растут без внятной причины, отчёты деградируют ступенькой. Смотреть `Created_tmp_tables` и `Created_tmp_disk_tables` в MySQL, `temp_bytes` в `pg_stat_database` у PostgreSQL. Дальше — `EXPLAIN ANALYZE` по подозреваемому.
Полный разбор с планами запросов, замерами памяти и всеми цифрами - на frolov.guru, стенд воспроизводится из репозитория.