diff --git a/editorial/production/README.md b/editorial/production/README.md index b87f4c8..050a090 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 49 из 358 созданных материалов. Остальные 309 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 52 из 358 созданных материалов. Остальные 306 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2019-07-draft.md b/editorial/reviews/2019-07-draft.md new file mode 100644 index 0000000..ef00b10 --- /dev/null +++ b/editorial/reviews/2019-07-draft.md @@ -0,0 +1,138 @@ +# П17 · июль 2019 · SQL-индексы и планы PostgreSQL — тройное ревью + +Статус: **принят в публикационный слой 31 июля 2026 года**. Registry +накладывает три ревизии по стабильным slug и сохраняет дату и автора базового +архива: + +- editorial-2019-07-practice-sql-indexes; +- editorial-2019-07-mechanism-sql-indexes; +- editorial-2019-07-field-sql-indexes. + +Созданы только пять файлов: + +- web/scripts/upgrade-2019-07.mjs; +- web/public/assets/editorial/2019/sql-indexes-selectivity-2019.svg; +- web/public/assets/editorial/2019/sql-indexes-plan-reading-2019.svg; +- web/public/assets/editorial/2019/sql-indexes-predicate-shape-2019.svg; +- этот файл. + +Модуль экспортирует ровно три revision без полей date и +author. При прямом вызове с --print-revisions он пишет +только JSON, совпадающий с import-safe export. SQL-фикстура находится внутри +контента как учебный, воспроизводимый сценарий; у модуля нет второго +исполняемого режима и он не меняет базу при audit. + +## Проход 1. Факты и техника — пройдено + +| Утверждение или решение | Первичный источник | Проверенная граница | +| --- | --- | --- | +| Планировщик выбирает план по структуре запроса и свойствам данных; при изменении селективности может выбрать другую стратегию | [PostgreSQL 11: Using EXPLAIN](https://www.postgresql.org/docs/11/using-explain.html) | Тексты не называют Seq Scan ошибкой. Один и тот же ключ допускает разные планы для ready и waiting | +| EXPLAIN ANALYZE исполняет statement и показывает фактические rows/time; rows и time на узле усреднены на один loops | [PostgreSQL 11: Using EXPLAIN](https://www.postgresql.org/docs/11/using-explain.html) | В статьях cost не превращается в миллисекунды, а маленький внутренний узел не оценивается без учёта loops | +| EXPLAIN (ANALYZE, BUFFERS) нужно запускать с той же осторожностью, что и исходный statement; изменяющий пример помещён в транзакцию с rollback | [PostgreSQL 11: EXPLAIN](https://www.postgresql.org/docs/11/sql-explain.html) | В материалах нет рекомендации безопасно запускать UPDATE «только ради плана» на production | +| Селективность определяется приблизительной статистикой; pg_stats является читаемым представлением, а ANALYZE обновляет статистику | [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) | ANALYZE не обещает индексный узел: он обновляет входные данные планировщика, после чего план снимается заново | +| 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) | Предикат created_at::date не объявлен автоматически плохим: предложены два варианта — семантически верный range или обоснованный expression index | +| Partial index применим, когда WHERE запроса доказуемо включает его predicate; распознавание ограничено и идёт при планировании | [PostgreSQL 11: Partial Indexes](https://www.postgresql.org/docs/11/indexes-partial.html) | Prepared statement не объявлен «никогда не использующим partial index»; текст оставляет точную границу доказуемости параметризированного условия | + +### Честная граница фикстуры + +Фикстура создаёт изолированную схему p17_sql_index_fixture в +disposable-базе, миллион синтетических строк с распределением 99/1, два B-tree +индекса, ANALYZE и три запроса EXPLAIN (ANALYZE, BUFFERS). +Она не содержит ожидаемого дерева, вычисленных миллисекунд или фиктивных +actual rows: это должен вывести реальный сервер с его версией, +настройками стоимости и буферами. + +В среде подготовки пакета psql не найден и подключение к +PostgreSQL не предоставлено. Поэтому автор **не заявляет запуск базы или +измерение плана**. Это ограничение прямо написано в каждой статье и не +заменено неподтверждённым скриншотом. Перед интеграцией fixture следует +выполнить только в отдельной disposable-базе PostgreSQL 11 и сохранить: +точный SQL и параметры, версию сервера, SHOW random_page_cost, +SHOW seq_page_cost, SHOW effective_cache_size, plan и +контекст нагрузки. + +Вердикт прохода: **пройден**. Технические утверждения привязаны к первичной +документации PostgreSQL 11; места, зависящие от конкретного контура, названы +планом проверки, а не выполненным замером. + +## Проход 2. Редактура и голос М2 / 2019 — пройдено + +| Ревизия | Симптом и цена в начале | Главный вопрос | Практический артефакт и ограничение | +| --- | --- | --- | --- | +| Практика | Добавили индекс, но запрос не ускорился; цена — лишняя стоимость INSERT/UPDATE без выигрыша чтения | Как различить честный Seq Scan, плохую селективность и неверную статистику | SQL-фикстура 99/1, таблица причин, pg_stats и маршрут EXPLAIN; нет обещания одинакового plan на каждом сервере | +| Механизм | В тикете есть скриншот с Index/Seq Scan, но нет actual rows и параметров; цена — лечить не тот узел | Как читать estimate, actual, loops, Buffers, Index Cond и Filter как единое дерево | Безопасный EXPLAIN/rollback пример, схема потока и маршрут первого расхождения; нет выдуманного production-time | +| Полевой разбор | Индекс (created_at) есть, а created_at::date всё ещё читает таблицу; цена — раздутая схема без диагноза | Как отличить selectivity, statistics и predicate shape | Инвентаризация catalog, range versus expression index и partial predicate; timezone и PREPARE оставлены явными границами | + +- Первые абзацы называют «симптом», «проблему» и цену решения. Далее каждый + текст держит одну цепочку: симптом → причина → проверка → действие → + ограничение. Вместо общих оценок названы rows, + actual rows, loops, Buffers, + pg_stats, Index Cond и Filter. +- Голос соответствует М2 / 2019: автор уверенно работает с SQL, + PostgreSQL, серверным планом, query shape и базовой доставкой данных, но не + приписывает себе управление платформой, SLO, распределённую трассировку, + Kubernetes или продуктовые метрики поздних лет. +- Тон краткий и прагматичный. Нет абсолютов «индекс всегда ускоряет» или + «Seq Scan всегда плох». Каждый совет требует наблюдаемой проверки до DDL. +- Длина, количество разделов, таблица с caption/thead + и scope, код, упорядоченный маршрут, figure с alt/caption и + два или больше первичных источника дополнительно проверяются draft gate. + +Вердикт прохода: **пройден**. Тексты развивают автора от практической +диагностики к более системному чтению планов, но остаются на его правдоподобной +глубине 2019 года. + +## Проход 3. Визуал и выпуск — пройдено в пределах автономного пакета + +- sql-indexes-selectivity-2019.svg сопоставляет одинаковый + индекс с двумя распределениями результата: 990 000 ready и + 10 000 waiting. Нижняя карточка явно говорит сравнить rows, + actual rows, loops и Buffers, а не ждать обязательный Index Scan. +- sql-indexes-plan-reading-2019.svg показывает вертикальный поток + данных от scan к результату и помечает первое расхождение estimate/actual + как точку расследования. Схема не подменяет настоящий plan и не содержит + цифр, объявленных измерением. +- sql-indexes-predicate-shape-2019.svg отделяет индексный ключ + (created_at), выражение created_at::date и + полуоткрытый range. Низ схемы оставляет обязательную проверку timezone и + EXPLAIN ANALYZE, чтобы скорость не сломала границу календарного дня. +- В каждом SVG есть title, desc, + role="img" и связка aria-labelledby. У картинок в + статьях есть самостоятельный содержательный alt и figcaption. + В SVG нет JavaScript, внешних URL или растровых вложений. Вертикальные + viewBox и короткие подписи дают масштабируемую композицию в контейнере + статьи; XML-проверка входит в выпускной набор. +- Автономный пакет не изменяет registry, поэтому не заявляет production build + или опубликованный browser-page. Реальный рендер страницы, strict audit + общего архива и build остаются задачами интегратора после подключения + revision по slug. + +### Фактические проверки + +```text +node --check web/scripts/upgrade-2019-07.mjs +cd web && npm run audit:draft -- scripts/upgrade-2019-07.mjs +xmllint --noout \ + web/public/assets/editorial/2019/sql-indexes-selectivity-2019.svg \ + web/public/assets/editorial/2019/sql-indexes-plan-reading-2019.svg \ + web/public/assets/editorial/2019/sql-indexes-predicate-shape-2019.svg +``` + +Результат финального запуска 31 июля 2026 года: + +| Проверка | Результат | +| --- | --- | +| node --check | PASS, синтаксис модуля корректен | +| --print-revisions | PASS, stdout — JSON, export import-safe и содержит ровно три revision | +| npm run audit:draft -- scripts/upgrade-2019-07.mjs | PASS: 11 230 / 10 655 / 10 608 знаков основного текста | +| xmllint --noout для трёх SVG | PASS, XML корректен | +| Scope/self-review | PASS: в рабочем дереве появились только пять разрешённых файлов; SVG не содержат script, внешних URL, foreignObject или растровых data URI | + +После подключения registry основной редактор повторил strict audit: все три +slug прошли объём 11 230 / 10 655 / 10 608 знаков, figure, таблицу, код, +маршрут действий и источники. npm run build завершился с кодом 0 +и сгенерировал 374 статические страницы. + +Выпусковой вердикт: **принят к публикации**. articles.json не +менялся; registry заменяет только редакционные поля по стабильному slug. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index 3508a17..87eee5d 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -12,6 +12,7 @@ import { revisions as february2019Revisions } from '../scripts/upgrade-2019-02.m import { revisions as march2019Revisions } from '../scripts/upgrade-2019-03.mjs'; import { revisions as april2019Revisions } from '../scripts/upgrade-2019-04.mjs'; import { revisions as may2019Revisions } from '../scripts/upgrade-2019-05.mjs'; +import { revisions as july2019Revisions } from '../scripts/upgrade-2019-07.mjs'; // This layer replaces archived source entries without losing their stable slug and date. export const editorialRevisions = [ @@ -29,4 +30,5 @@ export const editorialRevisions = [ ...march2019Revisions, ...april2019Revisions, ...may2019Revisions, + ...july2019Revisions, ]; diff --git a/web/public/assets/editorial/2019/sql-indexes-plan-reading-2019.svg b/web/public/assets/editorial/2019/sql-indexes-plan-reading-2019.svg new file mode 100644 index 0000000..194d55f --- /dev/null +++ b/web/public/assets/editorial/2019/sql-indexes-plan-reading-2019.svg @@ -0,0 +1,49 @@ + + Как читать EXPLAIN ANALYZE от результата к первому расхождению + Вертикальное дерево плана показывает Result, Filter и Index Scan. На каждом уровне сравниваются оценка строк, фактические строки, loops и buffers; расследование начинается с первого расхождения снизу. + + + + + + + + + + + План — поток данных, не список красивых узлов + + Aggregate / Result + rows=1 · actual rows=1 · loops=1 + + + Filter + rows=100 · actual rows=100 000 + первое заметное расхождение: ищем причину + + + Index Scan + Index Cond: state = 'ready' + Buffers: shared hit/read · loops=1 + + Проверить + 1. rows vs actual + 2. loops + 3. Index Cond + 4. Buffers + + + Не лечим верхний узел: начинаем с первого места, где оценка потеряла связь с фактом. + diff --git a/web/public/assets/editorial/2019/sql-indexes-predicate-shape-2019.svg b/web/public/assets/editorial/2019/sql-indexes-predicate-shape-2019.svg new file mode 100644 index 0000000..0dcef7e --- /dev/null +++ b/web/public/assets/editorial/2019/sql-indexes-predicate-shape-2019.svg @@ -0,0 +1,50 @@ + + Форма условия должна соответствовать индексному ключу + Индекс хранит created_at. Предикат created_at::date создаёт выражение и не совпадает с ключом напрямую, а полуоткрытый диапазон сравнивает исходный created_at и может стать кандидатом для B-tree после проверки плана и семантики времени. + + + + + + + + Индекс хранит ключ, а не намерение запроса + + B-tree: (created_at) + ключ — исходный timestamp без cast + + + + Выражение в WHERE + created_at::date + = DATE '2019-07-10' + ключ и выражение разные + проверяем range или + обоснованный expression index + + + Полуоткрытый range + created_at >= day + AND created_at < day + 1 + исходный ключ сохранён + кандидат для B-tree: + подтверждаем планом + + + + Сначала смысл времени, затем EXPLAIN ANALYZE + Проверить timezone, rows, Index Cond, Filter и Buffers. + Скорость не оправдывает неверную календарную границу. + diff --git a/web/public/assets/editorial/2019/sql-indexes-selectivity-2019.svg b/web/public/assets/editorial/2019/sql-indexes-selectivity-2019.svg new file mode 100644 index 0000000..e7fcbc0 --- /dev/null +++ b/web/public/assets/editorial/2019/sql-indexes-selectivity-2019.svg @@ -0,0 +1,47 @@ + + Селективность определяет ценность индекса + Из миллиона заказов условие ready оставляет девятьсот девяносто тысяч строк, а waiting десять тысяч. Для обоих существует индекс, но для частого значения последовательное чтение может быть дешевле. + + + + + + + + Индекс — кандидат; селективность решает цену + + work_orders + 1 000 000 строк после ANALYZE + + + + WHERE state = 'ready' + 990 000 строк + почти вся таблица + индекс + heap-обращения + могут стоить дороже Seq Scan + + WHERE state = 'waiting' + 10 000 строк + около 1% таблицы + индекс может сузить путь + проверяем фактическим планом + + + + EXPLAIN (ANALYZE, BUFFERS) + сравнить rows, actual rows, loops и Buffers до нового CREATE INDEX + diff --git a/web/scripts/upgrade-2019-07.mjs b/web/scripts/upgrade-2019-07.mjs new file mode 100644 index 0000000..807986e --- /dev/null +++ b/web/scripts/upgrade-2019-07.mjs @@ -0,0 +1,479 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function paragraph(text) { + return '

' + text + '

'; +} + +function heading(text) { + return '

' + text + '

'; +} + +function codeBlock(code) { + return '
' + escapeHtml(String(code).trim()) + '
'; +} + +function figure(src, alt, caption) { + return '
' + alt + '
' + caption + '
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function bulletList(items) { + return ''; +} + +function dataTable(caption, headers, rows) { + const head = '' + headers.map((header) => '' + header + '').join('') + ''; + const body = '' + rows.map((row) => '' + row.map((cell) => '' + cell + '').join('') + '').join('') + ''; + return '
' + head + body + '
' + caption + '
'; +} + +function sourceList(items) { + return ''; +} + +function visibleText(html) { + return html + .replace(/<[^>]*>/g, ' ') + .replaceAll(' ', ' ') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replace(/\s+/g, ' ') + .trim(); +} + +function proseText(html) { + return visibleText( + html + .replace(/
[\s\S]*?<\/code><\/pre>/g, '')
+      .replace(/
[\s\S]*?<\/figure>/g, '') + .replace(/
[\s\S]*?<\/div>/g, ''), + ); +} + +function createRevision(meta, bodyParts, sources) { + const bodyHtml = bodyParts.join('\n'); + const proseLength = proseText(bodyHtml).length; + + if (proseLength < 5000 || proseLength > 15000) { + throw new Error(meta.slug + ': prose length must be 5000–15000, got ' + proseLength); + } + + if (sources.length < 2) { + throw new Error(meta.slug + ': at least two primary sources are required'); + } + + return { + ...meta, + contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'), + proseLength, + }; +} + +const pg11Explain = { + title: 'PostgreSQL 11: Using EXPLAIN', + url: 'https://www.postgresql.org/docs/11/using-explain.html', + note: 'стоимости, дерево плана, реальные строки и время в EXPLAIN ANALYZE; значения actual time и rows даны в среднем на один loop', +}; + +const pg11SqlExplain = { + title: 'PostgreSQL 11: EXPLAIN', + url: 'https://www.postgresql.org/docs/11/sql-explain.html', + note: 'ANALYZE действительно выполняет statement, BUFFERS выводит статистику буферов; для изменяющих запросов документация рекомендует транзакцию с rollback', +}; + +const pg11PlannerStats = { + title: 'PostgreSQL 11: Statistics Used by the Planner', + url: 'https://www.postgresql.org/docs/11/planner-stats.html', + note: 'селективность оценивается по приблизительной статистике; pg_stats удобнее прямого чтения pg_statistic, а корреляцию столбцов не ловят обычные одноколоночные статистики', +}; + +const pg11Analyze = { + title: 'PostgreSQL 11: ANALYZE', + url: 'https://www.postgresql.org/docs/11/sql-analyze.html', + note: 'ANALYZE собирает приблизительную выборку, обновляет статистику для планировщика и позволяет повышать target ценой времени и места', +}; + +const pg11IndexesIntro = { + title: 'PostgreSQL 11: Introduction to Indexes', + url: 'https://www.postgresql.org/docs/11/indexes-intro.html', + note: 'индекс ускоряет поиск небольшого числа строк, но имеет цену на изменениях; после создания может требоваться актуальная статистика', +}; + +const pg11ExpressionIndexes = { + title: 'PostgreSQL 11: Indexes on Expressions', + url: 'https://www.postgresql.org/docs/11/indexes-expressional.html', + note: 'выражение в условии требует индекса на том же выражении; такие индексы дороже поддерживать при INSERT и non-HOT UPDATE', +}; + +const pg11PartialIndexes = { + title: 'PostgreSQL 11: Partial Indexes', + url: 'https://www.postgresql.org/docs/11/indexes-partial.html', + note: 'условие запроса должно доказуемо включать predicate индекса; распознавание ограничено и происходит при планировании, а не после подстановки строк на исполнении', +}; + +const pg11IndexTypes = { + title: 'PostgreSQL 11: Index Types', + url: 'https://www.postgresql.org/docs/11/indexes-types.html', + note: 'B-tree покрывает распространённые сравнения равенства и диапазона; возможность использовать индекс зависит от оператора и формы условия', +}; + +const fixtureSql = String.raw` +-- Выполнять только в отдельной disposable-базе PostgreSQL 11. +-- Скрипт создаёт собственную схему и не содержит DELETE/DROP. +CREATE SCHEMA p17_sql_index_fixture; +SET search_path TO p17_sql_index_fixture; + +CREATE TABLE work_orders ( + id bigint PRIMARY KEY, + state text NOT NULL, + created_at timestamp NOT NULL, + amount integer NOT NULL +); + +INSERT INTO work_orders (id, state, created_at, amount) +SELECT n, + CASE WHEN n % 100 = 0 THEN 'waiting' ELSE 'ready' END, + timestamp '2019-07-01 00:00:00' + (n % 31) * interval '1 day', + 100 + (n % 500) +FROM generate_series(1, 1000000) AS n; + +CREATE INDEX work_orders_state_idx ON work_orders (state); +CREATE INDEX work_orders_created_at_idx ON work_orders (created_at); +ANALYZE work_orders; + +-- Сохранить оба вывода вместе с SHOW всех cost-настроек окружения. +EXPLAIN (ANALYZE, BUFFERS) +SELECT id, amount FROM work_orders WHERE state = 'ready'; + +EXPLAIN (ANALYZE, BUFFERS) +SELECT id, amount FROM work_orders WHERE state = 'waiting'; + +EXPLAIN (ANALYZE, BUFFERS) +SELECT count(*) +FROM work_orders +WHERE created_at >= timestamp '2019-07-10 00:00:00' + AND created_at < timestamp '2019-07-11 00:00:00'; +`; + +const practiceArticle = createRevision( + { + slug: 'editorial-2019-07-practice-sql-indexes', + title: 'SQL-индекс не ускорил запрос: начинаем с плана, а не с CREATE INDEX', + categories: ['SQL', 'PostgreSQL', 'Индексы', 'Практика'], + cover: '/assets/editorial/2019/sql-indexes-selectivity-2019.svg', + excerpt: 'Новый индекс есть, но PostgreSQL по-прежнему читает таблицу. Разбираем селективность, статистику и форму предиката через EXPLAIN ANALYZE, не подменяя диагноз очередным CREATE INDEX.', + readingMinutes: 12, + }, + [ + paragraph('Симптом знакомый: на таблицу добавили индекс, запрос остался медленным, а в плане по-прежнему виден Seq Scan. Цена ошибки — лишний индекс на каждой вставке и обновлении, но без сокращения времени ответа. Проблема не в том, что PostgreSQL «не заметил» DDL. Планировщик мог посчитать последовательное чтение честно дешевле. До следующего CREATE INDEX надо увидеть, сколько строк условие действительно оставляет и на каком узле теряется время.'), + paragraph('Практика ниже строит минимальный маршрут для PostgreSQL 11: воспроизводим перекос значений, запускаем два одинаково написанных запроса с разной селективностью, читаем оценку и факт, затем выбираем действие. Это не рассказ о волшебном покрывающем индексе и не обещание одинакового плана на любом сервере. План зависит от размера строк, cache, стоимости I/O, версии, статистики и настроек. Нам нужен не узнаваемый текст узла, а проверяемая причина выбора.'), + heading('Индекс — вариант доступа, а не обязательный маршрут'), + paragraph('B-tree помогает быстро найти небольшую часть таблицы по подходящему оператору. Но после поиска по вторичному индексу серверу часто надо читать строки таблицы, проверять видимость и возвращать данные. Когда условие оставляет почти все строки, такой обход превращается в много точечных обращений и может стоить дороже одного последовательного прохода. Поэтому отсутствие Index Scan не доказывает неисправность индекса и не является само по себе bug report.'), + paragraph('Отделим две проверки. Первая — подходит ли условие к ключу и типу индекса: сравнение = или диапазон по B-tree обычно дают планировщику кандидата. Вторая — выгоден ли кандидат при текущем распределении данных и нужных колонках. Новичок часто останавливается на первой: «столбец проиндексирован». В работе важнее вторая: «какую долю таблицы вернёт конкретное значение и сколько heap-страниц придётся достать».') , + dataTable( + 'Четыре причины, почему существующий индекс не обязан ускорять этот запрос', + ['Наблюдение в плане', 'Рабочая причина', 'Чем подтвердить', 'Следующее действие'], + [ + ['Seq Scan при частом значении', 'Предикат возвращает большую долю таблицы; обход индекса дороже полного чтения', 'Сравнить rows с размером таблицы и выполнить контрастный запрос с редким значением', 'Не форсировать индекс; уточнить задачу, предикат или структуру данных'], + ['Оценка строк сильно не похожа на actual rows', 'Статистика устарела, груба или не описывает перекос/связь столбцов', 'Снять EXPLAIN (ANALYZE, BUFFERS), посмотреть pg_stats, время последнего ANALYZE', 'Обновить статистику, затем повторить сравнение; только потом менять индекс'], + ['Есть индекс на колонке, но условие содержит функцию', 'Индекс хранит исходное значение, а запрос ищет результат выражения', 'Сверить буквально индексный ключ и WHERE', 'Переписать предикат в диапазон или обоснованно создать expression index'], + ['Частичный индекс не выбран', 'Планировщик не может доказать, что WHERE включает predicate индекса', 'Посмотреть predicate в pg_indexes и фактический текст условия', 'Сделать условие явно совместимым либо отказаться от частичного индекса'], + ], + ), + paragraph('Таблица не говорит «всегда перепиши запрос». Например, частый статус может быть действительно нужен для выгрузки почти всех заказов. Тогда правильный результат расследования — признать последовательный проход нормальным и обсуждать пакетную обработку, ограничение выборки или отдельную витрину. Техническое решение начинается с цены операции, а не с желания увидеть слово Index.'), + heading('Фикстура: сначала собираем наблюдение'), + paragraph('Ниже SQL-фикстура для отдельной disposable-базы PostgreSQL 11. Она создаёт миллион строк: 990 000 со статусом ready и 10 000 со статусом waiting, строит два B-tree индекса и запускает три EXPLAIN (ANALYZE, BUFFERS). Она намеренно не содержит «ожидаемый план» и миллисекунды: после запуска их должен записать тот сервер, который будет обслуживать запрос. На этой машине автор не запускал PostgreSQL: бинарник psql и подключение к базе недоступны. Код — воспроизводимая инструкция, не замаскированный отчёт о прогоне.'), + codeBlock(fixtureSql), + paragraph('После запуска сохранить не только дерево плана. Рядом с ним нужны версия сервера, размер таблицы, результаты ANALYZE, SHOW random_page_cost, SHOW seq_page_cost, SHOW effective_cache_size и время/характер нагрузки. Не потому, что каждый запрос требует большой анкеты, а потому, что два одинаковых SQL на ноутбуке и на production могут законно получить разные стоимости. Без контекста скриншот одного узла плохо годится для следующего решения.'), + heading('Селективность: один столбец, два разных вопроса'), + paragraph('В фикстуре запрос по ready соответствует почти всей таблице, а waiting — примерно одному проценту. Индекс в обоих случаях существует и условие одинаковой формы. Меняется не синтаксис, а ожидаемая доля результата. Для редкого значения индекс часто уменьшает объём чтения. Для частого он сначала обходит индекс, а потом всё равно возвращается к большинству строк. Поэтому один и тот же ключ может быть хорошим для операционного списка исключений и бессмысленным для экрана «все готовые».'), + paragraph('Не превращайте процент в универсальную границу вроде «после пяти процентов индекс плохой». На выбор влияют ширина строк, физическая корреляция, число нужных колонок, cache и LIMIT. Например, маленький LIMIT меняет цену старта: план может предпочесть другой путь, потому что ему не надо дочитывать всё. Вопрос к плану конкретный: сколько строк он ожидает на каждом узле и сколько действительно вернул, а не «какая у нас любимая селективность».') , + figure('/assets/editorial/2019/sql-indexes-selectivity-2019.svg', 'Схема селективности: из миллиона заказов предикат ready оставляет 990 тысяч строк, а waiting — 10 тысяч; индекс является кандидатом, но для частого значения последовательное чтение может быть дешевле', 'Индекс не предписывает план. Сначала сравниваем долю строк и стоимость пути до того, как добавлять ещё один ключ.'), + heading('Как читать первый EXPLAIN ANALYZE'), + paragraph('Начните с верхнего узла и идите вниз по отступам. Верх отвечает за результат запроса, нижние узлы — за его входы. В каждом месте сравните оценку rows=... с фактом actual ... rows=.... У EXPLAIN ANALYZE фактические rows и time появляются потому, что запрос был выполнен. Его стоимости cost=... — внутренние условные единицы, не миллисекунды. Нельзя вычесть cost одного узла из времени другого и назвать это ускорением.'), + paragraph('Дальше смотрим loops. Фактическое время и число строк на узле сообщаются в среднем за один запуск узла; при loops > 1 умножаем, чтобы оценить общий вклад. Для расследования это важнее красивой строки Index Scan: маленький внутренний поиск, повторённый тысячами раз в nested loop, может съесть заметное время. Включённый BUFFERS показывает, откуда пришли страницы — из shared buffers или с чтения, — и помогает не путать CPU-предикат с I/O-ценой.'), + paragraph('Если estimate близок к actual, а план выбирает Seq Scan для ready, сначала принимаем гипотезу планировщика всерьёз: он видит массовый результат. Если estimate расходится в десять и более раз, не лечим симптом SET enable_seqscan = off. Такой переключатель может показать альтернативу для исследования, но он не даёт данным стать более селективными и не чинит статистику. В production его нельзя считать постоянным решением без отдельного основания.'), + heading('Статистика — вход планировщика, а не служебный шум'), + paragraph('Планировщик не перебирает таблицу перед каждым SELECT, чтобы узнать точную долю. Он использует приблизительные сведения о количестве строк, частых значениях, гистограммах и distinct-значениях. Их собирает ANALYZE; для ручного чтения документация советует view pg_stats, а не системный каталог напрямую. После большой загрузки, массового изменения статусов или перекоса новых данных убедитесь, что статистика обновилась, и только потом сравнивайте план до и после.'), + codeBlock(String.raw` +-- Сначала фиксируем контекст, затем обновляем только нужную таблицу. +SELECT attname, n_distinct, most_common_vals, most_common_freqs +FROM pg_stats +WHERE schemaname = 'p17_sql_index_fixture' + AND tablename = 'work_orders' + AND attname IN ('state', 'created_at'); + +ANALYZE p17_sql_index_fixture.work_orders; + +EXPLAIN (ANALYZE, BUFFERS) +SELECT id, amount +FROM p17_sql_index_fixture.work_orders +WHERE state = 'waiting'; +`), + paragraph('Обновление статистики не обещает смену узла. Оно делает следующую оценку честнее. Если после ANALYZE план и факт сблизились, а последовательный проход остался, это полезный результат: индекс не потерялся, он просто дороже для данного условия. Если цифры по-прежнему расходятся, проверяем корреляцию нескольких столбцов, типы, cast, predicate и версию параметризированного запроса. Каждый следующий шаг должен менять одну гипотезу, иначе дифф планов нельзя прочитать.'), + heading('Маршрут без угадывания'), + orderedList([ + 'Записать точный SQL, значения параметров, цель запроса и наблюдаемый симптом: задержку, рост I/O или ошибочный объём результата. Не начинать с имени предполагаемого индекса.', + 'Снять EXPLAIN (ANALYZE, BUFFERS) на безопасном SELECT. Для изменения данных использовать отдельную транзакцию и откат, потому что ANALYZE исполняет statement.', + 'Сверху вниз сравнить estimated rows, actual rows, loops и Buffers. Отметить первый узел, где оценка перестала быть похожа на факт.', + 'Проверить долю результата: частое значение, широкий диапазон и выгрузка без LIMIT могут честно требовать Seq Scan. Не объявлять такой выбор поломкой.', + 'Проверить свежесть и форму статистики через pg_stats, затем выполнить целевой ANALYZE и повторить тот же замер.', + 'Сверить выражение в WHERE, predicate частичного индекса и реальные нужные колонки. Только после этого обсуждать другой ключ, expression/partial index или изменение запроса.', + ]), + heading('Что эта практика не доказывает'), + bulletList([ + 'Она не измеряет production и не даёт нормативных миллисекунд: фикстура не запускалась в этой задаче и должна быть выполнена на отдельном контуре.', + 'Она не утверждает, что waiting обязательно даст Index Scan. Правильный артефакт — сохранённый план конкретного сервера и объяснение его строк/буферов.', + 'Она не оправдывает ручное отключение scan-стратегий как постоянную настройку. Принудительный план скрывает причину и может ухудшить соседние запросы.', + 'Она не заменяет проверку влияния индекса на INSERT, UPDATE, размер диска и время построения. Ускорение чтения имеет цену поддержки структуры.', + ]), + heading('Итог'), + paragraph('Новый индекс не обязан ускорять запрос, который честно возвращает почти всю таблицу, опирается на старую статистику или написан в другой форме, чем ключ. Практический ответ начинается с сохранённого EXPLAIN ANALYZE: оценка против факта, loops, buffers и доля результата. После такой проверки можно оставить Seq Scan как правильный план либо изменить именно подтверждённую причину, а не коллекционировать индексы.'), + ], + [pg11IndexesIntro, pg11IndexTypes, pg11Explain, pg11SqlExplain, pg11PlannerStats, pg11Analyze], +); + +const mechanismArticle = createRevision( + { + slug: 'editorial-2019-07-mechanism-sql-indexes', + title: 'EXPLAIN ANALYZE: читать план запроса, а не угадывать индекс', + categories: ['SQL', 'PostgreSQL', 'EXPLAIN', 'Разбор'], + cover: '/assets/editorial/2019/sql-indexes-plan-reading-2019.svg', + excerpt: 'План показывает Seq Scan, Index Scan и cost, но эти слова сами не объясняют задержку. Разбираем дерево, estimate versus actual, loops и Buffers, чтобы найти первую неверную гипотезу.', + readingMinutes: 13, + }, + [ + paragraph('Симптом: в тикет кладут скриншот EXPLAIN и пишут «нужен индекс», хотя ни параметров, ни фактических строк, ни времени там нет. Цена такой диагностики — исправление не того узла: добавляют ключ, когда ошибается оценка, либо гоняются за Seq Scan, который является самым дешёвым вариантом. План — не приговор и не рецепт. Это дерево предположений о том, как получить результат; EXPLAIN ANALYZE позволяет положить рядом часть предположений и факт выполнения.'), + paragraph('Разберём один механизм: как из плана сделать короткую цепочку «где возникла ошибка → чем её проверить → что менять». Уровень автора 2019 года здесь намеренно прикладной: SQL, параметры, server-side plan и базовые счётчики, без обещаний универсального тюнинга. Мы не называем один узел лучшим. Мы ищем первый узел, где оценённое число строк перестало быть правдоподобным, и отличаем его от узла, который просто виден вверху дерева.'), + heading('Два режима EXPLAIN отвечают на разные вопросы'), + paragraph('Обычный EXPLAIN строит выбранный план и показывает оценки: стоимость, количество строк и ширину строки. Он безопаснее для тяжёлого или изменяющего statement, потому что сам запрос не выполняет. EXPLAIN ANALYZE выполняет statement и добавляет фактические строки и время. Поэтому он нужен, когда мы проверяем оценку, но требует той же осторожности, что и исходный запрос: SELECT может нагрузить базу, а INSERT/UPDATE/DELETE действительно изменят данные, если не выполнить их в защищённой транзакции и не сделать rollback.'), + paragraph('Добавка BUFFERS делает разбор полезнее для медленных случаев. Она сообщает количество буферов, затронутых на узлах, и помогает увидеть разницу между «мы нашли мало строк, но прочитали много страниц» и «мы почти ничего не читали, но много раз повторили вычисление». Это не счётчик запросов приложения и не замер диска в чистом виде: это статистика буферов PostgreSQL. Читаем её вместе с rows и loops, а не отдельно как ещё одно большое число.'), + dataTable( + 'Словарь первого прохода по EXPLAIN ANALYZE', + ['Поле или строка', 'Что она означает', 'Частая ошибка чтения', 'Практическая проверка'], + [ + ['cost=a..b', 'Оценка затрат в условных единицах планировщика: startup и total', 'Считать b миллисекундами или сравнивать её с actual time напрямую', 'Сравнивать альтернативы в одном плане, а реальную задержку брать из actual'], + ['rows=n', 'Ожидаемое число строк на узле', 'Считать строку результатом всего запроса независимо от места в дереве', 'Сопоставить с actual rows на том же узле'], + ['actual ... rows=n loops=k', 'Наблюдаемые rows и время, усреднённые по одному выполнению узла', 'Забыть умножить небольшой узел на loops', 'Оценить общий вклад: среднее время/строки вместе с количеством повторов'], + ['Index Cond и Filter', 'Условие доступа через индекс и условие, проверяемое после доступа', 'Считать любой упомянутый индекс доказательством селективного поиска', 'Посмотреть, что отсекает индекс и что остаётся фильтром'], + ['Buffers', 'Затронутые shared/local/temp buffers на узле и в итогах', 'Называть каждый shared hit чтением с диска', 'Смотреть сочетание hit/read, rows и повторов узла'], + ], + ), + paragraph('План читается от верхнего оператора к его входам, но расследование часто начинает снизу: с scan, который создаёт объём данных. Отступы показывают, чей результат подаётся родителю. Если верхний Aggregate медленный, это ещё не означает, что агрегирование виновато. Он мог дождаться миллионов строк от ребёнка. И наоборот, красивый Index Scan ниже может не спасать, если следующий Filter выбрасывает почти всё найденное.'), + heading('Контракт одной фикстуры и одного снимка'), + paragraph('Для чтения плана недостаточно сократить SQL до «примерно такого». Нужны точные параметры, версия сервера и форма предиката. Используем ту же минимальную схему p17_sql_index_fixture.work_orders: редкий waiting, частый ready и диапазон по created_at. Выполнить её можно только в disposable-базе PostgreSQL 11. В этой задаче такого запуска не было: psql не установлен и подключения нет. Поэтому ниже только команды снятия данных, а не искусственно сочинённые строки 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 прямо отмечает, что EXPLAIN ANALYZE исполняет statement; транзакция с ROLLBACK защищает данные от учебного изменяющего примера. Для реального расследования договоритесь о безопасном окне и о том, можно ли вообще исполнять тяжёлый запрос. Измерение, которое само создаёт инцидент, не становится качественным от слова ANALYZE.'), + heading('Estimate против actual: ищем первую развилку'), + paragraph('Главная пара чисел — rows и actual rows на одном узле. Малое отклонение естественно: статистика приблизительна, распределение меняется, стоимость не является физическим секундомером. Но кратное расхождение важно, особенно если оно начинается на нижнем scan и затем размножается через join. Планировщик выбирает join order, способ соединения и размер промежуточных наборов по оценке. Если он ожидает десять строк, а получает сто тысяч, последующий nested loop или sort может стать дорогим не потому, что этот оператор «плохой», а потому, что вход в него оказался другим.'), + paragraph('Не надо исправлять каждый верхний узел по очереди. Отмечаем первый узел снизу, где estimate заметно ушёл от факта, и задаём узкий вопрос. Предикат слишком широкий? Статистика старая? Два столбца коррелируют, но известны планировщику по одному? Тип параметра ведёт к cast? Условие спрятано за функцией? Это уже проверяемые гипотезы. Фраза «оптимизатор тупит» не говорит, какую из них можно опровергнуть.'), + paragraph('Есть ещё ловушка loops. PostgreSQL показывает actual time и actual rows как средние за один запуск узла, чтобы сравнивать их с оценками. Внутренний узел nested loop с actual time=0.15..0.20 может казаться невинным, но при loops=10000 его вклад нельзя оценивать как две десятые миллисекунды. Умножаем среднее на 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('План с названием индекса не равен плану с точным поиском. У Index Cond видно условие, которое ограничивает доступ через индекс. У Filter видно условие, применённое к строкам после выбранного доступа. Это нормальная конструкция: один индекс может сузить набор, второй предикат проверяется позже. Но если индекс отдаёт большую часть таблицы, а filter выбрасывает почти всё, проверяем порядок ключей, форму выражения и статистику, а не объявляем любой Index Scan успехом.'), + paragraph('Пример: есть индекс (state), а запрос одновременно выбирает диапазон времени. Если state = 'ready' почти ничего не отсекает, он может быть плохой точкой старта даже при наличии Index Cond. Если реальный пользовательский путь всегда просит редкий state и короткий диапазон, исследуем составной индекс и порядок его колонок на подтверждённых запросах. Если путь выбирает почти всё, возможно, правильнее остаться на последовательном чтении. Индекс проектируют от устойчивой нагрузки, не от одного названия поля.'), + heading('Статистика, корреляция и честная проверка'), + paragraph('Стандартная статистика хранится по отдельным столбцам. Планировщик обычно предполагает независимость условий, а в реальных таблицах столбцы часто связаны: город и почтовый индекс, тип заказа и статус, страна и валюта. PostgreSQL 11 поддерживает расширенную статистику, но это не кнопка «собрать всё». Сначала нужен доказанный плохой estimate на конкретном сочетании условий. Потом можно рассмотреть CREATE STATISTICS для этой группы и снова выполнить ANALYZE.'), + codeBlock(String.raw` +-- Пример исследовательского шага для реально коррелирующих условий. +-- Не создавайте статистику на все пары колонок без плана и симптома. +CREATE STATISTICS work_orders_state_created_stats (dependencies) +ON state, created_at +FROM p17_sql_index_fixture.work_orders; + +ANALYZE p17_sql_index_fixture.work_orders; + +SELECT attname, n_distinct, most_common_vals, most_common_freqs +FROM pg_stats +WHERE schemaname = 'p17_sql_index_fixture' + AND tablename = 'work_orders'; +`), + paragraph('Этот пример не обещает, что зависимости помогут диапазону по времени: документация PostgreSQL 11 прямо ограничивает functional dependencies простыми equality-условиями с константами. Поэтому в журнале расследования фиксируем не «создали CREATE STATISTICS», а исходный estimate, форму условия, ожидание от статистики и результат повторного плана. Если условие не попадает в границы механизма, не надо приписывать ему эффект.'), + heading('Порядок чтения, который можно повторить'), + orderedList([ + 'Сохранить точный SQL и параметры. Отдельно записать, что болит: время ответа, чтение буферов, неверный join order или рост после изменения данных.', + 'Снять plain EXPLAIN, затем безопасный EXPLAIN (ANALYZE, BUFFERS). Не запускать write-statement без транзакционной границы и разрешения на нагрузку.', + 'Прочитать дерево от результата к входам и отметить scan/join, который создаёт крупный поток строк. Не считать верхний узел причиной только потому, что он напечатан первым.', + 'На каждом ключевом узле сверить rows с actual rows; при loops > 1 учесть повторения.', + 'Разобрать Index Cond отдельно от Filter и посмотреть Buffers. Это отделяет доступ к строкам от позднего отбора и повторного I/O.', + 'Изменить одну подтверждённую причину: статистику, форму предиката, ключ индекса или сам объём работы. Переснять такой же план и сравнить данные, а не только имя scan.', + ]), + heading('Границы вывода'), + bulletList([ + 'Фактическое время из EXPLAIN ANALYZE включает выполнение под его профилированием; не переносите одно значение как SLA для приложения.', + 'Одинаковый SQL может получить другой план после изменения данных, настроек стоимости, памяти, версии или параметров. Снимок надо хранить с контекстом.', + 'Принудительное отключение планов может помочь увидеть альтернативу, но не заменяет статистику и не является автоматическим production-fix.', + 'Расширенная статистика требует конкретной корреляции и конкретного неверного estimate. Собирать её на все комбинации означает добавлять стоимость без диагноза.', + ]), + heading('Итог'), + paragraph('Хорошее чтение EXPLAIN ANALYZE не начинается с охоты на Index Scan. Оно начинается с точного SQL, затем сопоставляет estimate и actual на узлах, учитывает loops, различает Index Cond и Filter, читает Buffers и ищет первую неверную предпосылку. После этого индекс становится одним из вариантов действия, а не ответом, выбранным до вопроса.'), + ], + [pg11Explain, pg11SqlExplain, pg11PlannerStats, pg11Analyze, pg11IndexesIntro], +); + +const fieldArticle = createRevision( + { + slug: 'editorial-2019-07-field-sql-indexes', + title: 'Индекс есть, Seq Scan остался: полевая диагностика PostgreSQL', + categories: ['SQL', 'PostgreSQL', 'Индексы', 'Диагностика'], + cover: '/assets/editorial/2019/sql-indexes-predicate-shape-2019.svg', + excerpt: 'Не каждый WHERE сопоставим с индексным ключом, а частичный индекс не читает мысли prepared statement. Полевой разбор: селективность, ANALYZE, выражения и predicate shape.', + readingMinutes: 13, + }, + [ + paragraph('Симптом: в таблице уже есть индекс на created_at, но запрос за один день идёт через Seq Scan; после создания ещё одного индекса ситуация не изменилась. Цена такого ремонта — раздутая схема, медленнее запись и отсутствие объяснения в следующем инциденте. В полевом разборе не будем угадывать «правильный индекс». Сначала отделим три причины: условие выбирает слишком много строк, статистика неверно оценивает долю или сам предикат не совпадает с формой ключа.'), + paragraph('Нужен один доказуемый маршрут: точный SQL → EXPLAIN (ANALYZE, BUFFERS) → первое расхождение estimate/fact → проверка статистики и формы WHERE → минимальная правка → тот же план повторно. Это не требует специального ORM и остаётся полезным, когда query пришёл из PHP, фоновой задачи или admin-экспорта. В 2019 году достаточно видеть серверный SQL и параметры; не нужно придумывать платформенные метрики, чтобы не делать индекс вслепую.'), + heading('До индекса фиксируем четыре факта'), + paragraph('Первая ловушка — знать только имя индекса. Для расследования нужны: точный индексный ключ и predicate, точное условие запроса с типами параметров, фактическая доля результата и свежесть статистики. \\d в psql удобен, но SQL-проверка переносимее: pg_indexes показывает определение, pg_stats — доступную статистику. Это не делает внутренние каталоги читабельным API для приложения; это инструменты диагностики для инженера, который должен сопоставить запись с планом.'), + codeBlock(String.raw` +-- Инвентаризация перед изменением DDL. +SELECT schemaname, tablename, indexname, indexdef +FROM pg_indexes +WHERE schemaname = 'p17_sql_index_fixture' + AND tablename = 'work_orders'; + +SELECT attname, n_distinct, most_common_vals, most_common_freqs +FROM pg_stats +WHERE schemaname = 'p17_sql_index_fixture' + AND tablename = 'work_orders'; + +EXPLAIN (ANALYZE, BUFFERS) +SELECT count(*) +FROM p17_sql_index_fixture.work_orders +WHERE created_at::date = DATE '2019-07-10'; +`), + paragraph('Последний запрос нарочно неудобен для обычного индекса на created_at: в условии написано выражение created_at::date, а не исходная колонка. Не делайте вывод по одной строке плана заранее. Сначала сохраните plan и rows. Затем спросите, какой контракт у бизнес-условия: нам нужна календарная дата в timestamp without time zone, или точный диапазон в определённой временной зоне? От ответа зависит корректная перепись, а не только скорость.'), + dataTable( + 'Полевая карта: условие, причина и безопасная следующая проверка', + ['Симптом', 'Вероятная причина', 'Что показать в плане/каталогах', 'Действие после подтверждения'], + [ + ['B-tree есть, Seq Scan для частого state', 'Селективность мала: нужно вернуть большую долю строк', 'rows и actual rows близки, most_common_freqs показывает частое значение', 'Оставить последовательный путь или изменить объём работы; не добавлять дубликат индекса'], + ['Оценка 100, факт 100 000', 'Статистика устарела/груба либо условия коррелируют', 'Первый нижний узел с расхождением, pg_stats, дата/объём недавних изменений', 'Выполнить целевой ANALYZE, затем исследовать корректную расширенную статистику'], + ['created_at::date при индексе (created_at)', 'Форма предиката ищет выражение, а индекс содержит исходный timestamp', 'Сравнить текст indexdef и Filter/Index Cond', 'Переписать в корректный диапазон или обосновать expression index'], + ['Partial index не участвует в prepared query', 'Планировщик не может доказать predicate для параметра или иной записи условия', 'Сверить WHERE индекса и SQL до подстановки', 'Сделать условие доказуемым, сменить ключ или не применять partial index к этому маршруту'], + ], + ), + paragraph('Эта карта не заменяет участие владельца данных. Например, «частый state» может быть следствием нового сценария, а не статистической ошибки. В таком случае сначала проверить продуктовую нагрузку: правда ли экрану нужна вся выборка или UI перестал пагинировать. Индекс решает путь доступа к уже запрошенным строкам; он не превращает экспорт миллиона записей в список из десяти.'), + heading('Форма предиката: range чаще честнее, чем cast'), + paragraph('Если created_at имеет тип timestamp without time zone и задача — один календарный день, диапазон сохраняет исходную колонку слева от сравнения. Это обычно проще сопоставить с B-tree индексом на created_at, чем выражение created_at::date. При этом диапазон не должен быть механической заменой: для timestamptz границы дня выбирают в явной бизнес-временной зоне. Сначала фиксируем семантику времени, потом меняем 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: psql не найден и тестовая база не предоставлена. Примеры выше — SQL-фикстуры, которые следует выполнять на disposable-контуре PostgreSQL 11 после создания схемы из практической статьи. В них нет заявленных rows, времени и названия узла: результат должен появиться из конкретного сервера вместе с его настройками. Это ограничение важнее красивого, но вымышленного Index Scan.'), + figure('/assets/editorial/2019/sql-indexes-predicate-shape-2019.svg', 'Схема формы предиката: обычный индекс хранит created_at; выражение created_at::date не совпадает с ключом напрямую, а полуоткрытый диапазон сравнивает исходный created_at и может быть кандидатом для B-tree', 'Переписывать предикат можно только после проверки его смысловой границы. Скорость не оправдывает ошибочный день или неправильную временную зону.'), + heading('Частичный индекс: планировщик должен доказать условие'), + paragraph('Частичный индекс хранит только строки, удовлетворяющие своему predicate. Это полезно, когда интересующая нагрузка постоянно работает с небольшой, заранее понятной частью данных. Но PostgreSQL использует такой индекс только когда при планировании может распознать, что WHERE запроса математически включает 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, возвращаемся к данным. ANALYZE хранит приблизительные частые значения, гистограммы и distinct-оценки; после изменений они могут устареть, а статистика одного столбца не знает взаимосвязь с другим. Сначала запускаем целевой ANALYZE в согласованное время, затем повторяем один и тот же план. Не смешиваем этот шаг с созданием нового индекса, иначе исчезает возможность понять, что действительно изменило выбор.'), + codeBlock(String.raw` +ANALYZE p17_sql_index_fixture.work_orders; + +-- Только если есть доказанное расхождение на двух связанных равенствах: +CREATE STATISTICS work_orders_state_amount_stats (dependencies) +ON state, amount +FROM p17_sql_index_fixture.work_orders; + +ANALYZE p17_sql_index_fixture.work_orders; +`), + paragraph('Расширенная статистика также имеет границы. В PostgreSQL 11 functional dependencies применяются к простым equality-условиям с константами; они не обещают исправить все range, LIKE, выражения или сравнения столбцов между собой. Это достаточная причина не добавлять объект «на всякий случай». В отчёте расследования показывают исходный и повторный plan, точное условие, estimate/actual и действие с индексом. Если эффект не подтверждён, объект и гипотеза не получают статуса решения.'), + heading('Полевой маршрут на один запрос'), + orderedList([ + 'Взять SQL из реального места вызова вместе с типами и значениями параметров. Указать, сколько строк требуется пользователю и есть ли LIMIT, сортировка или пагинация.', + 'Снять EXPLAIN (ANALYZE, BUFFERS) на безопасном контуре. Сохранить plan, версию PostgreSQL и важные cost-настройки; не переносить в отчёт вымышленные цифры.', + 'Найти первый узел, где estimate rows расходится с actual rows. Если расхождения нет, сначала признать выбранный Seq Scan или Bitmap путь обоснованным.', + 'Сверить Index Cond, Filter, pg_indexes.indexdef и точную форму WHERE. Проверить cast, функцию, operator, составной ключ и predicate partial index.', + 'Проверить pg_stats, выполнить целевой ANALYZE и повторить тот же plan. При доказанной корреляции рассмотреть ровно одну подходящую statistics object.', + 'Сделать минимальную правку: семантически верный range, expression index или иной ключ для подтверждённой нагрузки. Сравнить не только имя узла, но rows, loops, buffers, время и цену записи.', + ]), + heading('Что нельзя обещать по одному плану'), + bulletList([ + 'Нельзя обещать вечный Index Scan: данные, параметры, cache и настройки меняются, а планировщик вправе выбрать другой путь.', + 'Нельзя считать EXPLAIN ANALYZE безопасной сухой проверкой: он исполняет statement и может заметно нагрузить сервер.', + 'Нельзя подменять семантику времени быстрым cast/range. Для timestamptz календарные границы принадлежат выбранной бизнес-временной зоне.', + 'Нельзя добавлять partial/expressional index без оценки стоимости обновлений, размера и устойчивости реального query shape.', + ]), + heading('Итог'), + paragraph('Когда индекс есть, а Seq Scan остался, это не повод переписывать схему наугад. Проверяем селективность, точность estimate, свежесть статистики и форму предиката. Условие created_at::date, частый state и недоказуемый predicate partial index — три разные причины с разными исправлениями. Сохранённый план и одна проверенная правка дают повторяемый диагноз; серия DDL без этого только прячет проблему под новыми именами.'), + ], + [pg11Explain, pg11SqlExplain, pg11PlannerStats, pg11Analyze, pg11ExpressionIndexes, pg11PartialIndexes, pg11IndexTypes], +); + +export const revisions = [practiceArticle, mechanismArticle, fieldArticle] + .map(({ proseLength, ...revision }) => revision); + +const isDirectRun = process.argv[1] + && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isDirectRun) { + if (process.argv.includes('--print-revisions')) { + process.stdout.write(JSON.stringify(revisions, null, 2) + '\n'); + } else { + process.stderr.write('Usage: node web/scripts/upgrade-2019-07.mjs --print-revisions\n'); + process.exitCode = 1; + } +}