8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"index": 304,
|
||
"slug": "editorial-2019-07-field-sql-indexes",
|
||
"title": "Индекс есть, Seq Scan остался: как проверить форму запроса в PostgreSQL",
|
||
"excerpt": "Индекс на created_at не гарантирует быстрый запрос за день. Разбираем селективность, статистику, выражения и partial index через один проверяемый маршрут.",
|
||
"contentHtml": "<p>В таблице уже есть B-tree индекс на <code>created_at</code>, но выборка за один день всё равно читает таблицу целиком. Добавление второго индекса не меняет план. Запрос остаётся медленным на большой таблице, а каждая новая структура увеличивает размер базы и цену <code>INSERT</code>/<code>UPDATE</code>. Ошибка здесь стоит дороже, чем одна задержка: команда начинает подбирать индексы по названию узла, не проверив, что именно ищет запрос.</p>\n<p>Главный тезис простой: индекс помогает только тогда, когда планировщик видит подходящую форму условия, правильно оценивает долю строк и считает путь дешевле полного чтения. Поэтому сначала нужен точный SQL и план, потом проверка статистики и предиката, и только затем DDL. <code>Seq Scan</code> сам по себе не доказывает проблему.</p>\n<h2>Что на самом деле сравнивает планировщик</h2>\n<p>Планировщик строит дерево узлов. Нижние узлы получают строки из таблицы или индекса. Верхние узлы фильтруют, сортируют, соединяют и ограничивают результат. Для каждого узла PostgreSQL показывает оценку стоимости и приблизительное число строк. Стоимость — внутренняя величина планировщика, а не миллисекунды ответа.</p>\n<p>Индексный путь не бесплатен. Сначала сервер читает индекс, затем находит страницы таблицы и получает строки. Если условие возвращает большую часть таблицы, последовательное чтение может оказаться дешевле. Если запрос возвращает мало строк, индекс часто выигрывает. Решение зависит от размера таблицы, расположения страниц, кэша, сортировки, <code>LIMIT</code> и статистики.</p>\n<pre><code>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;</code></pre>\n<p>В учебном примере запрос читает один день, использует полуоткрытый интервал и возвращает первые 50 строк. Это не замер production. Реальные значения <code>cost</code>, <code>rows</code>, <code>actual time</code> и <code>Buffers</code> нужно получать на совместимом тестовом контуре с данными, близкими к рабочим.</p>\n<h2>Первый подозреваемый — форма WHERE</h2>\n<p>Индекс хранит значения исходного столбца. Запрос ниже вычисляет выражение для каждой строки:</p>\n<pre><code>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';</code></pre>\n<p>Предикат сравнивает <code>created_at::date</code>, а ключ индекса содержит <code>created_at</code>. Это разные формы. Планировщик может применить индекс на выражении, если такой индекс существует, но обычный индекс на исходной колонке не обязан подходить для этого условия.</p>\n<p>Сначала нужно определить смысл «за один день». Для <code>timestamp without time zone</code> можно задать границы прямо. Для <code>timestamptz</code> границы зависят от часового пояса бизнеса. Механическая замена cast на диапазон без выбора зоны способна вернуть записи соседнего календарного дня или пропустить часть нужных записей.</p>\n<pre><code>-- Учебный вариант для 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));</code></pre>\n<p>Диапазон сохраняет исходный ключ и обычно проще сопоставляется с B-tree. Индекс на выражении имеет другой компромисс: PostgreSQL вычисляет выражение и поддерживает его при изменениях строк. Он оправдан, если один и тот же выраженный предикат повторяется и переписать его без изменения смысла нельзя.</p>\n<figure><img src=\"/assets/editorial/2019/sql-indexes-predicate-shape-2019.svg\" alt=\"Схема сравнивает индекс на created_at, выражение created_at::date и полуоткрытый диапазон одного дня\" loading=\"lazy\" /><figcaption>Форма предиката важнее самого факта наличия индекса. Сначала сверяем смысл границ дня и текст условия, затем сравниваем планы.</figcaption></figure>\n<h2>Симптомы, причины и проверки</h2>\n<div class=\"table-scroll\"><table><caption>Диагностическая карта для запроса по дате</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Индекс есть, но <code>Seq Scan</code> возвращает почти всю таблицу</td><td>Низкая селективность</td><td>Сравнить <code>rows</code> и <code>actual rows</code>, проверить долю результата</td><td>Оставить полный scan или уменьшить объём запроса; не добавлять дубликат</td></tr><tr><td>Оценка — сотни строк, факт — сотни тысяч</td><td>Устаревшая или грубая статистика</td><td>Найти первый узел расхождения и посмотреть <code>pg_stats</code></td><td>Выполнить целевой <code>ANALYZE</code>, затем повторить тот же план</td></tr><tr><td><code>created_at::date</code> при ключе <code>(created_at)</code></td><td>Запрос использует выражение, индекс — исходное значение</td><td>Сопоставить <code>indexdef</code> с <code>Index Cond</code> и <code>Filter</code></td><td>Выбрать корректный диапазон или обоснованный expression index</td></tr><tr><td>Partial index не участвует в запросе</td><td>Планировщик не доказал его predicate для данного SQL</td><td>Сравнить условие индекса и запрос до подстановки параметров</td><td>Сделать условие доказуемым, сменить индекс или отказаться от partial index</td></tr></tbody></table></div>\n<p>Таблица задаёт порядок проверки, но не выбирает действие автоматически. Например, частое значение <code>state = 'ready'</code> может возвращать 90 процентов строк. Индекс на таком значении не обязан ускорить чтение. Если экрану нужны только первые 20 записей, проверяйте сортировку и <code>LIMIT</code>. Если экран запрашивает весь экспорт, индекс не уменьшит объём результата.</p>\n<h2>Проверяем оценку строк и статистику</h2>\n<p>Ключевой сигнал — не название scan, а первое существенное расхождение между ожидаемыми и фактическими строками. Если узел ожидал 100 строк, а получил 100 000, планировщик мог выбрать неподходящий путь из-за неверной оценки. Причины включают изменения распределения, недостаточную выборку и зависимость между столбцами.</p>\n<pre><code>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;</code></pre>\n<p><code>pg_indexes</code> показывает фактическое определение индекса. <code>pg_stats</code> даёт читаемое представление статистики столбцов. <code>ANALYZE</code> обновляет входные данные планировщика, но не обещает индексный узел. После него нужно снять тот же план и сравнить строки, буферы и время.</p>\n<p>Если запрос фильтрует связанные столбцы, обычная статистика по каждому столбцу может не описывать их зависимость. Расширенную статистику стоит рассматривать только после доказанного расхождения. Создание объектов «на всякий случай» увеличивает стоимость обслуживания и затрудняет следующий диагноз.</p>\n<h2>Partial index и доказуемое условие</h2>\n<p>Partial index хранит только строки, которые проходят его predicate. Это полезно для небольшой устойчивой части таблицы. Например, очередь может часто читать свежие записи со статусом <code>waiting</code>:</p>\n<pre><code>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';</code></pre>\n<p>Запрос содержит условие, которое явно включает predicate индекса. Но похожая запись не гарантирует сопоставление во всех случаях. Планировщик проверяет условие на этапе планирования и не доказывает произвольную эквивалентность всех выражений. Параметризованный запрос требует отдельной проверки: неизвестное значение параметра не всегда позволяет доказать, что <code>state = 'waiting'</code> истинно.</p>\n<pre><code>PREPARE orders_by_state(text) AS\nSELECT id\nFROM work_orders\nWHERE state = $1;</code></pre>\n<p>Из этого примера нельзя делать абсолютный вывод, что partial index никогда не используется с <code>PREPARE</code>. Нужно снять реальный план в режиме, который применяет клиент, и проверить конкретные параметры. Если общий query shape не доказывает predicate, partial index не должен быть единственной ставкой для маршрута.</p>\n<h2>Как читать EXPLAIN ANALYZE</h2>\n<p><code>EXPLAIN ANALYZE</code> выполняет запрос и добавляет фактические строки, время и число повторов узла. Поэтому его нельзя запускать на изменяющем запросе только ради просмотра плана. Для учебного <code>UPDATE</code> нужна транзакция с <code>ROLLBACK</code>, а для рабочей системы — согласованный безопасный контур и оценка нагрузки.</p>\n<p>Читайте дерево снизу вверх. Сначала найдите scan, который получает строки. Затем проверьте, что попало в <code>Index Cond</code>, а что осталось в <code>Filter</code>. Условие в <code>Index Cond</code> ограничивает поиск по индексу. Условие в <code>Filter</code> проверяется после получения строк, поэтому индекс мог не уменьшить основной объём чтения.</p>\n<p>Учитывайте <code>loops</code>. Значения <code>actual rows</code> и <code>actual time</code> на узле обычно показываются в расчёте на один проход. Вложенный узел, который выполняется тысячи раз, может создавать основную цену даже при маленьком времени одного прохода. <code>Buffers</code> помогает отличить работу с уже загруженными страницами от чтения с диска, но не заменяет измерение полного запроса.</p>\n<h2>Порядок действий</h2>\n<ol><li>Сохраните точный SQL из реального места вызова, типы параметров, <code>ORDER BY</code>, <code>LIMIT</code> и ожидаемый объём результата.</li><li>Проверьте определение индексов через <code>pg_indexes</code> и сопоставьте ключи с фактическим <code>WHERE</code>.</li><li>Снимите обычный <code>EXPLAIN</code>, затем безопасный <code>EXPLAIN (ANALYZE, BUFFERS)</code> на подходящем контуре.</li><li>Найдите первый узел с заметным расхождением <code>rows</code> и <code>actual rows</code>. Если расхождения нет, рассмотрите <code>Seq Scan</code> как возможный правильный выбор.</li><li>Проверьте форму предиката: cast, функцию, порядок составного ключа, границы времени и условие partial index.</li><li>Если оценки устарели, выполните целевой <code>ANALYZE</code> и переснимите тот же план. Не совмещайте этот шаг с несколькими изменениями DDL.</li><li>Сделайте одну минимальную правку: диапазон, expression index, partial index или изменение объёма результата. После этого сравните план, строки, loops, buffers и стоимость записи.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Нельзя обещать, что один индекс навсегда даст <code>Index Scan</code>. Данные, настройки стоимости, версия PostgreSQL, кэш и параметры запроса меняются. Один снимок плана отвечает только за конкретные входы и состояние сервера.</p>\n<p>Нельзя превращать значение <code>cost</code> в миллисекунды. Нельзя считать быстрым любой план с индексом. Нельзя переписывать календарную дату без явной временной зоны. Нельзя добавлять expression или partial index без оценки размера, времени построения и цены будущих изменений строк.</p>\n<p>Если после целевого <code>ANALYZE</code> оценка близка к факту, предикат совпадает с ключом, а результат занимает большую долю таблицы, остановитесь. В этом случае <code>Seq Scan</code> может быть честным и более дешёвым маршрутом. Ищите уменьшение результата, пагинацию или лишнюю работу приложения, а не ещё один индекс.</p>\n<h2>Критерий готовности</h2>\n<p>Диагностика закончена, когда сохранены точный SQL и параметры, определение индекса, версия PostgreSQL, план до изменения и план после него. В отчёте видно первое расхождение оценок, объяснено действие <code>Index Cond</code>/<code>Filter</code> и названа цена для записи. Если правка не дала подтверждённого улучшения или нарушила смысл даты, её не следует объявлять решением.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://www.postgresql.org/docs/current/using-explain.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL: Using EXPLAIN</a> — структура плана, оценки строк, <code>EXPLAIN ANALYZE</code> и ограничения cost.</li><li><a href=\"https://www.postgresql.org/docs/current/indexes-expressional.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL: Indexes on Expressions</a> — условия применимости и цена индексов на выражениях.</li><li><a href=\"https://www.postgresql.org/docs/current/indexes-partial.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL: Partial Indexes</a> — predicate частичного индекса и проверка его применимости при планировании.</li></ul>"
|
||
}
|