8 lines
19 KiB
JSON
8 lines
19 KiB
JSON
{
|
||
"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> или привести к дорогому чтению heap. Смотрите на строки, циклы, буферы и время всего плана.</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 > 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 >= timestamp '2019-07-10 00:00:00'\n AND created_at < 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-эффект. Фактические миллисекунды из одного запуска не становятся SLA. Их нельзя обещать без повторяемого сценария и контекста.</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/current/using-explain.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL: Using EXPLAIN</a> — дерево плана, оценки, фактические строки, стоимость и буферы.</li><li><a href=\"https://www.postgresql.org/docs/current/planner-stats.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL: Statistics Used by the Planner</a> — статистика, выборка и влияние оценок на план.</li><li><a href=\"https://www.postgresql.org/docs/current/indexes-intro.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL: Introduction to Indexes</a> — назначение индексов и их стоимость при изменениях данных.</li></ul>"
|
||
}
|