diff --git a/editorial/agent-rewrites/306.json b/editorial/agent-rewrites/306.json index 9cde539..d8457ba 100644 --- a/editorial/agent-rewrites/306.json +++ b/editorial/agent-rewrites/306.json @@ -3,5 +3,5 @@ "slug": "editorial-2019-07-practice-sql-indexes", "title": "Индекс есть, а запрос медленный: как читать план PostgreSQL", "excerpt": "Индекс не обязан ускорять каждый запрос. Разбираем Seq Scan, селективность, статистику и форму условия через EXPLAIN ANALYZE, а затем выбираем действие по данным.", - "contentHtml": "
Симптом выглядит как противоречие: на orders создали индекс по state, но список заказов всё ещё отвечает медленно. В плане виден Seq Scan, поэтому команда добавляет второй индекс или запрещает последовательное чтение. Цена ошибки растёт с каждой записью: индексы занимают место, замедляют INSERT и UPDATE, а задержка запроса остаётся.
Тезис простой: индекс — только один из вариантов доступа. PostgreSQL выбирает его не по факту существования, а по ожидаемой стоимости конкретного запроса на конкретных данных. Сначала сравните оценку строк с фактом, долю результата и форму предиката. После этого станет ясно, нужен ли индекс, свежая статистика, другой WHERE или правильный последовательный проход.
B-tree хранит ключи отдельно от таблицы и помогает найти часть строк по подходящему сравнению. Затем сервер часто обращается к таблице за остальными колонками и проверяет видимость строк. Если условие возвращает большую долю таблицы, точечных обращений становится много. Один последовательный проход может стоить дешевле.
\nПоэтому Seq Scan не равен ошибке. Он означает выбранный способ прочитать таблицу. Index Scan тоже не равен ускорению: поиск может вернуть мало строк, но повториться тысячи раз внутри Nested Loop или привести к дорогому чтению heap. Смотрите на строки, циклы, буферы и время всего плана.
У индекса есть и другая цена. PostgreSQL поддерживает его при изменении таблицы, а построение и хранение требуют ресурсов. Индекс по колонке с двумя значениями не становится полезным только потому, что колонка участвует в каждом запросе. Если значение встречается почти в каждой строке, такой ключ мало сокращает чтение.
\nНиже — самостоятельный пример для отдельной учебной базы. Он создаёт перекошенное распределение: ready встречается часто, а waiting — редко. Запросы имеют одинаковую форму и используют один индекс. Числа показывают устройство примера, а не результат измерения в production. Фактический план нужно снять на своей версии PostgreSQL и со своими данными.
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 и версия сервера.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
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 на узлах | Проверить соединение, кардинальность и объём входа, а не добавлять индекс вслепую |
Начните с верхнего узла и спускайтесь к источникам строк. В каждом важном узле сопоставьте rows=... с actual ... rows=.... Первое заметное расхождение часто объясняет последующие решения: планировщик ожидал маленький вход, получил большой и выбрал дорогой способ соединения или сортировки.
cost — условные единицы планировщика, а не миллисекунды. actual time появляется при выполнении запроса. При loops > 1 узел запускался многократно, поэтому его вклад нельзя читать по одной строке. BUFFERS показывает затронутые буферы и помогает отличить чтение страниц от вычислительной работы. Не выбирайте действие по одному слову Index или Seq.
EXPLAIN ANALYZE действительно запускает statement. Для SELECT это может быть тяжёлая нагрузка. Для UPDATE, DELETE и INSERT это ещё и изменение данных. Такие проверки проводят в безопасном контуре или в транзакции с откатом, если это разрешено условиями эксперимента. Обычный EXPLAIN подходит для первого безопасного снимка выбранного плана.
EXPLAIN (ANALYZE, BUFFERS)\nSELECT id, amount\nFROM work_orders\nWHERE state = 'waiting';\nСохраняйте SQL, параметры и версию сервера рядом с планом. План зависит от данных, статистики и настроек стоимости. Даже повторный запуск ANALYZE может слегка изменить оценки: статистика строится по выборке, а не по полному пересчёту каждой строки.
Планировщик не пересчитывает точное распределение строк перед каждым запросом. Он использует статистику таблицы. После массовой загрузки или смены значений оценка может устареть. Проверьте её точечно:
\nSELECT 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';\nANALYZE не обязан заменить Seq Scan на Index Scan. Его задача — дать планировщику более свежую основу для оценки. Если после обновления оценка стала близка к факту, а последовательное чтение осталось, это может быть правильный результат. Исправлять здесь нечего.
Отдельно проверьте форму условия. Индекс по created_at не равен индексу по date(created_at). Условие с функцией, неявным приведением типов или другим оператором может не дать ожидаемый путь. Для временного диапазона часто понятнее использовать полуоткрытые границы:
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Это учебная форма запроса. В рабочей системе границы должны соответствовать типу времени и бизнес-часовому поясу. Нельзя менять смысл фильтра ради красивого плана.
\nEXPLAIN, затем безопасный EXPLAIN (ANALYZE, BUFFERS) с теми же параметрами. Сохраните версию PostgreSQL и важные настройки стоимости.LIMIT могут честно требовать последовательного чтения.pg_stats, выполните целевой ANALYZE и повторите тот же запрос. Меняйте одну гипотезу за раз.Одинаковый SQL может получить другой план после изменения данных, кэша, настроек стоимости, версии сервера или параметров. Учебная таблица не доказывает production-эффект. Фактические миллисекунды из одного запуска не становятся SLA. Их нельзя обещать без повторяемого сценария и контекста.
\nНе отключайте enable_seqscan как постоянный ремонт. Временное отключение может показать альтернативный план для диагностики, но не делает данные селективнее и может ухудшить другие запросы. Не создавайте partial или expression index только потому, что название выглядит точным. Индекс имеет стоимость построения, хранения и поддержания.
Иногда итог расследования отрицательный: новый индекс не нужен. Если условие выбирает почти всю таблицу, а оценка близка к факту, Seq Scan может быть оптимальным. Тогда улучшайте границу выборки, добавляйте пагинацию или меняйте способ выдачи данных. Не маскируйте большой результат индексом.
Работа готова, когда сохранены два плана с одинаковым SQL и параметрами, названа первая подтверждённая причина, а действие повторно проверено на целевом контуре. В отчёте должны быть estimated rows, actual rows, loops, buffers, версия PostgreSQL и размер данных. Если индекс оставили, указана его цена для записи и диска. Если оставили Seq Scan, объяснено, почему он дешевле. Такой результат можно проверить снова, а не защищать по скриншоту.
Симптом выглядит как противоречие: на orders создали индекс по state, но список заказов всё ещё отвечает медленно. В плане виден Seq Scan, поэтому команда добавляет второй индекс или запрещает последовательное чтение. Цена ошибки растёт с каждой записью: индексы занимают место, замедляют INSERT и UPDATE, а задержка запроса остаётся.
Индекс — только один из вариантов доступа. PostgreSQL выбирает его не по факту существования, а по ожидаемой стоимости конкретного запроса на конкретных данных. Сначала сравните оценку строк с фактом, долю результата и форму предиката. После этого станет ясно, нужен ли индекс, свежая статистика, другой WHERE или правильный последовательный проход.
B-tree хранит ключи и указатели на строки отдельно от таблицы и помогает найти часть строк по подходящему сравнению. Затем сервер часто обращается к таблице за остальными колонками и проверяет видимость строк. Если условие возвращает большую долю таблицы, точечных обращений становится много. Один последовательный проход может стоить дешевле.
\nПоэтому Seq Scan не равен ошибке. Он означает выбранный способ прочитать таблицу. Index Scan тоже не равен ускорению: поиск может вернуть мало строк, но повториться тысячи раз внутри Nested Loop или привести к дорогому чтению таблицы. Смотрите на строки, циклы, буферы и время всего плана.
У индекса есть и другая цена. PostgreSQL поддерживает его при изменении таблицы, а построение и хранение требуют ресурсов. Индекс по колонке с двумя значениями не становится полезным только потому, что колонка участвует в каждом запросе. Если значение встречается почти в каждой строке, такой ключ мало сокращает чтение.
\nНиже — самостоятельный пример для отдельной учебной базы. Он создаёт перекошенное распределение: ready встречается часто, а waiting — редко. Запросы имеют одинаковую форму и используют один индекс. Числа показывают устройство примера, а не результат измерения в production. Фактический план нужно снять на своей версии PostgreSQL и со своими данными.
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 и версия сервера.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
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 на узлах | Проверить соединение, кардинальность и объём входа, а не добавлять индекс вслепую |
Начните с верхнего узла и спускайтесь к источникам строк. В каждом важном узле сопоставьте rows=... с actual ... rows=.... Первое заметное расхождение часто объясняет последующие решения: планировщик ожидал маленький вход, получил большой и выбрал дорогой способ соединения или сортировки.
cost — условные единицы планировщика, а не миллисекунды. actual time появляется при выполнении запроса. При loops > 1 узел запускался многократно, поэтому его вклад нельзя читать по одной строке. BUFFERS показывает затронутые буферы и помогает отличить чтение страниц от вычислительной работы. Не выбирайте действие по одному слову Index или Seq.
EXPLAIN ANALYZE действительно запускает statement. Для SELECT это может быть тяжёлая нагрузка. Для UPDATE, DELETE и INSERT это ещё и изменение данных. Такие проверки проводят в безопасном контуре или в транзакции с откатом, если это разрешено условиями эксперимента. Обычный EXPLAIN подходит для первого безопасного снимка выбранного плана.
EXPLAIN (ANALYZE, BUFFERS)\nSELECT id, amount\nFROM work_orders\nWHERE state = 'waiting';\nСохраняйте SQL, параметры и версию сервера рядом с планом. План зависит от данных, статистики и настроек стоимости. Даже повторный запуск ANALYZE может слегка изменить оценки: статистика строится по выборке, а не по полному пересчёту каждой строки.
Планировщик не пересчитывает точное распределение строк перед каждым запросом. Он использует статистику таблицы. После массовой загрузки или смены значений оценка может устареть. Проверьте её точечно:
\nSELECT 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';\nANALYZE не обязан заменить Seq Scan на Index Scan. Его задача — дать планировщику более свежую основу для оценки. Если после обновления оценка стала близка к факту, а последовательное чтение осталось, это может быть правильный результат. Исправлять здесь нечего.
Отдельно проверьте форму условия. Индекс по created_at не равен индексу по date(created_at). Условие с функцией, неявным приведением типов или другим оператором может не дать ожидаемый путь. Для временного диапазона часто понятнее использовать полуоткрытые границы:
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Это учебная форма запроса. В рабочей системе границы должны соответствовать типу времени и бизнес-часовому поясу. Нельзя менять смысл фильтра ради красивого плана.
\nEXPLAIN, затем безопасный EXPLAIN (ANALYZE, BUFFERS) с теми же параметрами. Сохраните версию PostgreSQL и важные настройки стоимости.LIMIT могут честно требовать последовательного чтения.pg_stats, выполните целевой ANALYZE и повторите тот же запрос. Меняйте одну гипотезу за раз.Одинаковый SQL может получить другой план после изменения данных, кэша, настроек стоимости, версии сервера или параметров. Учебная таблица не доказывает production-эффект. Фактические миллисекунды из одного запуска не становятся гарантией времени ответа. Их нельзя обещать без повторяемого сценария и контекста.
\nНе отключайте enable_seqscan как постоянный ремонт. Временное отключение может показать альтернативный план для диагностики, но не делает данные селективнее и может ухудшить другие запросы. Не создавайте partial или expression index только потому, что название выглядит точным. Индекс имеет стоимость построения, хранения и поддержания.
Иногда итог расследования отрицательный: новый индекс не нужен. Если условие выбирает почти всю таблицу, а оценка близка к факту, Seq Scan может быть оптимальным. Тогда улучшайте границу выборки, добавляйте пагинацию или меняйте способ выдачи данных. Не маскируйте большой результат индексом.
Работа готова, когда сохранены два плана с одинаковым SQL и параметрами, названа первая подтверждённая причина, а действие повторно проверено на целевом контуре. В отчёте должны быть estimated rows, actual rows, loops, buffers, версия PostgreSQL и размер данных. Если индекс оставили, указана его цена для записи и диска. Если оставили Seq Scan, объяснено, почему он дешевле. Такой результат можно проверить снова, а не защищать по скриншоту.
ANALYZE.