Files

8 lines
20 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 306,
"slug": "editorial-2019-07-practice-sql-indexes",
"title": "Индекс есть, а запрос медленный: как читать план PostgreSQL",
"excerpt": "Индекс не обязан ускорять каждый запрос. Разбираем Seq Scan, селективность, статистику и форму условия через EXPLAIN ANALYZE, а затем выбираем действие по данным.",
"contentHtml": "<p>Симптом выглядит как противоречие: на <code>orders</code> создали индекс по <code>state</code>, но список заказов всё ещё отвечает медленно. В плане виден <code>Seq Scan</code>, поэтому команда добавляет второй индекс или запрещает последовательное чтение. Цена ошибки растёт с каждой записью: индексы занимают место, замедляют <code>INSERT</code> и <code>UPDATE</code>, а задержка запроса остаётся.</p>\n<p>Индекс — только один из вариантов доступа. PostgreSQL выбирает его не по факту существования, а по ожидаемой стоимости конкретного запроса на конкретных данных. Сначала сравните оценку строк с фактом, долю результата и форму предиката. После этого станет ясно, нужен ли индекс, свежая статистика, другой <code>WHERE</code> или правильный последовательный проход.</p>\n<h2>Механизм: индекс не обещает короткий путь</h2>\n<p>B-tree хранит ключи и указатели на строки отдельно от таблицы и помогает найти часть строк по подходящему сравнению. Затем сервер часто обращается к таблице за остальными колонками и проверяет видимость строк. Если условие возвращает большую долю таблицы, точечных обращений становится много. Один последовательный проход может стоить дешевле.</p>\n<p>Поэтому <code>Seq Scan</code> не равен ошибке. Он означает выбранный способ прочитать таблицу. <code>Index Scan</code> тоже не равен ускорению: поиск может вернуть мало строк, но повториться тысячи раз внутри <code>Nested Loop</code> или привести к дорогому чтению таблицы. Смотрите на строки, циклы, буферы и время всего плана.</p>\n<p>У индекса есть и другая цена. PostgreSQL поддерживает его при изменении таблицы, а построение и хранение требуют ресурсов. Индекс по колонке с двумя значениями не становится полезным только потому, что колонка участвует в каждом запросе. Если значение встречается почти в каждой строке, такой ключ мало сокращает чтение.</p>\n<h2>Учебный пример: частое и редкое значение</h2>\n<p>Ниже — самостоятельный пример для отдельной учебной базы. Он создаёт перекошенное распределение: <code>ready</code> встречается часто, а <code>waiting</code> — редко. Запросы имеют одинаковую форму и используют один индекс. Числа показывают устройство примера, а не результат измерения в production. Фактический план нужно снять на своей версии PostgreSQL и со своими данными.</p>\n<pre><code>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';</code></pre>\n<p>Для <code>ready</code> условие оставляет почти всю таблицу. Индекс должен привести к строкам, а затем серверу всё равно придётся прочитать большую их часть. Для <code>waiting</code> результат меньше, поэтому индексный путь может оказаться выгоднее. Фиксированной границы вроде «индекс полезен до пяти процентов» нет. На выбор влияют размер строки, расположение страниц, кэш, <code>LIMIT</code>, нужные колонки, стоимость I/O и версия сервера.</p>\n<figure><img src=\"/assets/editorial/2019/sql-indexes-selectivity-2019.svg\" alt=\"Селективность индекса: частое значение ready оставляет почти всю таблицу, редкое значение waiting — малую часть, поэтому планировщик сравнивает индексный и последовательный пути\" loading=\"lazy\" /><figcaption>Один индекс даёт два разных результата: редкое значение сокращает чтение, частое может сделать последовательный проход дешевле.</figcaption></figure>\n<h2>Симптом → причина → проверка → действие</h2>\n<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> с размером таблицы; запустить контраст для редкого значения</td><td>Не форсировать индекс; уменьшить выборку, добавить пагинацию или оставить план</td></tr><tr><td>Оценка сильно расходится с <code>actual rows</code></td><td>Статистика устарела или плохо описывает распределение</td><td>Посмотреть <code>pg_stats</code>, выполнить целевой <code>ANALYZE</code> и повторить план</td><td>Сначала обновить статистику, затем оценить дальнейшее изменение</td></tr><tr><td>Индекс есть, но в условии функция или cast</td><td>Форма предиката не совпадает с ключом</td><td>Сверить выражение в <code>WHERE</code> с определением индекса</td><td>Переписать условие без потери смысла или обоснованно создать expression index</td></tr><tr><td>Частичный индекс не выбран</td><td>Планировщик не может доказать соответствие predicate</td><td>Сверить predicate в <code>pg_indexes</code> с точным SQL</td><td>Сделать условие совместимым или удалить лишнюю структуру</td></tr><tr><td><code>Index Scan</code> медленный внутри <code>Nested Loop</code></td><td>Дешёвый одиночный поиск многократно повторяется</td><td>Проверить <code>actual rows</code>, <code>actual time</code> и <code>loops</code> на узлах</td><td>Проверить соединение, кардинальность и объём входа, а не добавлять индекс вслепую</td></tr></tbody></table>\n<h2>Как читать EXPLAIN ANALYZE</h2>\n<p>Начните с верхнего узла и спускайтесь к источникам строк. В каждом важном узле сопоставьте <code>rows=...</code> с <code>actual ... rows=...</code>. Первое заметное расхождение часто объясняет последующие решения: планировщик ожидал маленький вход, получил большой и выбрал дорогой способ соединения или сортировки.</p>\n<p><code>cost</code> — условные единицы планировщика, а не миллисекунды. <code>actual time</code> появляется при выполнении запроса. При <code>loops &gt; 1</code> узел запускался многократно, поэтому его вклад нельзя читать по одной строке. <code>BUFFERS</code> показывает затронутые буферы и помогает отличить чтение страниц от вычислительной работы. Не выбирайте действие по одному слову <code>Index</code> или <code>Seq</code>.</p>\n<p><code>EXPLAIN ANALYZE</code> действительно запускает statement. Для <code>SELECT</code> это может быть тяжёлая нагрузка. Для <code>UPDATE</code>, <code>DELETE</code> и <code>INSERT</code> это ещё и изменение данных. Такие проверки проводят в безопасном контуре или в транзакции с откатом, если это разрешено условиями эксперимента. Обычный <code>EXPLAIN</code> подходит для первого безопасного снимка выбранного плана.</p>\n<pre><code>EXPLAIN (ANALYZE, BUFFERS)\nSELECT id, amount\nFROM work_orders\nWHERE state = 'waiting';</code></pre>\n<p>Сохраняйте SQL, параметры и версию сервера рядом с планом. План зависит от данных, статистики и настроек стоимости. Даже повторный запуск <code>ANALYZE</code> может слегка изменить оценки: статистика строится по выборке, а не по полному пересчёту каждой строки.</p>\n<h2>Статистика и форма условия</h2>\n<p>Планировщик не пересчитывает точное распределение строк перед каждым запросом. Он использует статистику таблицы. После массовой загрузки или смены значений оценка может устареть. Проверьте её точечно:</p>\n<pre><code>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';</code></pre>\n<p><code>ANALYZE</code> не обязан заменить <code>Seq Scan</code> на <code>Index Scan</code>. Его задача — дать планировщику более свежую основу для оценки. Если после обновления оценка стала близка к факту, а последовательное чтение осталось, это может быть правильный результат. Исправлять здесь нечего.</p>\n<p>Отдельно проверьте форму условия. Индекс по <code>created_at</code> не равен индексу по <code>date(created_at)</code>. Условие с функцией, неявным приведением типов или другим оператором может не дать ожидаемый путь. Для временного диапазона часто понятнее использовать полуоткрытые границы:</p>\n<pre><code>SELECT id\nFROM orders\nWHERE created_at &gt;= timestamp '2019-07-10 00:00:00'\n AND created_at &lt; timestamp '2019-07-11 00:00:00';</code></pre>\n<p>Это учебная форма запроса. В рабочей системе границы должны соответствовать типу времени и бизнес-часовому поясу. Нельзя менять смысл фильтра ради красивого плана.</p>\n<h2>Порядок действий</h2>\n<ol><li>Запишите точный SQL, реальные параметры, нужный объём результата и симптом: задержку, чтение буферов или рост нагрузки.</li><li>Снимите обычный <code>EXPLAIN</code>, затем безопасный <code>EXPLAIN (ANALYZE, BUFFERS)</code> с теми же параметрами. Сохраните версию PostgreSQL и важные настройки стоимости.</li><li>Прочитайте дерево сверху вниз. Сверьте estimated rows, actual rows, loops и buffers. Отметьте первый узел, где оценка перестала быть правдоподобной.</li><li>Проверьте долю результата. Частое значение, широкий диапазон и выборка без <code>LIMIT</code> могут честно требовать последовательного чтения.</li><li>Проверьте статистику через <code>pg_stats</code>, выполните целевой <code>ANALYZE</code> и повторите тот же запрос. Меняйте одну гипотезу за раз.</li><li>Сверьте ключ индекса, оператор, cast, функцию, partial predicate, сортировку и список возвращаемых колонок. Только теперь выбирайте переписывание запроса, другой индекс или отсутствие изменения.</li><li>Повторите замер на целевом контуре и проверьте цену решения для записи, диска и соседних запросов.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Одинаковый SQL может получить другой план после изменения данных, кэша, настроек стоимости, версии сервера или параметров. Учебная таблица не доказывает production-эффект. Фактические миллисекунды из одного запуска не становятся гарантией времени ответа. Их нельзя обещать без повторяемого сценария и контекста.</p>\n<p>Не отключайте <code>enable_seqscan</code> как постоянный ремонт. Временное отключение может показать альтернативный план для диагностики, но не делает данные селективнее и может ухудшить другие запросы. Не создавайте partial или expression index только потому, что название выглядит точным. Индекс имеет стоимость построения, хранения и поддержания.</p>\n<p>Иногда итог расследования отрицательный: новый индекс не нужен. Если условие выбирает почти всю таблицу, а оценка близка к факту, <code>Seq Scan</code> может быть оптимальным. Тогда улучшайте границу выборки, добавляйте пагинацию или меняйте способ выдачи данных. Не маскируйте большой результат индексом.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Работа готова, когда сохранены два плана с одинаковым SQL и параметрами, названа первая подтверждённая причина, а действие повторно проверено на целевом контуре. В отчёте должны быть estimated rows, actual rows, loops, buffers, версия PostgreSQL и размер данных. Если индекс оставили, указана его цена для записи и диска. Если оставили <code>Seq Scan</code>, объяснено, почему он дешевле. Такой результат можно проверить снова, а не защищать по скриншоту.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.postgresql.org/docs/11/using-explain.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL 11: Using EXPLAIN</a> — дерево плана, оценки, фактические строки, стоимость и буферы.</li><li><a href=\"https://www.postgresql.org/docs/11/sql-explain.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL 11: EXPLAIN</a> — режимы команды и предупреждение о выполнении statement с <code>ANALYZE</code>.</li><li><a href=\"https://www.postgresql.org/docs/11/planner-stats.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL 11: Statistics Used by the Planner</a> — статистика, выборка и влияние оценок на план.</li><li><a href=\"https://www.postgresql.org/docs/11/sql-analyze.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL 11: ANALYZE</a> — команда обновляет статистику таблицы для планировщика.</li><li><a href=\"https://www.postgresql.org/docs/11/indexes-intro.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL 11: Introduction to Indexes</a> — назначение индексов и их стоимость при изменениях данных.</li><li><a href=\"https://www.postgresql.org/docs/11/indexes-expressional.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL 11: Indexes on Expressions</a> — индексы для выражений и стоимость их поддержки.</li><li><a href=\"https://www.postgresql.org/docs/11/indexes-partial.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL 11: Partial Indexes</a> — условие применимости partial index.</li></ul>"
}