На 31 июля 2026 года строгий аудит проходит 49 из 358 созданных материалов. Остальные 309 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
На 31 июля 2026 года строгий аудит проходит 52 из 358 созданных материалов. Остальные 306 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
Модуль экспортирует ровно три revision без полей <code>date</code> и
<code>author</code>. При прямом вызове с <code>--print-revisions</code> он пишет
только JSON, совпадающий с import-safe export. SQL-фикстура находится внутри
контента как учебный, воспроизводимый сценарий; у модуля нет второго
исполняемого режима и он не меняет базу при audit.
## Проход 1. Факты и техника — пройдено
| Утверждение или решение | Первичный источник | Проверенная граница |
| --- | --- | --- |
| Планировщик выбирает план по структуре запроса и свойствам данных; при изменении селективности может выбрать другую стратегию | [PostgreSQL 11: Using EXPLAIN](https://www.postgresql.org/docs/11/using-explain.html) | Тексты не называют <code>Seq Scan</code> ошибкой. Один и тот же ключ допускает разные планы для <code>ready</code> и <code>waiting</code> |
| <code>EXPLAIN ANALYZE</code> исполняет statement и показывает фактические rows/time; rows и time на узле усреднены на один <code>loops</code> | [PostgreSQL 11: Using EXPLAIN](https://www.postgresql.org/docs/11/using-explain.html) | В статьях <code>cost</code> не превращается в миллисекунды, а маленький внутренний узел не оценивается без учёта loops |
| <code>EXPLAIN (ANALYZE, BUFFERS)</code> нужно запускать с той же осторожностью, что и исходный statement; изменяющий пример помещён в транзакцию с rollback | [PostgreSQL 11: EXPLAIN](https://www.postgresql.org/docs/11/sql-explain.html) | В материалах нет рекомендации безопасно запускать UPDATE «только ради плана» на production |
| Селективность определяется приблизительной статистикой; <code>pg_stats</code> является читаемым представлением, а <code>ANALYZE</code> обновляет статистику | [PostgreSQL 11: Statistics Used by the Planner](https://www.postgresql.org/docs/11/planner-stats.html), [PostgreSQL 11: ANALYZE](https://www.postgresql.org/docs/11/sql-analyze.html) | <code>ANALYZE</code> не обещает индексный узел: он обновляет входные данные планировщика, после чего план снимается заново |
| B-tree — кандидат для распространённых сравнений равенства и диапазона, но индекс имеет цену поддержки | [PostgreSQL 11: Index Types](https://www.postgresql.org/docs/11/indexes-types.html), [Introduction to Indexes](https://www.postgresql.org/docs/11/indexes-intro.html) | Условие «столбец проиндексирован» не выдаётся за доказательство выигрыша для массовой выборки |
| Запрос по выражению может использовать индекс на том же выражении; такой индекс вычисляется и поддерживается при записи | [PostgreSQL 11: Indexes on Expressions](https://www.postgresql.org/docs/11/indexes-expressional.html) | Предикат <code>created_at::date</code> не объявлен автоматически плохим: предложены два варианта — семантически верный range или обоснованный expression index |
| Partial index применим, когда WHERE запроса доказуемо включает его predicate; распознавание ограничено и идёт при планировании | [PostgreSQL 11: Partial Indexes](https://www.postgresql.org/docs/11/indexes-partial.html) | Prepared statement не объявлен «никогда не использующим partial index»; текст оставляет точную границу доказуемости параметризированного условия |
### Честная граница фикстуры
Фикстура создаёт изолированную схему <code>p17_sql_index_fixture</code> в
disposable-базе, миллион синтетических строк с распределением 99/1, два B-tree
индекса, <code>ANALYZE</code> и три запроса <code>EXPLAIN (ANALYZE, BUFFERS)</code>.
Она не содержит ожидаемого дерева, вычисленных миллисекунд или фиктивных
<code>actual rows</code>: это должен вывести реальный сервер с его версией,
настройками стоимости и буферами.
В среде подготовки пакета <code>psql</code> не найден и подключение к
PostgreSQL не предоставлено. Поэтому автор **не заявляет запуск базы или
измерение плана**. Это ограничение прямо написано в каждой статье и не
заменено неподтверждённым скриншотом. Перед интеграцией fixture следует
выполнить только в отдельной disposable-базе PostgreSQL 11 и сохранить:
точный SQL и параметры, версию сервера, <code>SHOW random_page_cost</code>,
<code>SHOW seq_page_cost</code>, <code>SHOW effective_cache_size</code>, plan и
контекст нагрузки.
Вердикт прохода: **пройден**. Технические утверждения привязаны к первичной
документации PostgreSQL 11; места, зависящие от конкретного контура, названы
| Ревизия | Симптом и цена в начале | Главный вопрос | Практический артефакт и ограничение |
| --- | --- | --- | --- |
| Практика | Добавили индекс, но запрос не ускорился; цена — лишняя стоимость INSERT/UPDATE без выигрыша чтения | Как различить честный Seq Scan, плохую селективность и неверную статистику | SQL-фикстура 99/1, таблица причин, <code>pg_stats</code> и маршрут EXPLAIN; нет обещания одинакового plan на каждом сервере |
| Механизм | В тикете есть скриншот с Index/Seq Scan, но нет actual rows и параметров; цена — лечить не тот узел | Как читать estimate, actual, loops, Buffers, Index Cond и Filter как единое дерево | Безопасный EXPLAIN/rollback пример, схема потока и маршрут первого расхождения; нет выдуманного production-time |
| Полевой разбор | Индекс <code>(created_at)</code> есть, а <code>created_at::date</code> всё ещё читает таблицу; цена — раздутая схема без диагноза | Как отличить selectivity, statistics и predicate shape | Инвентаризация catalog, range versus expression index и partial predicate; timezone и PREPARE оставлены явными границами |
- Первые абзацы называют «симптом», «проблему» и цену решения. Далее каждый
текст держит одну цепочку: симптом → причина → проверка → действие →
ограничение. Вместо общих оценок названы <code>rows</code>,
| <code>--print-revisions</code> | PASS, stdout — JSON, export import-safe и содержит ровно три revision |
| <code>npm run audit:draft -- scripts/upgrade-2019-07.mjs</code> | PASS: 11 230 / 10 655 / 10 608 знаков основного текста |
| <code>xmllint --noout</code> для трёх SVG | PASS, XML корректен |
| Scope/self-review | PASS: в рабочем дереве появились только пять разрешённых файлов; SVG не содержат script, внешних URL, <code>foreignObject</code> или растровых data URI |
После подключения registry основной редактор повторил strict audit: все три
slug прошли объём 11 230 / 10 655 / 10 608 знаков, figure, таблицу, код,
маршрут действий и источники. <code>npm run build</code> завершился с кодом 0
и сгенерировал 374 статические страницы.
Выпусковой вердикт: **принят к публикации**. <code>articles.json</code> не
менялся; registry заменяет только редакционные поля по стабильному slug.
<titleid="title">Как читать EXPLAIN ANALYZE от результата к первому расхождению</title>
<descid="desc">Вертикальное дерево плана показывает Result, Filter и Index Scan. На каждом уровне сравниваются оценка строк, фактические строки, loops и buffers; расследование начинается с первого расхождения снизу.</desc>
<titleid="title">Форма условия должна соответствовать индексному ключу</title>
<descid="desc">Индекс хранит created_at. Предикат created_at::date создаёт выражение и не совпадает с ключом напрямую, а полуоткрытый диапазон сравнивает исходный created_at и может стать кандидатом для B-tree после проверки плана и семантики времени.</desc>
<titleid="title">Селективность определяет ценность индекса</title>
<descid="desc">Из миллиона заказов условие ready оставляет девятьсот девяносто тысяч строк, а waiting десять тысяч. Для обоих существует индекс, но для частого значения последовательное чтение может быть дешевле.</desc>
note:'стоимости, дерево плана, реальные строки и время в <code>EXPLAIN ANALYZE</code>; значения <code>actual time</code> и <code>rows</code> даны в среднем на один loop',
note:'<code>ANALYZE</code> действительно выполняет statement, <code>BUFFERS</code> выводит статистику буферов; для изменяющих запросов документация рекомендует транзакцию с rollback',
};
constpg11PlannerStats={
title:'PostgreSQL 11: Statistics Used by the Planner',
note:'условие запроса должно доказуемо включать predicate индекса; распознавание ограничено и происходит при планировании, а не после подстановки строк на исполнении',
excerpt:'Новый индекс есть, но PostgreSQL по-прежнему читает таблицу. Разбираем селективность, статистику и форму предиката через EXPLAIN ANALYZE, не подменяя диагноз очередным CREATE INDEX.',
readingMinutes:12,
},
[
paragraph('Симптом знакомый: на таблицу добавили индекс, запрос остался медленным, а в плане по-прежнему виден <code>Seq Scan</code>. Цена ошибки — лишний индекс на каждой вставке и обновлении, но без сокращения времени ответа. Проблема не в том, что PostgreSQL «не заметил» DDL. Планировщик мог посчитать последовательное чтение честно дешевле. До следующего <code>CREATE INDEX</code> надо увидеть, сколько строк условие действительно оставляет и на каком узле теряется время.'),
paragraph('Практика ниже строит минимальный маршрут для PostgreSQL 11: воспроизводим перекос значений, запускаем два одинаково написанных запроса с разной селективностью, читаем оценку и факт, затем выбираем действие. Это не рассказ о волшебном покрывающем индексе и не обещание одинакового плана на любом сервере. План зависит от размера строк, cache, стоимости I/O, версии, статистики и настроек. Нам нужен не узнаваемый текст узла, а проверяемая причина выбора.'),
heading('Индекс — вариант доступа, а не обязательный маршрут'),
paragraph('B-tree помогает быстро найти небольшую часть таблицы по подходящему оператору. Но после поиска по вторичному индексу серверу часто надо читать строки таблицы, проверять видимость и возвращать данные. Когда условие оставляет почти все строки, такой обход превращается в много точечных обращений и может стоить дороже одного последовательного прохода. Поэтому отсутствие <code>Index Scan</code> не доказывает неисправность индекса и не является само по себе bug report.'),
paragraph('Отделим две проверки. Первая — подходит ли условие к ключу и типу индекса: сравнение <code>=</code> или диапазон по B-tree обычно дают планировщику кандидата. Вторая — выгоден ли кандидат при текущем распределении данных и нужных колонках. Новичок часто останавливается на первой: «столбец проиндексирован». В работе важнее вторая: «какую долю таблицы вернёт конкретное значение и сколько heap-страниц придётся достать».'),
dataTable(
'Четыре причины, почему существующий индекс не обязан ускорять этот запрос',
['Наблюдение в плане','Рабочая причина','Чем подтвердить','Следующее действие'],
[
['<code>Seq Scan</code> при частом значении','Предикат возвращает большую долю таблицы; обход индекса дороже полного чтения','Сравнить <code>rows</code> с размером таблицы и выполнить контрастный запрос с редким значением','Не форсировать индекс; уточнить задачу, предикат или структуру данных'],
['Оценка строк сильно не похожа на <code>actual rows</code>','Статистика устарела, груба или не описывает перекос/связь столбцов','Снять <code>EXPLAIN (ANALYZE, BUFFERS)</code>, посмотреть <code>pg_stats</code>, время последнего ANALYZE','Обновить статистику, затем повторить сравнение; только потом менять индекс'],
['Есть индекс на колонке, но условие содержит функцию','Индекс хранит исходное значение, а запрос ищет результат выражения','Сверить буквально индексный ключ и <code>WHERE</code>','Переписать предикат в диапазон или обоснованно создать expression index'],
['Частичный индекс не выбран','Планировщик не может доказать, что <code>WHERE</code> включает predicate индекса','Посмотреть predicate в <code>pg_indexes</code> и фактический текст условия','Сделать условие явно совместимым либо отказаться от частичного индекса'],
],
),
paragraph('Таблица не говорит «всегда перепиши запрос». Например, частый статус может быть действительно нужен для выгрузки почти всех заказов. Тогда правильный результат расследования — признать последовательный проход нормальным и обсуждать пакетную обработку, ограничение выборки или отдельную витрину. Техническое решение начинается с цены операции, а не с желания увидеть слово <code>Index</code>.'),
heading('Фикстура: сначала собираем наблюдение'),
paragraph('Ниже SQL-фикстура для отдельной disposable-базы PostgreSQL 11. Она создаёт миллион строк: 990 000 со статусом <code>ready</code> и 10 000 со статусом <code>waiting</code>, строит два B-tree индекса и запускает три <code>EXPLAIN (ANALYZE, BUFFERS)</code>. Она намеренно не содержит «ожидаемый план» и миллисекунды: после запуска их должен записать тот сервер, который будет обслуживать запрос. На этой машине автор не запускал PostgreSQL: бинарник <code>psql</code> и подключение к базе недоступны. Код — воспроизводимая инструкция, не замаскированный отчёт о прогоне.'),
codeBlock(fixtureSql),
paragraph('После запуска сохранить не только дерево плана. Рядом с ним нужны версия сервера, размер таблицы, результаты <code>ANALYZE</code>, <code>SHOW random_page_cost</code>, <code>SHOW seq_page_cost</code>, <code>SHOW effective_cache_size</code> и время/характер нагрузки. Не потому, что каждый запрос требует большой анкеты, а потому, что два одинаковых SQL на ноутбуке и на production могут законно получить разные стоимости. Без контекста скриншот одного узла плохо годится для следующего решения.'),
heading('Селективность: один столбец, два разных вопроса'),
paragraph('В фикстуре запрос по <code>ready</code> соответствует почти всей таблице, а <code>waiting</code> — примерно одному проценту. Индекс в обоих случаях существует и условие одинаковой формы. Меняется не синтаксис, а ожидаемая доля результата. Для редкого значения индекс часто уменьшает объём чтения. Для частого он сначала обходит индекс, а потом всё равно возвращается к большинству строк. Поэтому один и тот же ключ может быть хорошим для операционного списка исключений и бессмысленным для экрана «все готовые».'),
paragraph('Не превращайте процент в универсальную границу вроде «после пяти процентов индекс плохой». На выбор влияют ширина строк, физическая корреляция, число нужных колонок, cache и <code>LIMIT</code>. Например, маленький <code>LIMIT</code> меняет цену старта: план может предпочесть другой путь, потому что ему не надо дочитывать всё. Вопрос к плану конкретный: сколько строк он ожидает на каждом узле и сколько действительно вернул, а не «какая у нас любимая селективность».'),
figure('/assets/editorial/2019/sql-indexes-selectivity-2019.svg','Схема селективности: из миллиона заказов предикат ready оставляет 990 тысяч строк, а waiting — 10 тысяч; индекс является кандидатом, но для частого значения последовательное чтение может быть дешевле','Индекс не предписывает план. Сначала сравниваем долю строк и стоимость пути до того, как добавлять ещё один ключ.'),
heading('Как читать первый EXPLAIN ANALYZE'),
paragraph('Начните с верхнего узла и идите вниз по отступам. Верх отвечает за результат запроса, нижние узлы — за его входы. В каждом месте сравните оценку <code>rows=...</code> с фактом <code>actual ... rows=...</code>. У <code>EXPLAIN ANALYZE</code> фактические rows и time появляются потому, что запрос был выполнен. Его стоимости <code>cost=...</code> — внутренние условные единицы, не миллисекунды. Нельзя вычесть cost одного узла из времени другого и назвать это ускорением.'),
paragraph('Дальше смотрим <code>loops</code>. Фактическое время и число строк на узле сообщаются в среднем за один запуск узла; при <code>loops > 1</code> умножаем, чтобы оценить общий вклад. Для расследования это важнее красивой строки <code>Index Scan</code>: маленький внутренний поиск, повторённый тысячами раз в nested loop, может съесть заметное время. Включённый <code>BUFFERS</code> показывает, откуда пришли страницы — из shared buffers или с чтения, — и помогает не путать CPU-предикат с I/O-ценой.'),
paragraph('Если estimate близок к actual, а план выбирает <code>Seq Scan</code> для <code>ready</code>, сначала принимаем гипотезу планировщика всерьёз: он видит массовый результат. Если estimate расходится в десять и более раз, не лечим симптом <code>SET enable_seqscan = off</code>. Такой переключатель может показать альтернативу для исследования, но он не даёт данным стать более селективными и не чинит статистику. В production его нельзя считать постоянным решением без отдельного основания.'),
heading('Статистика — вход планировщика, а не служебный шум'),
paragraph('Планировщик не перебирает таблицу перед каждым SELECT, чтобы узнать точную долю. Он использует приблизительные сведения о количестве строк, частых значениях, гистограммах и distinct-значениях. Их собирает <code>ANALYZE</code>; для ручного чтения документация советует view <code>pg_stats</code>, а не системный каталог напрямую. После большой загрузки, массового изменения статусов или перекоса новых данных убедитесь, что статистика обновилась, и только потом сравнивайте план до и после.'),
codeBlock(String.raw`
-- Сначала фиксируем контекст, затем обновляем только нужную таблицу.
paragraph('Обновление статистики не обещает смену узла. Оно делает следующую оценку честнее. Если после <code>ANALYZE</code> план и факт сблизились, а последовательный проход остался, это полезный результат: индекс не потерялся, он просто дороже для данного условия. Если цифры по-прежнему расходятся, проверяем корреляцию нескольких столбцов, типы, cast, predicate и версию параметризированного запроса. Каждый следующий шаг должен менять одну гипотезу, иначе дифф планов нельзя прочитать.'),
heading('Маршрут без угадывания'),
orderedList([
'Записать точный SQL, значения параметров, цель запроса и наблюдаемый симптом: задержку, рост I/O или ошибочный объём результата. Не начинать с имени предполагаемого индекса.',
'Снять <code>EXPLAIN (ANALYZE, BUFFERS)</code> на безопасном SELECT. Для изменения данных использовать отдельную транзакцию и откат, потому что <code>ANALYZE</code> исполняет statement.',
'Сверху вниз сравнить estimated rows, actual rows, loops и Buffers. Отметить первый узел, где оценка перестала быть похожа на факт.',
'Проверить долю результата: частое значение, широкий диапазон и выгрузка без LIMIT могут честно требовать <code>Seq Scan</code>. Не объявлять такой выбор поломкой.',
'Проверить свежесть и форму статистики через <code>pg_stats</code>, затем выполнить целевой <code>ANALYZE</code> и повторить тот же замер.',
'Сверить выражение в <code>WHERE</code>, predicate частичного индекса и реальные нужные колонки. Только после этого обсуждать другой ключ, expression/partial index или изменение запроса.',
]),
heading('Что эта практика не доказывает'),
bulletList([
'Она не измеряет production и не даёт нормативных миллисекунд: фикстура не запускалась в этой задаче и должна быть выполнена на отдельном контуре.',
'Она не утверждает, что <code>waiting</code> обязательно даст <code>Index Scan</code>. Правильный артефакт — сохранённый план конкретного сервера и объяснение его строк/буферов.',
'Она не оправдывает ручное отключение scan-стратегий как постоянную настройку. Принудительный план скрывает причину и может ухудшить соседние запросы.',
'Она не заменяет проверку влияния индекса на INSERT, UPDATE, размер диска и время построения. Ускорение чтения имеет цену поддержки структуры.',
]),
heading('Итог'),
paragraph('Новый индекс не обязан ускорять запрос, который честно возвращает почти всю таблицу, опирается на старую статистику или написан в другой форме, чем ключ. Практический ответ начинается с сохранённого <code>EXPLAIN ANALYZE</code>: оценка против факта, loops, buffers и доля результата. После такой проверки можно оставить <code>Seq Scan</code> как правильный план либо изменить именно подтверждённую причину, а не коллекционировать индексы.'),
excerpt:'План показывает Seq Scan, Index Scan и cost, но эти слова сами не объясняют задержку. Разбираем дерево, estimate versus actual, loops и Buffers, чтобы найти первую неверную гипотезу.',
readingMinutes:13,
},
[
paragraph('Симптом: в тикет кладут скриншот <code>EXPLAIN</code> и пишут «нужен индекс», хотя ни параметров, ни фактических строк, ни времени там нет. Цена такой диагностики — исправление не того узла: добавляют ключ, когда ошибается оценка, либо гоняются за <code>Seq Scan</code>, который является самым дешёвым вариантом. План — не приговор и не рецепт. Это дерево предположений о том, как получить результат; <code>EXPLAIN ANALYZE</code> позволяет положить рядом часть предположений и факт выполнения.'),
paragraph('Разберём один механизм: как из плана сделать короткую цепочку «где возникла ошибка → чем её проверить → что менять». Уровень автора 2019 года здесь намеренно прикладной: SQL, параметры, server-side plan и базовые счётчики, без обещаний универсального тюнинга. Мы не называем один узел лучшим. Мы ищем первый узел, где оценённое число строк перестало быть правдоподобным, и отличаем его от узла, который просто виден вверху дерева.'),
heading('Два режима EXPLAIN отвечают на разные вопросы'),
paragraph('Обычный <code>EXPLAIN</code> строит выбранный план и показывает оценки: стоимость, количество строк и ширину строки. Он безопаснее для тяжёлого или изменяющего statement, потому что сам запрос не выполняет. <code>EXPLAIN ANALYZE</code> выполняет statement и добавляет фактические строки и время. Поэтому он нужен, когда мы проверяем оценку, но требует той же осторожности, что и исходный запрос: SELECT может нагрузить базу, а INSERT/UPDATE/DELETE действительно изменят данные, если не выполнить их в защищённой транзакции и не сделать rollback.'),
paragraph('Добавка <code>BUFFERS</code> делает разбор полезнее для медленных случаев. Она сообщает количество буферов, затронутых на узлах, и помогает увидеть разницу между «мы нашли мало строк, но прочитали много страниц» и «мы почти ничего не читали, но много раз повторили вычисление». Это не счётчик запросов приложения и не замер диска в чистом виде: это статистика буферов PostgreSQL. Читаем её вместе с rows и loops, а не отдельно как ещё одно большое число.'),
dataTable(
'Словарь первого прохода по EXPLAIN ANALYZE',
['Поле или строка','Что она означает','Частая ошибка чтения','Практическая проверка'],
[
['<code>cost=a..b</code>','Оценка затрат в условных единицах планировщика: startup и total','Считать b миллисекундами или сравнивать её с <code>actual time</code> напрямую','Сравнивать альтернативы в одном плане, а реальную задержку брать из actual'],
['<code>rows=n</code>','Ожидаемое число строк на узле','Считать строку результатом всего запроса независимо от места в дереве','Сопоставить с <code>actual rows</code> на том же узле'],
['<code>actual ... rows=n loops=k</code>','Наблюдаемые rows и время, усреднённые по одному выполнению узла','Забыть умножить небольшой узел на <code>loops</code>','Оценить общий вклад: среднее время/строки вместе с количеством повторов'],
['<code>Index Cond</code> и <code>Filter</code>','Условие доступа через индекс и условие, проверяемое после доступа','Считать любой упомянутый индекс доказательством селективного поиска','Посмотреть, что отсекает индекс и что остаётся фильтром'],
['<code>Buffers</code>','Затронутые shared/local/temp buffers на узле и в итогах','Называть каждый shared hit чтением с диска','Смотреть сочетание hit/read, rows и повторов узла'],
],
),
paragraph('План читается от верхнего оператора к его входам, но расследование часто начинает снизу: с scan, который создаёт объём данных. Отступы показывают, чей результат подаётся родителю. Если верхний <code>Aggregate</code> медленный, это ещё не означает, что агрегирование виновато. Он мог дождаться миллионов строк от ребёнка. И наоборот, красивый <code>Index Scan</code> ниже может не спасать, если следующий <code>Filter</code> выбрасывает почти всё найденное.'),
heading('Контракт одной фикстуры и одного снимка'),
paragraph('Для чтения плана недостаточно сократить SQL до «примерно такого». Нужны точные параметры, версия сервера и форма предиката. Используем ту же минимальную схему <code>p17_sql_index_fixture.work_orders</code>: редкий <code>waiting</code>, частый <code>ready</code> и диапазон по <code>created_at</code>. Выполнить её можно только в disposable-базе PostgreSQL 11. В этой задаче такого запуска не было: <code>psql</code> не установлен и подключения нет. Поэтому ниже только команды снятия данных, а не искусственно сочинённые строки actual time.'),
codeBlock(String.raw`
-- Для уже созданной p17_sql_index_fixture.work_orders.
-- План фиксируем вместе с реальными значениями параметров.
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT id, amount
FROM p17_sql_index_fixture.work_orders
WHERE state = 'waiting';
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT id, amount
FROM p17_sql_index_fixture.work_orders
WHERE state = 'ready';
-- В изменяющем сценарии не оставляем изменения ради плана.
BEGIN;
EXPLAIN (ANALYZE, BUFFERS)
UPDATE p17_sql_index_fixture.work_orders
SET amount = amount + 1
WHERE state = 'waiting';
ROLLBACK;
`),
paragraph('Последние три строки показывают границу, а не призыв гонять UPDATE в production. Документация PostgreSQL прямо отмечает, что <code>EXPLAIN ANALYZE</code> исполняет statement; транзакция с <code>ROLLBACK</code> защищает данные от учебного изменяющего примера. Для реального расследования договоритесь о безопасном окне и о том, можно ли вообще исполнять тяжёлый запрос. Измерение, которое само создаёт инцидент, не становится качественным от слова ANALYZE.'),
heading('Estimate против actual: ищем первую развилку'),
paragraph('Главная пара чисел — <code>rows</code> и <code>actual rows</code> на одном узле. Малое отклонение естественно: статистика приблизительна, распределение меняется, стоимость не является физическим секундомером. Но кратное расхождение важно, особенно если оно начинается на нижнем scan и затем размножается через join. Планировщик выбирает join order, способ соединения и размер промежуточных наборов по оценке. Если он ожидает десять строк, а получает сто тысяч, последующий nested loop или sort может стать дорогим не потому, что этот оператор «плохой», а потому, что вход в него оказался другим.'),
paragraph('Не надо исправлять каждый верхний узел по очереди. Отмечаем первый узел снизу, где estimate заметно ушёл от факта, и задаём узкий вопрос. Предикат слишком широкий? Статистика старая? Два столбца коррелируют, но известны планировщику по одному? Тип параметра ведёт к cast? Условие спрятано за функцией? Это уже проверяемые гипотезы. Фраза «оптимизатор тупит» не говорит, какую из них можно опровергнуть.'),
paragraph('Есть ещё ловушка <code>loops</code>. PostgreSQL показывает actual time и actual rows как средние за один запуск узла, чтобы сравнивать их с оценками. Внутренний узел nested loop с <code>actual time=0.15..0.20</code> может казаться невинным, но при <code>loops=10000</code> его вклад нельзя оценивать как две десятые миллисекунды. Умножаем среднее на loops, затем смотрим Buffers: это связывает повторение логики с количеством затронутых страниц.'),
figure('/assets/editorial/2019/sql-indexes-plan-reading-2019.svg','Дерево чтения плана: верхний Result получает строки от узла Filter, тот — от Index Scan; на каждом узле сопоставляются estimate rows, actual rows, loops и buffers, а расследование начинается с первого расхождения снизу','План читается как поток данных. Сначала находим место, где ожидание перестало совпадать с фактом, затем меняем только связанную гипотезу.'),
heading('Index Cond, Filter и цена поздней проверки'),
paragraph('План с названием индекса не равен плану с точным поиском. У <code>Index Cond</code> видно условие, которое ограничивает доступ через индекс. У <code>Filter</code> видно условие, применённое к строкам после выбранного доступа. Это нормальная конструкция: один индекс может сузить набор, второй предикат проверяется позже. Но если индекс отдаёт большую часть таблицы, а filter выбрасывает почти всё, проверяем порядок ключей, форму выражения и статистику, а не объявляем любой Index Scan успехом.'),
paragraph('Пример: есть индекс <code>(state)</code>, а запрос одновременно выбирает диапазон времени. Если <code>state = 'ready'</code> почти ничего не отсекает, он может быть плохой точкой старта даже при наличии <code>Index Cond</code>. Если реальный пользовательский путь всегда просит редкий state и короткий диапазон, исследуем составной индекс и порядок его колонок на подтверждённых запросах. Если путь выбирает почти всё, возможно, правильнее остаться на последовательном чтении. Индекс проектируют от устойчивой нагрузки, не от одного названия поля.'),
heading('Статистика, корреляция и честная проверка'),
paragraph('Стандартная статистика хранится по отдельным столбцам. Планировщик обычно предполагает независимость условий, а в реальных таблицах столбцы часто связаны: город и почтовый индекс, тип заказа и статус, страна и валюта. PostgreSQL 11 поддерживает расширенную статистику, но это не кнопка «собрать всё». Сначала нужен доказанный плохой estimate на конкретном сочетании условий. Потом можно рассмотреть <code>CREATE STATISTICS</code> для этой группы и снова выполнить <code>ANALYZE</code>.'),
codeBlock(String.raw`
-- Пример исследовательского шага для реально коррелирующих условий.
-- Не создавайте статистику на все пары колонок без плана и симптома.
paragraph('Этот пример не обещает, что зависимости помогут диапазону по времени: документация PostgreSQL 11 прямо ограничивает functional dependencies простыми equality-условиями с константами. Поэтому в журнале расследования фиксируем не «создали CREATE STATISTICS», а исходный estimate, форму условия, ожидание от статистики и результат повторного плана. Если условие не попадает в границы механизма, не надо приписывать ему эффект.'),
heading('Порядок чтения, который можно повторить'),
orderedList([
'Сохранить точный SQL и параметры. Отдельно записать, что болит: время ответа, чтение буферов, неверный join order или рост после изменения данных.',
'Снять plain <code>EXPLAIN</code>, затем безопасный <code>EXPLAIN (ANALYZE, BUFFERS)</code>. Не запускать write-statement без транзакционной границы и разрешения на нагрузку.',
'Прочитать дерево от результата к входам и отметить scan/join, который создаёт крупный поток строк. Не считать верхний узел причиной только потому, что он напечатан первым.',
'На каждом ключевом узле сверить <code>rows</code> с <code>actual rows</code>; при <code>loops > 1</code> учесть повторения.',
'Разобрать <code>Index Cond</code> отдельно от <code>Filter</code> и посмотреть Buffers. Это отделяет доступ к строкам от позднего отбора и повторного I/O.',
'Изменить одну подтверждённую причину: статистику, форму предиката, ключ индекса или сам объём работы. Переснять такой же план и сравнить данные, а не только имя scan.',
]),
heading('Границы вывода'),
bulletList([
'Фактическое время из <code>EXPLAIN ANALYZE</code> включает выполнение под его профилированием; не переносите одно значение как SLA для приложения.',
'Одинаковый SQL может получить другой план после изменения данных, настроек стоимости, памяти, версии или параметров. Снимок надо хранить с контекстом.',
'Принудительное отключение планов может помочь увидеть альтернативу, но не заменяет статистику и не является автоматическим production-fix.',
'Расширенная статистика требует конкретной корреляции и конкретного неверного estimate. Собирать её на все комбинации означает добавлять стоимость без диагноза.',
]),
heading('Итог'),
paragraph('Хорошее чтение <code>EXPLAIN ANALYZE</code> не начинается с охоты на <code>Index Scan</code>. Оно начинается с точного SQL, затем сопоставляет estimate и actual на узлах, учитывает loops, различает Index Cond и Filter, читает Buffers и ищет первую неверную предпосылку. После этого индекс становится одним из вариантов действия, а не ответом, выбранным до вопроса.'),
excerpt:'Не каждый WHERE сопоставим с индексным ключом, а частичный индекс не читает мысли prepared statement. Полевой разбор: селективность, ANALYZE, выражения и predicate shape.',
readingMinutes:13,
},
[
paragraph('Симптом: в таблице уже есть индекс на <code>created_at</code>, но запрос за один день идёт через <code>Seq Scan</code>; после создания ещё одного индекса ситуация не изменилась. Цена такого ремонта — раздутая схема, медленнее запись и отсутствие объяснения в следующем инциденте. В полевом разборе не будем угадывать «правильный индекс». Сначала отделим три причины: условие выбирает слишком много строк, статистика неверно оценивает долю или сам предикат не совпадает с формой ключа.'),
paragraph('Нужен один доказуемый маршрут: точный SQL → <code>EXPLAIN (ANALYZE, BUFFERS)</code> → первое расхождение estimate/fact → проверка статистики и формы <code>WHERE</code> → минимальная правка → тот же план повторно. Это не требует специального ORM и остаётся полезным, когда query пришёл из PHP, фоновой задачи или admin-экспорта. В 2019 году достаточно видеть серверный SQL и параметры; не нужно придумывать платформенные метрики, чтобы не делать индекс вслепую.'),
heading('До индекса фиксируем четыре факта'),
paragraph('Первая ловушка — знать только имя индекса. Для расследования нужны: точный индексный ключ и predicate, точное условие запроса с типами параметров, фактическая доля результата и свежесть статистики. <code>\\d</code> в psql удобен, но SQL-проверка переносимее: <code>pg_indexes</code> показывает определение, <code>pg_stats</code> — доступную статистику. Это не делает внутренние каталоги читабельным API для приложения; это инструменты диагностики для инженера, который должен сопоставить запись с планом.'),
paragraph('Последний запрос нарочно неудобен для обычного индекса на <code>created_at</code>: в условии написано выражение <code>created_at::date</code>, а не исходная колонка. Не делайте вывод по одной строке плана заранее. Сначала сохраните plan и rows. Затем спросите, какой контракт у бизнес-условия: нам нужна календарная дата в timestamp without time zone, или точный диапазон в определённой временной зоне? От ответа зависит корректная перепись, а не только скорость.'),
dataTable(
'Полевая карта: условие, причина и безопасная следующая проверка',
['Симптом','Вероятная причина','Что показать в плане/каталогах','Действие после подтверждения'],
[
['B-tree есть, <code>Seq Scan</code> для частого state','Селективность мала: нужно вернуть большую долю строк','<code>rows</code> и <code>actual rows</code> близки, <code>most_common_freqs</code> показывает частое значение','Оставить последовательный путь или изменить объём работы; не добавлять дубликат индекса'],
['Оценка 100, факт 100 000','Статистика устарела/груба либо условия коррелируют','Первый нижний узел с расхождением, <code>pg_stats</code>, дата/объём недавних изменений','Выполнить целевой ANALYZE, затем исследовать корректную расширенную статистику'],
['<code>created_at::date</code> при индексе <code>(created_at)</code>','Форма предиката ищет выражение, а индекс содержит исходный timestamp','Сравнить текст <code>indexdef</code> и <code>Filter</code>/<code>Index Cond</code>','Переписать в корректный диапазон или обосновать expression index'],
['Partial index не участвует в prepared query','Планировщик не может доказать predicate для параметра или иной записи условия','Сверить <code>WHERE</code> индекса и SQL до подстановки','Сделать условие доказуемым, сменить ключ или не применять partial index к этому маршруту'],
],
),
paragraph('Эта карта не заменяет участие владельца данных. Например, «частый state» может быть следствием нового сценария, а не статистической ошибки. В таком случае сначала проверить продуктовую нагрузку: правда ли экрану нужна вся выборка или UI перестал пагинировать. Индекс решает путь доступа к уже запрошенным строкам; он не превращает экспорт миллиона записей в список из десяти.'),
heading('Форма предиката: range чаще честнее, чем cast'),
paragraph('Если <code>created_at</code> имеет тип <code>timestamp without time zone</code> и задача — один календарный день, диапазон сохраняет исходную колонку слева от сравнения. Это обычно проще сопоставить с B-tree индексом на <code>created_at</code>, чем выражение <code>created_at::date</code>. При этом диапазон не должен быть механической заменой: для <code>timestamptz</code> границы дня выбирают в явной бизнес-временной зоне. Сначала фиксируем семантику времени, потом меняем SQL.'),
codeBlock(String.raw`
-- Для timestamp without time zone: полуоткрытый диапазон одного дня.
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM p17_sql_index_fixture.work_orders
WHERE created_at >= timestamp '2019-07-10 00:00:00'
AND created_at < timestamp '2019-07-11 00:00:00';
-- Альтернатива только для действительно устойчивого выражения в запросах:
CREATE INDEX work_orders_created_date_idx
ON p17_sql_index_fixture.work_orders ((created_at::date));
`),
paragraph('Expression index — не бесплатная оптимизация. PostgreSQL хранит вычисленное выражение и поддерживает его при вставках и изменениях. Он уместен, когда выражение является стабильным контрактом многих запросов и переписать предикат нельзя или нельзя без потери смысла. Если нужен только день из timestamp, диапазон часто проще читать, покрывает один интервал и не требует дублировать вычисленное значение. Решение фиксируем рядом с реальным SQL и его планом, а не только в миграции.'),
paragraph('В этой задаче нет реального запуска PostgreSQL: <code>psql</code> не найден и тестовая база не предоставлена. Примеры выше — SQL-фикстуры, которые следует выполнять на disposable-контуре PostgreSQL 11 после создания схемы из практической статьи. В них нет заявленных rows, времени и названия узла: результат должен появиться из конкретного сервера вместе с его настройками. Это ограничение важнее красивого, но вымышленного <code>Index Scan</code>.'),
figure('/assets/editorial/2019/sql-indexes-predicate-shape-2019.svg','Схема формы предиката: обычный индекс хранит created_at; выражение created_at::date не совпадает с ключом напрямую, а полуоткрытый диапазон сравнивает исходный created_at и может быть кандидатом для B-tree','Переписывать предикат можно только после проверки его смысловой границы. Скорость не оправдывает ошибочный день или неправильную временную зону.'),
heading('Частичный индекс: планировщик должен доказать условие'),
paragraph('Частичный индекс хранит только строки, удовлетворяющие своему predicate. Это полезно, когда интересующая нагрузка постоянно работает с небольшой, заранее понятной частью данных. Но PostgreSQL использует такой индекс только когда при планировании может распознать, что <code>WHERE</code> запроса математически включает predicate индекса. Система не пытается доказывать произвольную эквивалентность всех возможных SQL-выражений. Поэтому похожий человеку текст не всегда является подходящим текстом для планировщика.'),
codeBlock(String.raw`
CREATE INDEX work_orders_waiting_created_idx
ON p17_sql_index_fixture.work_orders (created_at)
WHERE state = 'waiting';
-- Условие явно включает predicate: кандидат для partial index.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM p17_sql_index_fixture.work_orders
WHERE state = 'waiting'
AND created_at >= timestamp '2019-07-10 00:00:00'
AND created_at < timestamp '2019-07-11 00:00:00';
-- Не считайте заранее эквивалентным prepared SQL с неизвестным значением.
PREPARE orders_by_state(text) AS
SELECT id FROM p17_sql_index_fixture.work_orders
WHERE state = $1;
`),
paragraph('В последнем примере нет утверждения «partial index никогда не используется с PREPARE». Документация говорит точнее: сопоставление происходит при планировании, а параметризованная оговорка не может в общем случае доказать predicate, который должен быть истинным для всех возможных значений. Поэтому до DDL нужно снять настоящий план применения с реальными параметрами и режимом планирования вашего клиента. Если общее условие не доказуемо, partial index — неправильная ставка для этого маршрута, даже если для одного значения он выглядит заманчиво.'),
heading('Когда проблема в статистике, а не в ключе'),
paragraph('Если выражение и key совпадают, но estimate всё равно сильно расходится с actual, возвращаемся к данным. <code>ANALYZE</code> хранит приблизительные частые значения, гистограммы и distinct-оценки; после изменений они могут устареть, а статистика одного столбца не знает взаимосвязь с другим. Сначала запускаем целевой <code>ANALYZE</code> в согласованное время, затем повторяем один и тот же план. Не смешиваем этот шаг с созданием нового индекса, иначе исчезает возможность понять, что действительно изменило выбор.'),
codeBlock(String.raw`
ANALYZE p17_sql_index_fixture.work_orders;
-- Только если есть доказанное расхождение на двух связанных равенствах:
paragraph('Расширенная статистика также имеет границы. В PostgreSQL 11 functional dependencies применяются к простым equality-условиям с константами; они не обещают исправить все range, LIKE, выражения или сравнения столбцов между собой. Это достаточная причина не добавлять объект «на всякий случай». В отчёте расследования показывают исходный и повторный plan, точное условие, estimate/actual и действие с индексом. Если эффект не подтверждён, объект и гипотеза не получают статуса решения.'),
heading('Полевой маршрут на один запрос'),
orderedList([
'Взять SQL из реального места вызова вместе с типами и значениями параметров. Указать, сколько строк требуется пользователю и есть ли LIMIT, сортировка или пагинация.',
'Снять <code>EXPLAIN (ANALYZE, BUFFERS)</code> на безопасном контуре. Сохранить plan, версию PostgreSQL и важные cost-настройки; не переносить в отчёт вымышленные цифры.',
'Найти первый узел, где estimate rows расходится с actual rows. Если расхождения нет, сначала признать выбранный Seq Scan или Bitmap путь обоснованным.',
'Сверить <code>Index Cond</code>, <code>Filter</code>, <code>pg_indexes.indexdef</code> и точную форму WHERE. Проверить cast, функцию, operator, составной ключ и predicate partial index.',
'Проверить <code>pg_stats</code>, выполнить целевой ANALYZE и повторить тот же plan. При доказанной корреляции рассмотреть ровно одну подходящую statistics object.',
'Сделать минимальную правку: семантически верный range, expression index или иной ключ для подтверждённой нагрузки. Сравнить не только имя узла, но rows, loops, buffers, время и цену записи.',
]),
heading('Что нельзя обещать по одному плану'),
bulletList([
'Нельзя обещать вечный <code>Index Scan</code>: данные, параметры, cache и настройки меняются, а планировщик вправе выбрать другой путь.',
'Нельзя считать <code>EXPLAIN ANALYZE</code> безопасной сухой проверкой: он исполняет statement и может заметно нагрузить сервер.',
'Нельзя подменять семантику времени быстрым cast/range. Для <code>timestamptz</code> календарные границы принадлежат выбранной бизнес-временной зоне.',
'Нельзя добавлять partial/expressional index без оценки стоимости обновлений, размера и устойчивости реального query shape.',
]),
heading('Итог'),
paragraph('Когда индекс есть, а <code>Seq Scan</code> остался, это не повод переписывать схему наугад. Проверяем селективность, точность estimate, свежесть статистики и форму предиката. Условие <code>created_at::date</code>, частый state и недоказуемый predicate partial index — три разные причины с разными исправлениями. Сохранённый план и одна проверенная правка дают повторяемый диагноз; серия DDL без этого только прячет проблему под новыми именами.'),
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.