From b8d71286281939091859bf6aaca2b00ec3cdf2fc Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 23:20:28 +0300 Subject: [PATCH] Rewrite editorial article 304 to 10x10 standard --- editorial/agent-rewrites/304.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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. Ошибка здесь стоит дороже, чем одна задержка: команда начинает подбирать индексы по названию узла, не проверив, что именно ищет запрос.

\n

Главный тезис простой: индекс помогает только тогда, когда планировщик видит подходящую форму условия, правильно оценивает долю строк и считает путь дешевле полного чтения. Поэтому сначала нужен точный SQL и план, потом проверка статистики и предиката, и только затем DDL. Seq Scan сам по себе не доказывает проблему.

\n

Что на самом деле сравнивает планировщик

\n

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

\n

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

\n
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 нужно получать на совместимом тестовом контуре с данными, близкими к рабочим.

\n

Первый подозреваемый — форма WHERE

\n

Индекс хранит значения исходного столбца. Запрос ниже вычисляет выражение для каждой строки:

\n
CREATE 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. Это разные формы. Планировщик может применить индекс на выражении, если такой индекс существует, но обычный индекс на исходной колонке не обязан подходить для этого условия.

\n

Сначала нужно определить смысл «за один день». Для timestamp without time zone можно задать границы прямо. Для timestamptz границы зависят от часового пояса бизнеса. Механическая замена cast на диапазон без выбора зоны способна вернуть записи соседнего календарного дня или пропустить часть нужных записей.

\n
-- Учебный вариант для 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
\"Схема
Форма предиката важнее самого факта наличия индекса. Сначала сверяем смысл границ дня и текст условия, затем сравниваем планы.
\n

Симптомы, причины и проверки

\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
\n

Таблица задаёт порядок проверки, но не выбирает действие автоматически. Например, частое значение state = 'ready' может возвращать 90 процентов строк. Индекс на таком значении не обязан ускорить чтение. Если экрану нужны только первые 20 записей, проверяйте сортировку и LIMIT. Если экран запрашивает весь экспорт, индекс не уменьшит объём результата.

\n

Проверяем оценку строк и статистику

\n

Ключевой сигнал — не название scan, а первое существенное расхождение между ожидаемыми и фактическими строками. Если узел ожидал 100 строк, а получил 100 000, планировщик мог выбрать неподходящий путь из-за неверной оценки. Причины включают изменения распределения, недостаточную выборку и зависимость между столбцами.

\n
SELECT 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;
\n

pg_indexes показывает фактическое определение индекса. pg_stats даёт читаемое представление статистики столбцов. ANALYZE обновляет входные данные планировщика, но не обещает индексный узел. После него нужно снять тот же план и сравнить строки, буферы и время.

\n

Если запрос фильтрует связанные столбцы, обычная статистика по каждому столбцу может не описывать их зависимость. Расширенную статистику стоит рассматривать только после доказанного расхождения. Создание объектов «на всякий случай» увеличивает стоимость обслуживания и затрудняет следующий диагноз.

\n

Partial index и доказуемое условие

\n

Partial index хранит только строки, которые проходят его predicate. Это полезно для небольшой устойчивой части таблицы. Например, очередь может часто читать свежие записи со статусом waiting:

\n
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' истинно.

\n
PREPARE orders_by_state(text) AS\nSELECT id\nFROM work_orders\nWHERE state = $1;
\n

Из этого примера нельзя делать абсолютный вывод, что partial index никогда не используется с PREPARE. Нужно снять реальный план в режиме, который применяет клиент, и проверить конкретные параметры. Если общий query shape не доказывает predicate, partial index не должен быть единственной ставкой для маршрута.

\n

Как читать EXPLAIN ANALYZE

\n

EXPLAIN ANALYZE выполняет запрос и добавляет фактические строки, время и число повторов узла. Поэтому его нельзя запускать на изменяющем запросе только ради просмотра плана. Для учебного UPDATE нужна транзакция с ROLLBACK, а для рабочей системы — согласованный безопасный контур и оценка нагрузки.

\n

Читайте дерево снизу вверх. Сначала найдите scan, который получает строки. Затем проверьте, что попало в Index Cond, а что осталось в Filter. Условие в Index Cond ограничивает поиск по индексу. Условие в Filter проверяется после получения строк, поэтому индекс мог не уменьшить основной объём чтения.

\n

Учитывайте loops. Значения actual rows и actual time на узле обычно показываются в расчёте на один проход. Вложенный узел, который выполняется тысячи раз, может создавать основную цену даже при маленьком времени одного прохода. Buffers помогает отличить работу с уже загруженными страницами от чтения с диска, но не заменяет измерение полного запроса.

\n

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

\n
  1. Сохраните точный SQL из реального места вызова, типы параметров, ORDER BY, LIMIT и ожидаемый объём результата.
  2. Проверьте определение индексов через pg_indexes и сопоставьте ключи с фактическим WHERE.
  3. Снимите обычный EXPLAIN, затем безопасный EXPLAIN (ANALYZE, BUFFERS) на подходящем контуре.
  4. Найдите первый узел с заметным расхождением rows и actual rows. Если расхождения нет, рассмотрите Seq Scan как возможный правильный выбор.
  5. Проверьте форму предиката: cast, функцию, порядок составного ключа, границы времени и условие partial index.
  6. Если оценки устарели, выполните целевой ANALYZE и переснимите тот же план. Не совмещайте этот шаг с несколькими изменениями DDL.
  7. Сделайте одну минимальную правку: диапазон, expression index, partial index или изменение объёма результата. После этого сравните план, строки, loops, buffers и стоимость записи.
\n

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

\n

Нельзя обещать, что один индекс навсегда даст Index Scan. Данные, настройки стоимости, версия PostgreSQL, кэш и параметры запроса меняются. Один снимок плана отвечает только за конкретные входы и состояние сервера.

\n

Нельзя превращать значение cost в миллисекунды. Нельзя считать быстрым любой план с индексом. Нельзя переписывать календарную дату без явной временной зоны. Нельзя добавлять expression или partial index без оценки размера, времени построения и цены будущих изменений строк.

\n

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

\n

Критерий готовности

\n

Диагностика закончена, когда сохранены точный SQL и параметры, определение индекса, версия PostgreSQL, план до изменения и план после него. В отчёте видно первое расхождение оценок, объяснено действие Index Cond/Filter и названа цена для записи. Если правка не дала подтверждённого улучшения или нарушила смысл даты, её не следует объявлять решением.

\n

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

" + "title": "Каталог замедлился после роста таблицы: как проверить индекс до изменения схемы", + "excerpt": "В кейсе каталога Seq Scan оказался не доказательством ошибки. Разбираем форму запроса, селективность и статистику, затем выбираем одно проверяемое изменение.", + "contentHtml": "

Симптом на странице каталога такой: список товаров стал отвечать медленнее после роста таблицы products. В базе уже есть B-tree индекс на category_id, поэтому первый вывод звучит привычно: раз в плане виден Seq Scan, нужен ещё один индекс. Это пока только гипотеза. Неизвестно, сколько строк реально возвращает фильтр, какая колонка попала в Filter и не платит ли запрос за сортировку.

\n

Разбор ниже — воспроизводимый учебный кейс, а не отчёт с замерами production. Его результатом будет не обещание конкретных миллисекунд, а решение, которое можно проверить на тех же данных: оставить план, обновить статистику или добавить один индекс под доказанную форму запроса. Числа actual time, rows и Buffers нужно снять на своём контуре.

\n

Я начинаю с точного SQL и плана до изменения. Затем нахожу первый факт, который не совпал с гипотезой, и меняю только один фактор. Такой порядок защищает от типичной ошибки: лечить название узла плана вместо причины задержки.

\n

Кейс каталога: фиксируем запрос до изменения

\n

Предположим, экран отдаёт 50 последних видимых товаров категории. На небольшом объёме данных одиночный индекс по категории мог казаться достаточным, но после роста таблицы чтение и сортировка стали заметны. Воспроизведение должно использовать структуру, близкую к рабочей, иначе результат будет демонстрацией, а не проверкой.

\n
CREATE 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, а значения зависят от данных, кэша и настроек. Если запрос изменяет данные, нужен безопасный контур либо согласованная транзакция с откатом.

\n
Дерево EXPLAIN показывает первое расхождение между оценочными и фактическими строками, а также Index Cond, loops и Buffers
В расследовании начинаем с первого расхождения оценки и факта, а затем проверяем узел, условие индекса, число запусков и буферы.
\n

Что на самом деле сравнивает планировщик

\n

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

\n

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

\n
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 нужно получать на совместимом тестовом контуре с данными, близкими к рабочим.

\n

Первый подозреваемый — форма WHERE

\n

Индекс хранит значения исходного столбца. Запрос ниже вычисляет выражение для каждой строки:

\n
CREATE 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. Это разные формы. Планировщик может применить индекс на выражении, если такой индекс существует, но обычный индекс на исходной колонке не обязан подходить для этого условия.

\n

Сначала нужно определить смысл «за один день». Для timestamp without time zone можно задать границы прямо. Для timestamptz границы зависят от часового пояса бизнеса. Механическая замена cast на диапазон без выбора зоны способна вернуть записи соседнего календарного дня или пропустить часть нужных записей.

\n
-- Учебный вариант для 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
\"Схема
Форма предиката важнее самого факта наличия индекса. Сначала сверяем смысл границ дня и текст условия, затем сравниваем планы.
\n

Симптомы, причины и проверки

\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
\n

Таблица задаёт порядок проверки, но не выбирает действие автоматически. Например, частое значение state = 'ready' может возвращать 90 процентов строк. Индекс на таком значении не обязан ускорить чтение. Если экрану нужны только первые 20 записей, проверяйте сортировку и LIMIT. Если экран запрашивает весь экспорт, индекс не уменьшит объём результата.

\n

Проверяем оценку строк и статистику

\n

Ключевой сигнал — не название scan, а первое существенное расхождение между ожидаемыми и фактическими строками. Если узел ожидал 100 строк, а получил 100 000, планировщик мог выбрать неподходящий путь из-за неверной оценки. Причины включают изменения распределения, недостаточную выборку и зависимость между столбцами.

\n
SELECT 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;
\n

pg_indexes показывает фактическое определение индекса. pg_stats даёт читаемое представление статистики столбцов. ANALYZE обновляет входные данные планировщика, но не обещает индексный узел. После него нужно снять тот же план и сравнить строки, буферы и время.

\n

Если запрос фильтрует связанные столбцы, обычная статистика по каждому столбцу может не описывать их зависимость. Расширенную статистику стоит рассматривать только после доказанного расхождения. Создание объектов «на всякий случай» увеличивает стоимость обслуживания и затрудняет следующий диагноз.

\n

Partial index и доказуемое условие

\n

Partial index хранит только строки, которые проходят его predicate. Это полезно для небольшой устойчивой части таблицы. Например, очередь может часто читать свежие записи со статусом waiting:

\n
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' истинно.

\n
PREPARE orders_by_state(text) AS\nSELECT id\nFROM work_orders\nWHERE state = $1;
\n

Из этого примера нельзя делать абсолютный вывод, что partial index никогда не используется с PREPARE. Нужно снять реальный план в режиме, который применяет клиент, и проверить конкретные параметры. Если общий query shape не доказывает predicate, partial index не должен быть единственной ставкой для маршрута.

\n

Как читать EXPLAIN ANALYZE

\n

EXPLAIN ANALYZE выполняет запрос и добавляет фактические строки, время и число повторов узла. Поэтому его нельзя запускать на изменяющем запросе только ради просмотра плана. Для учебного UPDATE нужна транзакция с ROLLBACK, а для рабочей системы — согласованный безопасный контур и оценка нагрузки.

\n

Читайте дерево снизу вверх. Сначала найдите scan, который получает строки. Затем проверьте, что попало в Index Cond, а что осталось в Filter. Условие в Index Cond ограничивает поиск по индексу. Условие в Filter проверяется после получения строк, поэтому индекс мог не уменьшить основной объём чтения.

\n

Учитывайте loops. Значения actual rows и actual time на узле обычно показываются в расчёте на один проход. Вложенный узел, который выполняется тысячи раз, может создавать основную цену даже при маленьком времени одного прохода. Buffers помогает отличить работу с уже загруженными страницами от чтения с диска, но не заменяет измерение полного запроса.

\n

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

\n
  1. Сохраните точный SQL из реального места вызова, типы параметров, ORDER BY, LIMIT и ожидаемый объём результата.
  2. Проверьте определение индексов через pg_indexes и сопоставьте ключи с фактическим WHERE.
  3. Снимите обычный EXPLAIN, затем безопасный EXPLAIN (ANALYZE, BUFFERS) на подходящем контуре.
  4. Найдите первый узел с заметным расхождением rows и actual rows. Если расхождения нет, рассмотрите Seq Scan как возможный правильный выбор.
  5. Проверьте форму предиката: cast, функцию, порядок составного ключа, границы времени и условие partial index.
  6. Если оценки устарели, выполните целевой ANALYZE и переснимите тот же план. Не совмещайте этот шаг с несколькими изменениями DDL.
  7. Сделайте одну минимальную правку: диапазон, expression index, partial index или изменение объёма результата. После этого сравните план, строки, loops, buffers и стоимость записи.
\n

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

\n

Нельзя обещать, что один индекс навсегда даст Index Scan. Данные, настройки стоимости, версия PostgreSQL, кэш и параметры запроса меняются. Один снимок плана отвечает только за конкретные входы и состояние сервера.

\n

Нельзя превращать значение cost в миллисекунды. Нельзя считать быстрым любой план с индексом. Нельзя переписывать календарную дату без явной временной зоны. Нельзя добавлять expression или partial index без оценки размера, времени построения и цены будущих изменений строк.

\n

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

\n

Критерий готовности

\n

Диагностика закончена, когда сохранены точный SQL и параметры, определение индекса, версия PostgreSQL, план до изменения и план после него. В отчёте видно первое расхождение оценок, объяснено действие Index Cond/Filter и названа цена для записи. Если правка не дала подтверждённого улучшения или нарушила смысл даты, её не следует объявлять решением.

\n

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

" }