{ "index": 305, "slug": "editorial-2019-07-mechanism-sql-indexes", "title": "EXPLAIN ANALYZE: как понять, почему PostgreSQL выбрал этот план", "excerpt": "Медленный SQL не всегда требует нового индекса. Разбираем дерево плана, сравниваем оценку с фактом, проверяем статистику и выбираем действие по данным, а не по названию узла.", "contentHtml": "
Запрос к каталогу внезапно стал медленным. В тикете появляется короткий вывод: «PostgreSQL выбрал Seq Scan, нужен индекс». Команда добавляет ключ, но задержка не исчезает. Записи и обновления становятся тяжелее, диск занят больше, а причина остаётся. Цена ошибки — новый объект в базе, который постоянно поддерживают, но не сокращает работу нужного запроса.
\nПлан запроса не обещает использовать индекс. Он описывает расчёт планировщика: сколько строк, страниц и операций тот ожидает увидеть на каждом пути. EXPLAIN показывает расчёт. EXPLAIN ANALYZE выполняет запрос и добавляет наблюдаемый факт. Диагноз начинается там, где эти два слоя расходятся.
Планировщик собирает дерево операторов. Нижний узел читает таблицу или индекс. Его результат получает родительский узел. Дальше сервер фильтрует строки, соединяет наборы, сортирует их или считает агрегат. Верхний узел выдаёт ответ клиенту. Медленный верхний узел не обязательно является причиной: он может ждать большой поток строк от ребёнка.
\nSeq Scan читает таблицу последовательно. Index Scan ищет записи через индекс, а затем часто обращается к таблице за остальными колонками. Если условие возвращает большую долю строк, последовательное чтение может стоить дешевле. Если условие редкое и есть подходящий ключ, индекс может резко уменьшить объём чтения. Оба решения могут быть правильными для одной таблицы и разных параметров.
Индекс хранит дополнительную структуру. INSERT и UPDATE должны поддерживать её. Индекс занимает место и может увеличивать стоимость записи. Поэтому наличие индекса отвечает только на вопрос «есть ли кандидат», но не на вопрос «выгоден ли он для этого запроса».
\nОбычный EXPLAIN строит план без выполнения statement. В строке cost=a..b указаны условные единицы планировщика, а не миллисекунды. rows — ожидаемое число строк на конкретном узле. width — оценка средней ширины строки.
EXPLAIN ANALYZE запускает statement. Он добавляет actual time, actual rows и loops. При loops=1 это значения одного выполнения. При повторениях actual time и actual rows показываются как средние на одно выполнение узла. Если внутренний узел получил loops=10000, умножьте время на число повторов, а его малое среднее не принимайте за стоимость единственного вызова.
Опция BUFFERS показывает обращения плана к буферам PostgreSQL: например, shared hit, read или written. Сопоставляйте эти счётчики с Rows Removed by Filter: так видно, где накопилось чтение, а где строки отсекаются поздней проверкой. Это не чистый замер диска и не замена профилю приложения. План нужно сохранять вместе с SQL, параметрами, версией PostgreSQL и настройками стоимости. Иначе сравнение до и после легко выдаёт ложный вывод.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Seq Scan при частом значении | Предикат оставляет большую часть таблицы | Сравнить actual rows с размером таблицы и повторить запрос с редким значением | Не форсировать индекс; проверить объём результата и форму запроса |
rows сильно меньше actual rows | Статистика устарела или не описывает распределение | Снять план с фактами, посмотреть pg_stats, проверить момент последнего анализа | Обновить статистику и повторить тот же план |
| Есть индекс, но условие содержит функцию | Индекс хранит исходное значение, а запрос вычисляет другое | Сопоставить ключ индекса с выражением в WHERE | Переписать условие диапазоном или обоснованно создать expression index |
Есть Index Cond, но фильтр удаляет почти всё | Индекс сузил доступ недостаточно, отбор произошёл поздно | Сравнить строки после доступа и после Filter | Проверить составной ключ, порядок колонок и альтернативный запрос |
Внутренний узел быстрый, но loops велик | Nested loop повторяет работу тысячи раз | Умножить вклад узла на число запусков и посмотреть BUFFERS | Исследовать порядок соединения и объём промежуточного набора |
Следующий пример предназначен только для отдельной лабораторной базы. Он не содержит production-измерений и не задаёт ожидаемых миллисекунд. Таблица в примере будет иметь миллион строк: статус ready встречается часто, а waiting — редко. Оба запроса получают доступ к одному и тому же индексу, но планировщик не обязан его выбирать. Разный план объясняется долей результата, а не тем, что индекс «сломался».
CREATE TABLE work_orders (\n id bigint PRIMARY KEY,\n state text NOT NULL,\n created_at timestamp NOT NULL,\n amount integer NOT NULL\n);\n\nINSERT INTO work_orders (id, state, created_at, amount)\nSELECT n,\n CASE WHEN n % 100 = 0 THEN 'waiting' ELSE 'ready' END,\n TIMESTAMP '2019-07-10 00:00:00' + (n % 86400) * INTERVAL '1 second',\n n % 10000\nFROM generate_series(1, 1000000) AS series(n);\n\nCREATE INDEX work_orders_state_idx\n ON work_orders (state);\n\nANALYZE work_orders;\n\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT id, amount\nFROM work_orders\nWHERE state = 'waiting';\n\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT id, amount\nFROM work_orders\nWHERE state = 'ready';\nСначала смотрите, сколько строк реально возвращает каждый запрос. Для редкого waiting индекс может сократить чтение. Для частого ready серверу может быть дешевле пройти таблицу один раз. Не переносите результат этой фикстуры на свой сервер: ширина строк, кэш, физический порядок данных, настройки стоимости и версия меняют расчёт.
Если планировщик ожидал десять строк, а получил сто тысяч, ошибка оценки может повлиять и на последующие JOIN. Nested loop, выбранный для маленького набора, становится дорогим при большом фактическом наборе. Ищите первый узел снизу, где rows перестал быть похож на actual rows. Не исправляйте каждый верхний узел по очереди.
Планировщик не пересчитывает точную долю значений перед каждым SELECT. Он использует статистику, которую собирает ANALYZE. После массовой загрузки или изменения распределения статусов оценка может отстать от данных. Проверьте её отдельно:
SELECT attname, n_distinct, most_common_vals, most_common_freqs\nFROM pg_stats\nWHERE schemaname = 'public'\n AND tablename = 'work_orders'\n AND attname IN ('state', 'created_at');\n\nANALYZE work_orders;\nANALYZE не обещает сменить Seq Scan на Index Scan. Он обновляет вход для следующего расчёта. Если оценка и факт после этого сблизились, а последовательное чтение осталось, это нормальный результат. Индекс может быть невыгоден для массового ответа.
Форма условия тоже важна. Индекс на created_at и условие created_at::date = DATE '2019-07-10' не являются одной и той же операцией доступа. Без подходящего expression index планировщик может не использовать обычный ключ так, как ожидает автор запроса. Для диапазона можно проверить эквивалентную по смыслу форму:
SELECT id, amount\nFROM work_orders\nWHERE created_at >= TIMESTAMP '2019-07-10 00:00:00'\n AND created_at < TIMESTAMP '2019-07-11 00:00:00';\nЭто учебное преобразование требует проверки часового пояса и границ периода. Скорость не оправдывает изменение смысла даты. Если запрос всегда возвращает почти всю таблицу, новый ключ не решит проблему объёма. Тогда проверяют LIMIT, пагинацию, пакетную обработку или отдельную витрину.
EXPLAIN ANALYZE выполняет statement. Для SELECT это всё равно может означать тяжёлую нагрузку. Для INSERT, UPDATE и DELETE выполнение меняет данные. Учебный изменяющий запрос запускают только в разрешённом контуре и внутри транзакции с откатом:
BEGIN;\n\nEXPLAIN (ANALYZE, BUFFERS)\nUPDATE work_orders\nSET amount = amount + 1\nWHERE state = 'waiting';\n\nROLLBACK;\nОткат отменяет изменения, выполненные PostgreSQL в этой транзакции, но не отменяет нагрузку от выполнения и не компенсирует побочные эффекты за пределами транзакции. Для чужой production-базы сначала согласуйте время, объём и безопасный способ измерения. Если такой запуск запрещён, используйте обычный EXPLAIN, снимок с реплики или тестовый контур. Не объявляйте неполный план доказательством причины.
EXPLAIN, затем разрешённый и ограниченный по нагрузке EXPLAIN (ANALYZE, BUFFERS). Для изменяющего statement заранее определите транзакционную границу.rows и actual rows на каждом ключевом узле. При loops > 1 учтите повторения.Index Cond и Filter. Проверьте, что именно отсекает индекс и сколько строк остаётся для поздней проверки.ANALYZE, если это безопасно, и повторите тот же запрос.Одинаковый SQL может получить другой план после изменения данных, настроек памяти, стоимости I/O, версии PostgreSQL или параметров. cost не равен времени ответа приложения. Один запуск не описывает распределение задержек. Учебная фикстура не доказывает production-результат. Принудительное отключение enable_seqscan может показать альтернативу для исследования, но не лечит статистику и не является постоянным исправлением.
Диагностика готова, когда сохранены точный запрос и контекст, подтверждён первый существенный разрыв между оценкой и фактом либо объяснена высокая доля результата, а выбранное действие повторно проверено тем же сценарием. Для изменения нужен сравнимый план до и после. Если Seq Scan остался, но он честно дешевле и возвращает нужный объём данных, готовый результат — оставить его и зафиксировать почему.