{ "index": 306, "slug": "editorial-2019-07-practice-sql-indexes", "title": "Индекс есть, а запрос медленный: как читать план PostgreSQL", "excerpt": "Индекс не обязан ускорять каждый запрос. Разбираем Seq Scan, селективность, статистику и форму условия через EXPLAIN ANALYZE, а затем выбираем действие по данным.", "contentHtml": "

Симптом выглядит как противоречие: на orders создали индекс по state, но список заказов всё ещё отвечает медленно. В плане виден Seq Scan, поэтому команда добавляет второй индекс или запрещает последовательное чтение. Цена ошибки растёт с каждой записью: индексы занимают место, замедляют INSERT и UPDATE, а задержка запроса остаётся.

\n

Тезис простой: индекс — только один из вариантов доступа. PostgreSQL выбирает его не по факту существования, а по ожидаемой стоимости конкретного запроса на конкретных данных. Сначала сравните оценку строк с фактом, долю результата и форму предиката. После этого станет ясно, нужен ли индекс, свежая статистика, другой WHERE или правильный последовательный проход.

\n

Механизм: индекс не обещает короткий путь

\n

B-tree хранит ключи отдельно от таблицы и помогает найти часть строк по подходящему сравнению. Затем сервер часто обращается к таблице за остальными колонками и проверяет видимость строк. Если условие возвращает большую долю таблицы, точечных обращений становится много. Один последовательный проход может стоить дешевле.

\n

Поэтому Seq Scan не равен ошибке. Он означает выбранный способ прочитать таблицу. Index Scan тоже не равен ускорению: поиск может вернуть мало строк, но повториться тысячи раз внутри Nested Loop или привести к дорогому чтению heap. Смотрите на строки, циклы, буферы и время всего плана.

\n

У индекса есть и другая цена. PostgreSQL поддерживает его при изменении таблицы, а построение и хранение требуют ресурсов. Индекс по колонке с двумя значениями не становится полезным только потому, что колонка участвует в каждом запросе. Если значение встречается почти в каждой строке, такой ключ мало сокращает чтение.

\n

Учебный пример: частое и редкое значение

\n

Ниже — самостоятельный пример для отдельной учебной базы. Он создаёт перекошенное распределение: ready встречается часто, а waiting — редко. Запросы имеют одинаковую форму и используют один индекс. Числа показывают устройство примера, а не результат измерения в production. Фактический план нужно снять на своей версии PostgreSQL и со своими данными.

\n
CREATE TABLE work_orders (\n  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  state text NOT NULL,\n  amount integer NOT NULL,\n  created_at timestamp NOT NULL\n);\n\nINSERT INTO work_orders (state, amount, created_at)\nSELECT CASE WHEN n % 100 = 0 THEN 'waiting' ELSE 'ready' END,\n       100 + (n % 500),\n       timestamp '2019-07-01' + (n % 31) * interval '1 day'\nFROM generate_series(1, 1000000) AS n;\n\nCREATE INDEX work_orders_state_idx ON work_orders (state);\nANALYZE work_orders;\n\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT id, amount FROM work_orders WHERE state = 'ready';\n\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT id, amount FROM work_orders WHERE state = 'waiting';
\n

Для ready условие оставляет почти всю таблицу. Индекс должен привести к строкам, а затем серверу всё равно придётся прочитать большую их часть. Для waiting результат меньше, поэтому индексный путь может оказаться выгоднее. Фиксированной границы вроде «индекс полезен до пяти процентов» нет. На выбор влияют размер строки, расположение страниц, кэш, LIMIT, нужные колонки, стоимость I/O и версия сервера.

\n
\"Селективность
Один индекс даёт два разных результата: редкое значение сокращает чтение, частое может сделать последовательный проход дешевле.
\n

Симптом → причина → проверка → действие

\n
Диагностика запроса по наблюдаемому плану
СимптомПричинаПроверкаДействие
Seq Scan для частого значенияУсловие возвращает большую долю таблицыСравнить rows с размером таблицы; запустить контраст для редкого значенияНе форсировать индекс; уменьшить выборку, добавить пагинацию или оставить план
Оценка сильно расходится с actual rowsСтатистика устарела или плохо описывает распределениеПосмотреть pg_stats, выполнить целевой ANALYZE и повторить планСначала обновить статистику, затем оценить дальнейшее изменение
Индекс есть, но в условии функция или castФорма предиката не совпадает с ключомСверить выражение в WHERE с определением индексаПереписать условие без потери смысла или обоснованно создать expression index
Частичный индекс не выбранПланировщик не может доказать соответствие predicateСверить predicate в pg_indexes с точным SQLСделать условие совместимым или удалить лишнюю структуру
Index Scan медленный внутри Nested LoopДешёвый одиночный поиск многократно повторяетсяПроверить actual rows, actual time и loops на узлахПроверить соединение, кардинальность и объём входа, а не добавлять индекс вслепую
\n

Как читать EXPLAIN ANALYZE

\n

Начните с верхнего узла и спускайтесь к источникам строк. В каждом важном узле сопоставьте rows=... с actual ... rows=.... Первое заметное расхождение часто объясняет последующие решения: планировщик ожидал маленький вход, получил большой и выбрал дорогой способ соединения или сортировки.

\n

cost — условные единицы планировщика, а не миллисекунды. actual time появляется при выполнении запроса. При loops > 1 узел запускался многократно, поэтому его вклад нельзя читать по одной строке. BUFFERS показывает затронутые буферы и помогает отличить чтение страниц от вычислительной работы. Не выбирайте действие по одному слову Index или Seq.

\n

EXPLAIN ANALYZE действительно запускает statement. Для SELECT это может быть тяжёлая нагрузка. Для UPDATE, DELETE и INSERT это ещё и изменение данных. Такие проверки проводят в безопасном контуре или в транзакции с откатом, если это разрешено условиями эксперимента. Обычный EXPLAIN подходит для первого безопасного снимка выбранного плана.

\n
EXPLAIN (ANALYZE, BUFFERS)\nSELECT id, amount\nFROM work_orders\nWHERE state = 'waiting';
\n

Сохраняйте SQL, параметры и версию сервера рядом с планом. План зависит от данных, статистики и настроек стоимости. Даже повторный запуск ANALYZE может слегка изменить оценки: статистика строится по выборке, а не по полному пересчёту каждой строки.

\n

Статистика и форма условия

\n

Планировщик не пересчитывает точное распределение строк перед каждым запросом. Он использует статистику таблицы. После массовой загрузки или смены значений оценка может устареть. Проверьте её точечно:

\n
SELECT attname, n_distinct, most_common_vals, most_common_freqs\nFROM pg_stats\nWHERE schemaname = 'public'\n  AND tablename = 'work_orders'\n  AND attname = 'state';\n\nANALYZE work_orders;\n\nSELECT indexname, indexdef\nFROM pg_indexes\nWHERE schemaname = 'public'\n  AND tablename = 'work_orders';
\n

ANALYZE не обязан заменить Seq Scan на Index Scan. Его задача — дать планировщику более свежую основу для оценки. Если после обновления оценка стала близка к факту, а последовательное чтение осталось, это может быть правильный результат. Исправлять здесь нечего.

\n

Отдельно проверьте форму условия. Индекс по created_at не равен индексу по date(created_at). Условие с функцией, неявным приведением типов или другим оператором может не дать ожидаемый путь. Для временного диапазона часто понятнее использовать полуоткрытые границы:

\n
SELECT id\nFROM orders\nWHERE created_at >= timestamp '2019-07-10 00:00:00'\n  AND created_at <  timestamp '2019-07-11 00:00:00';
\n

Это учебная форма запроса. В рабочей системе границы должны соответствовать типу времени и бизнес-часовому поясу. Нельзя менять смысл фильтра ради красивого плана.

\n

Порядок действий

\n
  1. Запишите точный SQL, реальные параметры, нужный объём результата и симптом: задержку, чтение буферов или рост нагрузки.
  2. Снимите обычный EXPLAIN, затем безопасный EXPLAIN (ANALYZE, BUFFERS) с теми же параметрами. Сохраните версию PostgreSQL и важные настройки стоимости.
  3. Прочитайте дерево сверху вниз. Сверьте estimated rows, actual rows, loops и buffers. Отметьте первый узел, где оценка перестала быть правдоподобной.
  4. Проверьте долю результата. Частое значение, широкий диапазон и выборка без LIMIT могут честно требовать последовательного чтения.
  5. Проверьте статистику через pg_stats, выполните целевой ANALYZE и повторите тот же запрос. Меняйте одну гипотезу за раз.
  6. Сверьте ключ индекса, оператор, cast, функцию, partial predicate, сортировку и список возвращаемых колонок. Только теперь выбирайте переписывание запроса, другой индекс или отсутствие изменения.
  7. Повторите замер на целевом контуре и проверьте цену решения для записи, диска и соседних запросов.
\n

Ограничения и отрицательный путь

\n

Одинаковый SQL может получить другой план после изменения данных, кэша, настроек стоимости, версии сервера или параметров. Учебная таблица не доказывает production-эффект. Фактические миллисекунды из одного запуска не становятся SLA. Их нельзя обещать без повторяемого сценария и контекста.

\n

Не отключайте enable_seqscan как постоянный ремонт. Временное отключение может показать альтернативный план для диагностики, но не делает данные селективнее и может ухудшить другие запросы. Не создавайте partial или expression index только потому, что название выглядит точным. Индекс имеет стоимость построения, хранения и поддержания.

\n

Иногда итог расследования отрицательный: новый индекс не нужен. Если условие выбирает почти всю таблицу, а оценка близка к факту, Seq Scan может быть оптимальным. Тогда улучшайте границу выборки, добавляйте пагинацию или меняйте способ выдачи данных. Не маскируйте большой результат индексом.

\n

Проверяемый критерий готовности

\n

Работа готова, когда сохранены два плана с одинаковым SQL и параметрами, названа первая подтверждённая причина, а действие повторно проверено на целевом контуре. В отчёте должны быть estimated rows, actual rows, loops, buffers, версия PostgreSQL и размер данных. Если индекс оставили, указана его цена для записи и диска. Если оставили Seq Scan, объяснено, почему он дешевле. Такой результат можно проверить снова, а не защищать по скриншоту.

\n

Проверяемые источники

\n" }