diff --git a/editorial/agent-rewrites/304.json b/editorial/agent-rewrites/304.json index 70f8552..66950b1 100644 --- a/editorial/agent-rewrites/304.json +++ b/editorial/agent-rewrites/304.json @@ -1,7 +1,7 @@ { "index": 304, "slug": "editorial-2019-07-field-sql-indexes", - "title": "Индекс есть, Seq Scan остался: как проверить форму запроса в PostgreSQL", - "excerpt": "Индекс на created_at не гарантирует быстрый запрос за день. Разбираем селективность, статистику, выражения и partial index через один проверяемый маршрут.", - "contentHtml": "
В таблице уже есть B-tree индекс на created_at, но выборка за один день всё равно читает таблицу целиком. Добавление второго индекса не меняет план. Запрос остаётся медленным на большой таблице, а каждая новая структура увеличивает размер базы и цену INSERT/UPDATE. Ошибка здесь стоит дороже, чем одна задержка: команда начинает подбирать индексы по названию узла, не проверив, что именно ищет запрос.
Главный тезис простой: индекс помогает только тогда, когда планировщик видит подходящую форму условия, правильно оценивает долю строк и считает путь дешевле полного чтения. Поэтому сначала нужен точный SQL и план, потом проверка статистики и предиката, и только затем DDL. Seq Scan сам по себе не доказывает проблему.
Планировщик строит дерево узлов. Нижние узлы получают строки из таблицы или индекса. Верхние узлы фильтруют, сортируют, соединяют и ограничивают результат. Для каждого узла PostgreSQL показывает оценку стоимости и приблизительное число строк. Стоимость — внутренняя величина планировщика, а не миллисекунды ответа.
\nИндексный путь не бесплатен. Сначала сервер читает индекс, затем находит страницы таблицы и получает строки. Если условие возвращает большую часть таблицы, последовательное чтение может оказаться дешевле. Если запрос возвращает мало строк, индекс часто выигрывает. Решение зависит от размера таблицы, расположения страниц, кэша, сортировки, LIMIT и статистики.
EXPLAIN (ANALYZE, BUFFERS)\nSELECT id, created_at, 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'\nORDER BY created_at DESC\nLIMIT 50;\nВ учебном примере запрос читает один день, использует полуоткрытый интервал и возвращает первые 50 строк. Это не замер production. Реальные значения cost, rows, actual time и Buffers нужно получать на совместимом тестовом контуре с данными, близкими к рабочим.
Индекс хранит значения исходного столбца. Запрос ниже вычисляет выражение для каждой строки:
\nCREATE INDEX work_orders_created_idx\nON work_orders (created_at);\n\nSELECT id\nFROM work_orders\nWHERE created_at::date = DATE '2019-07-10';\nПредикат сравнивает created_at::date, а ключ индекса содержит created_at. Это разные формы. Планировщик может применить индекс на выражении, если такой индекс существует, но обычный индекс на исходной колонке не обязан подходить для этого условия.
Сначала нужно определить смысл «за один день». Для timestamp without time zone можно задать границы прямо. Для timestamptz границы зависят от часового пояса бизнеса. Механическая замена cast на диапазон без выбора зоны способна вернуть записи соседнего календарного дня или пропустить часть нужных записей.
-- Учебный вариант для timestamp without time zone.\nSELECT id\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\n-- Вариант для устойчивого query shape, если выражение менять нельзя.\nCREATE INDEX work_orders_created_date_idx\nON work_orders ((created_at::date));\nДиапазон сохраняет исходный ключ и обычно проще сопоставляется с B-tree. Индекс на выражении имеет другой компромисс: PostgreSQL вычисляет выражение и поддерживает его при изменениях строк. Он оправдан, если один и тот же выраженный предикат повторяется и переписать его без изменения смысла нельзя.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Индекс есть, но Seq Scan возвращает почти всю таблицу | Низкая селективность | Сравнить rows и actual rows, проверить долю результата | Оставить полный scan или уменьшить объём запроса; не добавлять дубликат |
| Оценка — сотни строк, факт — сотни тысяч | Устаревшая или грубая статистика | Найти первый узел расхождения и посмотреть pg_stats | Выполнить целевой ANALYZE, затем повторить тот же план |
created_at::date при ключе (created_at) | Запрос использует выражение, индекс — исходное значение | Сопоставить indexdef с Index Cond и Filter | Выбрать корректный диапазон или обоснованный expression index |
| Partial index не участвует в запросе | Планировщик не доказал его predicate для данного SQL | Сравнить условие индекса и запрос до подстановки параметров | Сделать условие доказуемым, сменить индекс или отказаться от partial index |
Таблица задаёт порядок проверки, но не выбирает действие автоматически. Например, частое значение state = 'ready' может возвращать 90 процентов строк. Индекс на таком значении не обязан ускорить чтение. Если экрану нужны только первые 20 записей, проверяйте сортировку и LIMIT. Если экран запрашивает весь экспорт, индекс не уменьшит объём результата.
Ключевой сигнал — не название scan, а первое существенное расхождение между ожидаемыми и фактическими строками. Если узел ожидал 100 строк, а получил 100 000, планировщик мог выбрать неподходящий путь из-за неверной оценки. Причины включают изменения распределения, недостаточную выборку и зависимость между столбцами.
\nSELECT schemaname, tablename, indexname, indexdef\nFROM pg_indexes\nWHERE schemaname = 'public'\n AND tablename = 'work_orders';\n\nSELECT attname, n_distinct, most_common_vals, most_common_freqs\nFROM pg_stats\nWHERE schemaname = 'public'\n AND tablename = 'work_orders';\n\nANALYZE work_orders;\npg_indexes показывает фактическое определение индекса. pg_stats даёт читаемое представление статистики столбцов. ANALYZE обновляет входные данные планировщика, но не обещает индексный узел. После него нужно снять тот же план и сравнить строки, буферы и время.
Если запрос фильтрует связанные столбцы, обычная статистика по каждому столбцу может не описывать их зависимость. Расширенную статистику стоит рассматривать только после доказанного расхождения. Создание объектов «на всякий случай» увеличивает стоимость обслуживания и затрудняет следующий диагноз.
\nPartial index хранит только строки, которые проходят его predicate. Это полезно для небольшой устойчивой части таблицы. Например, очередь может часто читать свежие записи со статусом waiting:
CREATE INDEX work_orders_waiting_created_idx\nON work_orders (created_at)\nWHERE state = 'waiting';\n\nSELECT id, created_at\nFROM work_orders\nWHERE state = 'waiting'\n AND created_at >= timestamp '2019-07-10 00:00:00'\n AND created_at < timestamp '2019-07-11 00:00:00';\nЗапрос содержит условие, которое явно включает predicate индекса. Но похожая запись не гарантирует сопоставление во всех случаях. Планировщик проверяет условие на этапе планирования и не доказывает произвольную эквивалентность всех выражений. Параметризованный запрос требует отдельной проверки: неизвестное значение параметра не всегда позволяет доказать, что state = 'waiting' истинно.
PREPARE orders_by_state(text) AS\nSELECT id\nFROM work_orders\nWHERE state = $1;\nИз этого примера нельзя делать абсолютный вывод, что partial index никогда не используется с PREPARE. Нужно снять реальный план в режиме, который применяет клиент, и проверить конкретные параметры. Если общий query shape не доказывает predicate, partial index не должен быть единственной ставкой для маршрута.
EXPLAIN ANALYZE выполняет запрос и добавляет фактические строки, время и число повторов узла. Поэтому его нельзя запускать на изменяющем запросе только ради просмотра плана. Для учебного UPDATE нужна транзакция с ROLLBACK, а для рабочей системы — согласованный безопасный контур и оценка нагрузки.
Читайте дерево снизу вверх. Сначала найдите scan, который получает строки. Затем проверьте, что попало в Index Cond, а что осталось в Filter. Условие в Index Cond ограничивает поиск по индексу. Условие в Filter проверяется после получения строк, поэтому индекс мог не уменьшить основной объём чтения.
Учитывайте loops. Значения actual rows и actual time на узле обычно показываются в расчёте на один проход. Вложенный узел, который выполняется тысячи раз, может создавать основную цену даже при маленьком времени одного прохода. Buffers помогает отличить работу с уже загруженными страницами от чтения с диска, но не заменяет измерение полного запроса.
ORDER BY, LIMIT и ожидаемый объём результата.pg_indexes и сопоставьте ключи с фактическим WHERE.EXPLAIN, затем безопасный EXPLAIN (ANALYZE, BUFFERS) на подходящем контуре.rows и actual rows. Если расхождения нет, рассмотрите Seq Scan как возможный правильный выбор.ANALYZE и переснимите тот же план. Не совмещайте этот шаг с несколькими изменениями DDL.Нельзя обещать, что один индекс навсегда даст Index Scan. Данные, настройки стоимости, версия PostgreSQL, кэш и параметры запроса меняются. Один снимок плана отвечает только за конкретные входы и состояние сервера.
Нельзя превращать значение cost в миллисекунды. Нельзя считать быстрым любой план с индексом. Нельзя переписывать календарную дату без явной временной зоны. Нельзя добавлять expression или partial index без оценки размера, времени построения и цены будущих изменений строк.
Если после целевого ANALYZE оценка близка к факту, предикат совпадает с ключом, а результат занимает большую долю таблицы, остановитесь. В этом случае Seq Scan может быть честным и более дешёвым маршрутом. Ищите уменьшение результата, пагинацию или лишнюю работу приложения, а не ещё один индекс.
Диагностика закончена, когда сохранены точный SQL и параметры, определение индекса, версия PostgreSQL, план до изменения и план после него. В отчёте видно первое расхождение оценок, объяснено действие Index Cond/Filter и названа цена для записи. Если правка не дала подтверждённого улучшения или нарушила смысл даты, её не следует объявлять решением.
EXPLAIN ANALYZE и ограничения cost.Симптом на странице каталога такой: список товаров стал отвечать медленнее после роста таблицы products. В базе уже есть B-tree индекс на category_id, поэтому первый вывод звучит привычно: раз в плане виден Seq Scan, нужен ещё один индекс. Это пока только гипотеза. Неизвестно, сколько строк реально возвращает фильтр, какая колонка попала в Filter и не платит ли запрос за сортировку.
Разбор ниже — воспроизводимый учебный кейс, а не отчёт с замерами production. Его результатом будет не обещание конкретных миллисекунд, а решение, которое можно проверить на тех же данных: оставить план, обновить статистику или добавить один индекс под доказанную форму запроса. Числа actual time, rows и Buffers нужно снять на своём контуре.
Я начинаю с точного SQL и плана до изменения. Затем нахожу первый факт, который не совпал с гипотезой, и меняю только один фактор. Такой порядок защищает от типичной ошибки: лечить название узла плана вместо причины задержки.
\nПредположим, экран отдаёт 50 последних видимых товаров категории. На небольшом объёме данных одиночный индекс по категории мог казаться достаточным, но после роста таблицы чтение и сортировка стали заметны. Воспроизведение должно использовать структуру, близкую к рабочей, иначе результат будет демонстрацией, а не проверкой.
\nCREATE TABLE products (\n id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n category_id integer NOT NULL,\n updated_at timestamp NOT NULL,\n visible boolean NOT NULL,\n price numeric(12,2) NOT NULL\n);\n\nINSERT INTO products (category_id, updated_at, visible, price)\nSELECT CASE WHEN n % 20 = 0 THEN 42 ELSE 7 END,\n timestamp '2019-07-01' + (n % 31) * interval '1 day',\n n % 10 <> 0,\n 100 + (n % 10000) / 100.0\nFROM generate_series(1, 200000) AS n;\n\nCREATE INDEX products_category_idx ON products (category_id);\nANALYZE products;\n\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT id, price, updated_at\nFROM products\nWHERE category_id = 42\n AND visible\nORDER BY updated_at DESC\nLIMIT 50;\nСохраняйте этот план до изменений вместе с объёмом таблицы, версией PostgreSQL и фактическим параметром категории. Не подменяйте EXPLAIN (ANALYZE, BUFFERS) цифрами из документации: команда действительно выполняет SELECT, а значения зависят от данных, кэша и настроек. Если запрос изменяет данные, нужен безопасный контур либо согласованная транзакция с откатом.
Планировщик строит дерево узлов. Нижние узлы получают строки из таблицы или индекса. Верхние узлы фильтруют, сортируют, соединяют и ограничивают результат. Для каждого узла PostgreSQL показывает оценку стоимости и приблизительное число строк. Стоимость — внутренняя величина планировщика, а не миллисекунды ответа.
\nИндексный путь не бесплатен. Сначала сервер читает индекс, затем находит страницы таблицы и получает строки. Если условие возвращает большую часть таблицы, последовательное чтение может оказаться дешевле. Если запрос возвращает мало строк, индекс часто выигрывает. Решение зависит от размера таблицы, расположения страниц, кэша, сортировки, LIMIT и статистики.
EXPLAIN (ANALYZE, BUFFERS)\nSELECT id, created_at, 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'\nORDER BY created_at DESC\nLIMIT 50;\nВ учебном примере запрос читает один день, использует полуоткрытый интервал и возвращает первые 50 строк. Это не замер production. Реальные значения cost, rows, actual time и Buffers нужно получать на совместимом тестовом контуре с данными, близкими к рабочим.
Индекс хранит значения исходного столбца. Запрос ниже вычисляет выражение для каждой строки:
\nCREATE INDEX work_orders_created_idx\nON work_orders (created_at);\n\nSELECT id\nFROM work_orders\nWHERE created_at::date = DATE '2019-07-10';\nПредикат сравнивает created_at::date, а ключ индекса содержит created_at. Это разные формы. Планировщик может применить индекс на выражении, если такой индекс существует, но обычный индекс на исходной колонке не обязан подходить для этого условия.
Сначала нужно определить смысл «за один день». Для timestamp without time zone можно задать границы прямо. Для timestamptz границы зависят от часового пояса бизнеса. Механическая замена cast на диапазон без выбора зоны способна вернуть записи соседнего календарного дня или пропустить часть нужных записей.
-- Учебный вариант для timestamp without time zone.\nSELECT id\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\n-- Вариант для устойчивого query shape, если выражение менять нельзя.\nCREATE INDEX work_orders_created_date_idx\nON work_orders ((created_at::date));\nДиапазон сохраняет исходный ключ и обычно проще сопоставляется с B-tree. Индекс на выражении имеет другой компромисс: PostgreSQL вычисляет выражение и поддерживает его при изменениях строк. Он оправдан, если один и тот же выраженный предикат повторяется и переписать его без изменения смысла нельзя.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Индекс есть, но Seq Scan возвращает почти всю таблицу | Низкая селективность | Сравнить rows и actual rows, проверить долю результата | Оставить полный scan или уменьшить объём запроса; не добавлять дубликат |
| Оценка — сотни строк, факт — сотни тысяч | Устаревшая или грубая статистика | Найти первый узел расхождения и посмотреть pg_stats | Выполнить целевой ANALYZE, затем повторить тот же план |
created_at::date при ключе (created_at) | Запрос использует выражение, индекс — исходное значение | Сопоставить indexdef с Index Cond и Filter | Выбрать корректный диапазон или обоснованный expression index |
| Partial index не участвует в запросе | Планировщик не доказал его predicate для данного SQL | Сравнить условие индекса и запрос до подстановки параметров | Сделать условие доказуемым, сменить индекс или отказаться от partial index |
Таблица задаёт порядок проверки, но не выбирает действие автоматически. Например, частое значение state = 'ready' может возвращать 90 процентов строк. Индекс на таком значении не обязан ускорить чтение. Если экрану нужны только первые 20 записей, проверяйте сортировку и LIMIT. Если экран запрашивает весь экспорт, индекс не уменьшит объём результата.
Ключевой сигнал — не название scan, а первое существенное расхождение между ожидаемыми и фактическими строками. Если узел ожидал 100 строк, а получил 100 000, планировщик мог выбрать неподходящий путь из-за неверной оценки. Причины включают изменения распределения, недостаточную выборку и зависимость между столбцами.
\nSELECT schemaname, tablename, indexname, indexdef\nFROM pg_indexes\nWHERE schemaname = 'public'\n AND tablename = 'work_orders';\n\nSELECT attname, n_distinct, most_common_vals, most_common_freqs\nFROM pg_stats\nWHERE schemaname = 'public'\n AND tablename = 'work_orders';\n\nANALYZE work_orders;\npg_indexes показывает фактическое определение индекса. pg_stats даёт читаемое представление статистики столбцов. ANALYZE обновляет входные данные планировщика, но не обещает индексный узел. После него нужно снять тот же план и сравнить строки, буферы и время.
Если запрос фильтрует связанные столбцы, обычная статистика по каждому столбцу может не описывать их зависимость. Расширенную статистику стоит рассматривать только после доказанного расхождения. Создание объектов «на всякий случай» увеличивает стоимость обслуживания и затрудняет следующий диагноз.
\nPartial index хранит только строки, которые проходят его predicate. Это полезно для небольшой устойчивой части таблицы. Например, очередь может часто читать свежие записи со статусом waiting:
CREATE INDEX work_orders_waiting_created_idx\nON work_orders (created_at)\nWHERE state = 'waiting';\n\nSELECT id, created_at\nFROM work_orders\nWHERE state = 'waiting'\n AND created_at >= timestamp '2019-07-10 00:00:00'\n AND created_at < timestamp '2019-07-11 00:00:00';\nЗапрос содержит условие, которое явно включает predicate индекса. Но похожая запись не гарантирует сопоставление во всех случаях. Планировщик проверяет условие на этапе планирования и не доказывает произвольную эквивалентность всех выражений. Параметризованный запрос требует отдельной проверки: неизвестное значение параметра не всегда позволяет доказать, что state = 'waiting' истинно.
PREPARE orders_by_state(text) AS\nSELECT id\nFROM work_orders\nWHERE state = $1;\nИз этого примера нельзя делать абсолютный вывод, что partial index никогда не используется с PREPARE. Нужно снять реальный план в режиме, который применяет клиент, и проверить конкретные параметры. Если общий query shape не доказывает predicate, partial index не должен быть единственной ставкой для маршрута.
EXPLAIN ANALYZE выполняет запрос и добавляет фактические строки, время и число повторов узла. Поэтому его нельзя запускать на изменяющем запросе только ради просмотра плана. Для учебного UPDATE нужна транзакция с ROLLBACK, а для рабочей системы — согласованный безопасный контур и оценка нагрузки.
Читайте дерево снизу вверх. Сначала найдите scan, который получает строки. Затем проверьте, что попало в Index Cond, а что осталось в Filter. Условие в Index Cond ограничивает поиск по индексу. Условие в Filter проверяется после получения строк, поэтому индекс мог не уменьшить основной объём чтения.
Учитывайте loops. Значения actual rows и actual time на узле обычно показываются в расчёте на один проход. Вложенный узел, который выполняется тысячи раз, может создавать основную цену даже при маленьком времени одного прохода. Buffers помогает отличить работу с уже загруженными страницами от чтения с диска, но не заменяет измерение полного запроса.
ORDER BY, LIMIT и ожидаемый объём результата.pg_indexes и сопоставьте ключи с фактическим WHERE.EXPLAIN, затем безопасный EXPLAIN (ANALYZE, BUFFERS) на подходящем контуре.rows и actual rows. Если расхождения нет, рассмотрите Seq Scan как возможный правильный выбор.ANALYZE и переснимите тот же план. Не совмещайте этот шаг с несколькими изменениями DDL.Нельзя обещать, что один индекс навсегда даст Index Scan. Данные, настройки стоимости, версия PostgreSQL, кэш и параметры запроса меняются. Один снимок плана отвечает только за конкретные входы и состояние сервера.
Нельзя превращать значение cost в миллисекунды. Нельзя считать быстрым любой план с индексом. Нельзя переписывать календарную дату без явной временной зоны. Нельзя добавлять expression или partial index без оценки размера, времени построения и цены будущих изменений строк.
Если после целевого ANALYZE оценка близка к факту, предикат совпадает с ключом, а результат занимает большую долю таблицы, остановитесь. В этом случае Seq Scan может быть честным и более дешёвым маршрутом. Ищите уменьшение результата, пагинацию или лишнюю работу приложения, а не ещё один индекс.
Диагностика закончена, когда сохранены точный SQL и параметры, определение индекса, версия PostgreSQL, план до изменения и план после него. В отчёте видно первое расхождение оценок, объяснено действие Index Cond/Filter и названа цена для записи. Если правка не дала подтверждённого улучшения или нарушила смысл даты, её не следует объявлять решением.
EXPLAIN ANALYZE и ограничения cost.