Операторы плана PostgreSQL: когда это нормально, а когда тревожно
Практичный справочник по узлам EXPLAIN: Seq Scan, Index Scan, Bitmap Heap Scan, Materialize, Sort, Hash Join и строке Rows Removed by Filter.
Когда разработчик впервые открывает EXPLAIN ANALYZE, взгляд почти всегда цепляется за одно знакомое слово: Seq Scan. После этого часто следует поспешный вывод: план плохой, нужен индекс. Проблема в том, что отдельный оператор плана сам по себе не бывает хорошим или плохим. Он бывает уместным или неуместным для конкретного объёма данных, фильтра, сортировки и ожидаемого результата.
Эта статья — не справочник по всем узлам PostgreSQL, а практическая шпаргалка. Что такое Scan, какие его виды встречаются чаще всего, когда они нормальны, когда тревожны, что означает Materialize, почему появляется Rows Removed by Filter и на какие места в плане смотреть в первую очередь.
Сначала главное: оператор плана — это не диагноз
План надо читать не как список ошибок, а как рассказ PostgreSQL о том, как он решил добыть строки. Если таблица маленькая, Seq Scan может быть лучшим вариантом. Если выборка большая, Index Scan может оказаться хуже полного прохода. Если план показывает Materialize, это не обязательно лишняя работа: иногда это осознанный способ не перечитывать один и тот же набор строк.
Поэтому удобный стартовый вопрос не “почему здесь такой оператор?”, а “почему именно этот способ оказался дешевле остальных по мнению планировщика?”.
Что такое Scan
Scan — это способ чтения строк из таблицы или из индекса. Почти любой план начинается именно со сканов: PostgreSQL должен сначала откуда-то получить строки, а уже потом сортировать, соединять, агрегировать и фильтровать.
На практике чаще всего встречаются Seq Scan, Index Scan, Index Only Scan, Bitmap Index Scan и Bitmap Heap Scan. Иногда ещё Parallel Seq Scan, когда большую таблицу читают несколько воркеров.
Seq Scan
Seq Scan, или Sequential Scan, означает последовательное чтение таблицы целиком. PostgreSQL идёт по heap-страницам одну за другой и проверяет условие для каждой строки.
explain analyze
select *
from app_logs
where level = 'error';Когда это нормально: таблица маленькая; запрос и так забирает заметную долю строк; подходящего индекса нет; чтение почти всей таблицы дешевле, чем множество случайных переходов через индекс.
Когда это тревожно: таблица большая; условие очень выборочное; нужный индекс есть, но не используется; в плане видно, что возвращается мало строк, а Rows Removed by Filter очень велик.
Ключевая мысль: Seq Scan — не синоним плохого плана. Для таблицы на несколько тысяч строк он часто абсолютно адекватен. Плохо не слово Seq Scan, а ситуация, когда ради пяти строк перечитывается огромная таблица.
Index Scan
Index Scan означает, что PostgreSQL сначала идёт в индекс, находит подходящие записи, а затем по ссылкам читает соответствующие строки из таблицы.
explain analyze
select order_id, created_at, total
from orders
where customer_id = 42;Когда это нормально: выборка маленькая; индекс хорошо совпадает с фильтром; запросу нужна сортировка, которую индекс уже даёт; чтение из таблицы по найденным TID остаётся умеренным.
Когда это тревожно: через индекс поднимается слишком большая доля таблицы; возникает много случайных heap-read; план показывает, что проще было бы прочитать таблицу последовательно. На больших выборках Index Scan может оказаться дороже Seq Scan именно из-за разбросанных обращений к heap.
Поэтому наличие индекса ещё не означает, что Index Scan обязан появляться всегда. Если запрос возвращает 30-40 процентов таблицы, последовательное чтение может быть разумнее.
Index Only Scan
Index Only Scan похож на Index Scan, но в идеале позволяет ответить на запрос данными только из индекса, без чтения строк таблицы. Это работает, когда запросу нужны только колонки из индекса и visibility map позволяет не ходить в heap для проверки видимости.
Когда это нормально: у вас покрывающий индекс; таблица не слишком часто меняется; в EXPLAIN ANALYZE нет большого числа Heap Fetches.
Когда это тревожно: план называется Index Only Scan, но PostgreSQL всё равно часто идёт в heap. Тогда выигрыша почти нет, и название вводит в заблуждение. Для таких случаев важно смотреть не только имя узла, но и дополнительные строки плана.
Bitmap Index Scan и Bitmap Heap Scan
Эта пара обычно работает вместе. Сначала Bitmap Index Scan собирает набор подходящих ссылок на строки, затем Bitmap Heap Scan читает нужные страницы таблицы пачками. Это попытка снизить случайные чтения по сравнению с обычным Index Scan.
Когда это нормально: запрос возвращает не считанные строки, но и не половину таблицы; надо объединить несколько индексов; выгодно сначала собрать карту страниц, а потом читать heap более упорядоченно.
Когда это тревожно: bitmap-путь выбран для слишком широкой выборки; много recheck; фактически приходится перечитывать слишком много страниц, и выгода исчезает.
Практически это часто хороший компромиссный сценарий: не совсем точечный доступ, но и не сплошной проход по таблице.
Parallel Seq Scan
Parallel Seq Scan — это тот же последовательный проход, только разбитый между несколькими воркерами. Для крупных аналитических выборок это часто хорошая новость, а не повод паниковать.
Когда это нормально: таблица большая; нужно прочитать значимую её часть; запрос хорошо параллелится; накладные расходы на запуск воркеров окупаются.
Когда это тревожно: параллелизм включился, но выгоды почти нет; данные слишком маленькие; основной узкий момент вообще не в чтении таблицы, а дальше — например, в сортировке или join.
Materialize
Materialize не читает таблицу сам по себе. Это вспомогательный узел, который сохраняет результат дочернего узла, чтобы использовать его повторно без повторного вычисления или повторного чтения.
Часто его можно увидеть внутри Nested Loop, когда внутреннюю сторону соединения выгоднее один раз прочитать и затем многократно переиспользовать.
Когда это нормально: внутренний набор строк небольшой или умеренный; его действительно будут читать несколько раз; материализация экономит повторные обращения к индексу или таблице.
Когда это тревожно: материализуется большой набор; он не помещается в память; появляются лишние расходы или spill в disk-операциях выше по плану. Сам по себе Materialize — не ошибка, но он может подсветить, что структура join получилась тяжёлой.
Sort
Sort означает явную сортировку строк. Это нормально для ORDER BY, Merge Join, некоторых агрегатов и оконных функций.
Когда это нормально: сортируемый набор невелик; сортировка действительно нужна; она выполняется в памяти.
Когда это тревожно: в EXPLAIN ANALYZE видно external merge или использование disk; сортировка идёт над огромным набором, который можно было сократить раньше фильтром или индексом; сортировка делается только потому, что подходящего индекса под порядок нет.
Nested Loop, Hash Join, Merge Join
Эти узлы уже не про чтение одной таблицы, а про соединение наборов строк. Но их адекватность тесно связана со сканами под ними.
Nested Loop хорош, когда внешняя сторона маленькая, а внутренняя быстро ищется по индексу. Плох он тогда, когда внешних строк много и индексного доступа на внутренней стороне нет: тогда маленькая проблема умножается на большое число проходов.
Hash Join хорош для соединений по равенству, когда один набор можно захешировать, а второй по нему проверять. Тревожный сигнал — большие хеш-таблицы и уход в batches, если памяти не хватает.
Merge Join хорош, когда обе стороны уже отсортированы или их выгодно отсортировать один раз. Плохим он становится, если ради него приходится слишком дорого сортировать большие наборы.
Rows Removed by Filter
Rows Removed by Filter — это не отдельный оператор, а диагностическая строка в EXPLAIN ANALYZE. Она показывает, сколько строк было прочитано узлом, но потом отброшено обычным фильтром.
Seq Scan on app_logs
Filter: (level = 'error')
Rows Removed by Filter: 1287345Когда это нормально: таблица маленькая; фильтр дешёвый; запрос действительно обязан посмотреть почти всё; читаемый объём не критичен.
Когда это тревожно: строк отброшено на порядки больше, чем возвращено; это происходит на большой таблице; условие могло бы оказаться в Index Cond, но вместо этого осталось просто Filter. В такой ситуации PostgreSQL сначала тащит много лишних строк, а потом выкидывает их.
Полезный приём: всегда сравнивайте Rows Removed by Filter с фактическим числом возвращённых строк. Если вернули 20 строк, а выбросили 5 миллионов, это уже не косметика.
Filter, Index Cond, Recheck Cond: куда именно попало условие
Одна из самых полезных привычек — смотреть не только на название узла, но и на подписи под ним.
- Index Cond означает, что условие участвует в индексном доступе. Это обычно хороший знак.
- Filter означает, что строки сначала прочитали, а потом уже проверили условие.
- Recheck Cond часто появляется рядом с bitmap- или lossy-сценариями: условие приходится дополнительно перепроверять после первичного отбора.
Именно здесь часто видно реальную причину проблемы. Снаружи вы видите вроде бы “индексный” план, а внутри оказывается, что значимая часть условий всё равно висит в Filter.
Короткий практический справочник
- Seq Scan: нормально для маленьких таблиц и широких выборок; плохо для огромной таблицы при очень точечном запросе.
- Index Scan: нормально для узкой выборки и полезного порядка; плохо, если через индекс поднимается слишком много разбросанных строк.
- Index Only Scan: хорошо, когда запрос реально закрывается индексом; сомнительно, если Heap Fetches всё равно велики.
- Bitmap Heap Scan: хорошо для средней селективности и комбинирования индексов; хуже, когда страниц всё равно читается слишком много.
- Materialize: нормально при повторном использовании небольшого набора; тревожно при крупном промежуточном результате.
- Sort: нормально, если сортировка нужна и помещается в память; плохо, если она огромная и пишет во временные файлы.
- Nested Loop: хорошо при маленькой внешней стороне и быстром поиске внутри; плохо при большом числе повторных проходов.
- Hash Join: хорошо для equality join; тревожно, если hash не помещается в память.
- Rows Removed by Filter: нормально в умеренных объёмах; плохо, когда это симптом массового чтения лишних строк.
Что смотреть в плане в первую очередь
- Сколько строк узел реально вернул и сколько раз он выполнился: actual rows и loops.
- Какой объём данных прочитан до фильтрации.
- Где находится условие: в Index Cond или только в Filter.
- Не сортируется ли огромный набор слишком поздно.
- Не повторяется ли дорогой доступ много раз внутри Nested Loop.
- Нет ли больших Rows Removed by Filter, Heap Fetches или признаков работы через disk.
Вывод
Самая полезная привычка при чтении EXPLAIN ANALYZE — перестать оценивать узлы по названию. Seq Scan не всегда плох, Index Scan не всегда хорош, а Materialize не всегда лишний. Адекватность оператора определяется контекстом: размером таблицы, долей выборки, качеством индекса, местом фильтра и количеством повторных проходов.
Если нужен один короткий критерий, то он такой: хороший план читает примерно то, что действительно нужно запросу. Плохой план сначала тащит слишком много, а затем долго сортирует, фильтрует и выбрасывает лишнее.