Files
progcode/editorial/agent-rewrites/305.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
19 KiB
JSON
Raw 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": 305,
"slug": "editorial-2019-07-mechanism-sql-indexes",
"title": "EXPLAIN ANALYZE: как понять, почему PostgreSQL выбрал этот план",
"excerpt": "Медленный SQL не всегда требует нового индекса. Разбираем дерево плана, сравниваем оценку с фактом, проверяем статистику и выбираем действие по данным, а не по названию узла.",
"contentHtml": "<p>Запрос к каталогу внезапно стал медленным. В тикете появляется короткий вывод: «PostgreSQL выбрал Seq Scan, нужен индекс». Команда добавляет ключ, но задержка не исчезает. Записи и обновления становятся тяжелее, диск занят больше, а причина остаётся. Цена ошибки — новый объект в базе, который постоянно поддерживают, но не сокращает работу нужного запроса.</p>\n<p>План запроса не обещает использовать индекс. Он описывает расчёт планировщика: сколько строк, страниц и операций тот ожидает увидеть на каждом пути. <code>EXPLAIN</code> показывает расчёт. <code>EXPLAIN ANALYZE</code> выполняет запрос и добавляет наблюдаемый факт. Диагноз начинается там, где эти два слоя расходятся.</p>\n<h2>Механизм: план сравнивает варианты доступа</h2>\n<p>Планировщик собирает дерево операторов. Нижний узел читает таблицу или индекс. Его результат получает родительский узел. Дальше сервер фильтрует строки, соединяет наборы, сортирует их или считает агрегат. Верхний узел выдаёт ответ клиенту. Медленный верхний узел не обязательно является причиной: он может ждать большой поток строк от ребёнка.</p>\n<p><code>Seq Scan</code> читает таблицу последовательно. <code>Index Scan</code> ищет записи через индекс, а затем часто обращается к таблице за остальными колонками. Если условие возвращает большую долю строк, последовательное чтение может стоить дешевле. Если условие редкое и есть подходящий ключ, индекс может резко уменьшить объём чтения. Оба решения могут быть правильными для одной таблицы и разных параметров.</p>\n<p>Индекс хранит дополнительную структуру. INSERT и UPDATE должны поддерживать её. Индекс занимает место и может увеличивать стоимость записи. Поэтому наличие индекса отвечает только на вопрос «есть ли кандидат», но не на вопрос «выгоден ли он для этого запроса».</p>\n<h2>Сначала различаем оценку и факт</h2>\n<p>Обычный <code>EXPLAIN</code> строит план без выполнения statement. В строке <code>cost=a..b</code> указаны условные единицы планировщика, а не миллисекунды. <code>rows</code> — ожидаемое число строк на конкретном узле. <code>width</code> — оценка средней ширины строки.</p>\n<p><code>EXPLAIN ANALYZE</code> запускает statement. Он добавляет <code>actual time</code>, <code>actual rows</code> и <code>loops</code>. Фактические значения времени и строк на узле усредняются по одному запуску. Если внутренний узел получил <code>loops=10000</code>, его малое время нельзя читать как единственный вызов. Сначала учитывают число повторов, затем смотрят, сколько буферов затронуто.</p>\n<p>Опция <code>BUFFERS</code> показывает работу с буферами PostgreSQL. Она помогает отличить большое чтение страниц от повторного CPU-фильтра, но не является чистым замером диска и не заменяет профиль приложения. План нужно сохранять вместе с SQL, параметрами, версией PostgreSQL и настройками стоимости. Иначе сравнение до и после легко выдаёт ложный вывод.</p>\n<div class=\"table-scroll\"><table><caption>Первый проход по строкам EXPLAIN ANALYZE</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>actual rows</code> с размером таблицы и повторить запрос с редким значением</td><td>Не форсировать индекс; проверить объём результата и форму запроса</td></tr><tr><td><code>rows</code> сильно меньше <code>actual rows</code></td><td>Статистика устарела или не описывает распределение</td><td>Снять план с фактами, посмотреть <code>pg_stats</code>, проверить момент последнего анализа</td><td>Обновить статистику и повторить тот же план</td></tr><tr><td>Есть индекс, но условие содержит функцию</td><td>Индекс хранит исходное значение, а запрос вычисляет другое</td><td>Сопоставить ключ индекса с выражением в <code>WHERE</code></td><td>Переписать условие диапазоном или обоснованно создать expression index</td></tr><tr><td>Есть <code>Index Cond</code>, но фильтр удаляет почти всё</td><td>Индекс сузил доступ недостаточно, отбор произошёл поздно</td><td>Сравнить строки после доступа и после <code>Filter</code></td><td>Проверить составной ключ, порядок колонок и альтернативный запрос</td></tr><tr><td>Внутренний узел быстрый, но <code>loops</code> велик</td><td>Nested loop повторяет работу тысячи раз</td><td>Умножить вклад узла на число запусков и посмотреть <code>BUFFERS</code></td><td>Исследовать порядок соединения и объём промежуточного набора</td></tr></tbody></table></div>\n<h2>Учебный пример: один ключ, два результата</h2>\n<p>Следующий пример предназначен только для отдельной лабораторной базы. Он не содержит production-измерений и не задаёт ожидаемых миллисекунд. Таблица имеет миллион строк: статус <code>ready</code> встречается часто, а <code>waiting</code> — редко. Оба запроса используют один и тот же индекс. Разный план объясняется долей результата, а не тем, что индекс «сломался».</p>\n<pre><code>CREATE TABLE work_orders (\n id bigint PRIMARY KEY,\n state text NOT NULL,\n created_at timestamp NOT NULL,\n amount integer NOT NULL\n);\n\nCREATE INDEX work_orders_state_idx\n ON work_orders (state);\n\nANALYZE work_orders;\n\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT id, amount\nFROM work_orders\nWHERE state = 'waiting';\n\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT id, amount\nFROM work_orders\nWHERE state = 'ready';</code></pre>\n<p>Сначала смотрите, сколько строк реально возвращает каждый запрос. Для редкого <code>waiting</code> индекс может сократить чтение. Для частого <code>ready</code> серверу может быть дешевле пройти таблицу один раз. Не переносите результат этой фикстуры на свой сервер: ширина строк, кэш, физический порядок данных, настройки стоимости и версия меняют расчёт.</p>\n<p>Если планировщик ожидал десять строк, а получил сто тысяч, ошибка оценки может повлиять и на последующие JOIN. Nested loop, выбранный для маленького набора, становится дорогим при большом фактическом наборе. Ищите первый узел снизу, где <code>rows</code> перестал быть похож на <code>actual rows</code>. Не исправляйте каждый верхний узел по очереди.</p>\n<h2>Статистика и форма предиката</h2>\n<p>Планировщик не пересчитывает точную долю значений перед каждым SELECT. Он использует статистику, которую собирает <code>ANALYZE</code>. После массовой загрузки или изменения распределения статусов оценка может отстать от данных. Проверьте её отдельно:</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 IN ('state', 'created_at');\n\nANALYZE 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>created_at::date = DATE '2019-07-10'</code> не являются одной и той же операцией доступа. Без подходящего expression index планировщик может не использовать обычный ключ так, как ожидает автор запроса. Для диапазона можно проверить эквивалентную по смыслу форму:</p>\n<pre><code>SELECT id, amount\nFROM work_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>Это учебное преобразование требует проверки часового пояса и границ периода. Скорость не оправдывает изменение смысла даты. Если запрос всегда возвращает почти всю таблицу, новый ключ не решит проблему объёма. Тогда проверяют <code>LIMIT</code>, пагинацию, пакетную обработку или отдельную витрину.</p>\n<figure><img src=\"/assets/editorial/2019/sql-indexes-plan-reading-2019.svg\" alt=\"Дерево чтения плана: верхний Result получает строки от Filter, тот получает данные от Index Scan; на узлах сравниваются оценка строк, фактические строки, loops и buffers\" loading=\"lazy\" /><figcaption>План читается как поток данных. Сначала найдите первый узел, где оценка перестала совпадать с фактом.</figcaption></figure>\n<h2>Осторожность с EXPLAIN ANALYZE</h2>\n<p><code>EXPLAIN ANALYZE</code> выполняет statement. Для SELECT это всё равно может означать тяжёлую нагрузку. Для INSERT, UPDATE и DELETE выполнение меняет данные. Учебный изменяющий запрос запускают только в разрешённом контуре и внутри транзакции с откатом:</p>\n<pre><code>BEGIN;\n\nEXPLAIN (ANALYZE, BUFFERS)\nUPDATE work_orders\nSET amount = amount + 1\nWHERE state = 'waiting';\n\nROLLBACK;</code></pre>\n<p>Откат защищает данные в этом примере, но не отменяет нагрузку от выполнения. Для чужой production-базы сначала согласуйте время, объём и безопасный способ измерения. Если такой запуск запрещён, используйте обычный <code>EXPLAIN</code>, снимок с реплики или тестовый контур. Не объявляйте неполный план доказательством причины.</p>\n<h2>Порядок действий</h2>\n<ol><li>Запишите точный SQL, значения параметров, цель запроса и наблюдаемый симптом: задержку, рост I/O или неверный объём результата.</li><li>Снимите обычный <code>EXPLAIN</code>, затем безопасный <code>EXPLAIN (ANALYZE, BUFFERS)</code>. Для изменяющего statement заранее определите транзакционную границу.</li><li>Прочитайте дерево от результата к входам. Найдите узел, который создаёт большой поток строк, но не называйте его причиной без сравнения оценки и факта.</li><li>Сопоставьте <code>rows</code> и <code>actual rows</code> на каждом ключевом узле. При <code>loops &gt; 1</code> учтите повторения.</li><li>Разделите <code>Index Cond</code> и <code>Filter</code>. Проверьте, что именно отсекает индекс и сколько строк остаётся для поздней проверки.</li><li>Проверьте свежесть статистики и форму предиката. Выполните целевой <code>ANALYZE</code>, если это безопасно, и повторите тот же запрос.</li><li>Измените одну подтверждённую причину: предикат, статистику, состав индекса или объём работы. Не добавляйте несколько ключей одновременно.</li><li>Сравните планы в тех же условиях и проверьте отрицательный путь: частое значение, пустой результат, большой диапазон и ошибку параметра.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Одинаковый SQL может получить другой план после изменения данных, настроек памяти, стоимости I/O, версии PostgreSQL или параметров. <code>cost</code> не равен времени ответа приложения. Один запуск не описывает распределение задержек. Учебная фикстура не доказывает production-результат. Принудительное отключение <code>enable_seqscan</code> может показать альтернативу для исследования, но не лечит статистику и не является постоянным исправлением.</p>\n<p>Диагностика готова, когда сохранены точный запрос и контекст, подтверждён первый существенный разрыв между оценкой и фактом либо объяснена высокая доля результата, а выбранное действие повторно проверено тем же сценарием. Для изменения нужен сравнимый план до и после. Если <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> — дерево плана, оценка стоимости, фактические строки, время и loops.</li><li><a href=\"https://www.postgresql.org/docs/current/sql-explain.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL: EXPLAIN</a> — выполнение statement через ANALYZE, BUFFERS и ограничения для изменяющих запросов.</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> — статистика, pg_stats и её роль в оценке планировщика.</li></ul>"
}