revise July 2019 SQL articles
Build and deploy / deploy (push) Successful in 14s

This commit is contained in:
2026-07-31 10:37:50 +03:00
parent 368fa96733
commit bbedf1ae87
7 changed files with 766 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий # Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 49 из 358 созданных материалов. Остальные 309 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. На 31 июля 2026 года строгий аудит проходит 52 из 358 созданных материалов. Остальные 306 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия ## Одна партия
+138
View File
@@ -0,0 +1,138 @@
# П17 · июль 2019 · SQL-индексы и планы PostgreSQL — тройное ревью
Статус: **принят в публикационный слой 31 июля 2026 года**. Registry
накладывает три ревизии по стабильным slug и сохраняет дату и автора базового
архива:
- <code>editorial-2019-07-practice-sql-indexes</code>;
- <code>editorial-2019-07-mechanism-sql-indexes</code>;
- <code>editorial-2019-07-field-sql-indexes</code>.
Созданы только пять файлов:
- <code>web/scripts/upgrade-2019-07.mjs</code>;
- <code>web/public/assets/editorial/2019/sql-indexes-selectivity-2019.svg</code>;
- <code>web/public/assets/editorial/2019/sql-indexes-plan-reading-2019.svg</code>;
- <code>web/public/assets/editorial/2019/sql-indexes-predicate-shape-2019.svg</code>;
- этот файл.
Модуль экспортирует ровно три 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; места, зависящие от конкретного контура, названы
планом проверки, а не выполненным замером.
## Проход 2. Редактура и голос М2 / 2019 — пройдено
| Ревизия | Симптом и цена в начале | Главный вопрос | Практический артефакт и ограничение |
| --- | --- | --- | --- |
| Практика | Добавили индекс, но запрос не ускорился; цена — лишняя стоимость 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>actual rows</code>, <code>loops</code>, <code>Buffers</code>,
<code>pg_stats</code>, <code>Index Cond</code> и <code>Filter</code>.
- Голос соответствует М2 / 2019: автор уверенно работает с SQL,
PostgreSQL, серверным планом, query shape и базовой доставкой данных, но не
приписывает себе управление платформой, SLO, распределённую трассировку,
Kubernetes или продуктовые метрики поздних лет.
- Тон краткий и прагматичный. Нет абсолютов «индекс всегда ускоряет» или
«Seq Scan всегда плох». Каждый совет требует наблюдаемой проверки до DDL.
- Длина, количество разделов, таблица с <code>caption</code>/<code>thead</code>
и <code>scope</code>, код, упорядоченный маршрут, figure с alt/caption и
два или больше первичных источника дополнительно проверяются draft gate.
Вердикт прохода: **пройден**. Тексты развивают автора от практической
диагностики к более системному чтению планов, но остаются на его правдоподобной
глубине 2019 года.
## Проход 3. Визуал и выпуск — пройдено в пределах автономного пакета
- <code>sql-indexes-selectivity-2019.svg</code> сопоставляет одинаковый
индекс с двумя распределениями результата: 990 000 <code>ready</code> и
10 000 <code>waiting</code>. Нижняя карточка явно говорит сравнить rows,
actual rows, loops и Buffers, а не ждать обязательный Index Scan.
- <code>sql-indexes-plan-reading-2019.svg</code> показывает вертикальный поток
данных от scan к результату и помечает первое расхождение estimate/actual
как точку расследования. Схема не подменяет настоящий plan и не содержит
цифр, объявленных измерением.
- <code>sql-indexes-predicate-shape-2019.svg</code> отделяет индексный ключ
<code>(created_at)</code>, выражение <code>created_at::date</code> и
полуоткрытый range. Низ схемы оставляет обязательную проверку timezone и
EXPLAIN ANALYZE, чтобы скорость не сломала границу календарного дня.
- В каждом SVG есть <code>title</code>, <code>desc</code>,
<code>role="img"</code> и связка <code>aria-labelledby</code>. У картинок в
статьях есть самостоятельный содержательный alt и <code>figcaption</code>.
В 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 года:
| Проверка | Результат |
| --- | --- |
| <code>node --check</code> | PASS, синтаксис модуля корректен |
| <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.
+2
View File
@@ -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 march2019Revisions } from '../scripts/upgrade-2019-03.mjs';
import { revisions as april2019Revisions } from '../scripts/upgrade-2019-04.mjs'; import { revisions as april2019Revisions } from '../scripts/upgrade-2019-04.mjs';
import { revisions as may2019Revisions } from '../scripts/upgrade-2019-05.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. // This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [ export const editorialRevisions = [
@@ -29,4 +30,5 @@ export const editorialRevisions = [
...march2019Revisions, ...march2019Revisions,
...april2019Revisions, ...april2019Revisions,
...may2019Revisions, ...may2019Revisions,
...july2019Revisions,
]; ];
@@ -0,0 +1,49 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 650" role="img" aria-labelledby="title desc">
<title id="title">Как читать EXPLAIN ANALYZE от результата к первому расхождению</title>
<desc id="desc">Вертикальное дерево плана показывает Result, Filter и Index Scan. На каждом уровне сравниваются оценка строк, фактические строки, loops и buffers; расследование начинается с первого расхождения снизу.</desc>
<defs>
<style>
.bg { fill: #f8fafc; }
.node { fill: #ffffff; stroke: #94a3b8; stroke-width: 2; }
.scan { fill: #ecfdf5; stroke: #059669; stroke-width: 2; }
.warn { fill: #fff7ed; stroke: #ea580c; stroke-width: 2; }
.note { fill: #eff6ff; stroke: #2563eb; stroke-width: 2; }
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #0f172a; }
.label { font: 700 18px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #0f172a; }
.text { font: 15px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #334155; }
.mono { font: 600 14px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #0f172a; }
.arrow { stroke: #64748b; stroke-width: 3; fill: none; marker-end: url(#tip); }
.dash { stroke: #ea580c; stroke-width: 3; stroke-dasharray: 7 6; fill: none; marker-end: url(#tipOrange); }
</style>
<marker id="tip" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#64748b" />
</marker>
<marker id="tipOrange" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#ea580c" />
</marker>
</defs>
<rect class="bg" width="760" height="650" rx="28" />
<text class="title" x="42" y="52">План — поток данных, не список красивых узлов</text>
<rect class="node" x="150" y="86" width="360" height="100" rx="14" />
<text class="label" x="178" y="122">Aggregate / Result</text>
<text class="mono" x="178" y="152">rows=1 · actual rows=1 · loops=1</text>
<path class="arrow" d="M330 188 L330 244" />
<rect class="warn" x="150" y="254" width="360" height="116" rx="14" />
<text class="label" x="178" y="291">Filter</text>
<text class="mono" x="178" y="321">rows=100 · actual rows=100 000</text>
<text class="text" x="178" y="347">первое заметное расхождение: ищем причину</text>
<path class="arrow" d="M330 372 L330 428" />
<rect class="scan" x="150" y="438" width="360" height="116" rx="14" />
<text class="label" x="178" y="475">Index Scan</text>
<text class="mono" x="178" y="505">Index Cond: state = 'ready'</text>
<text class="mono" x="178" y="530">Buffers: shared hit/read · loops=1</text>
<rect class="note" x="548" y="235" width="174" height="164" rx="14" />
<text class="label" x="568" y="271">Проверить</text>
<text class="text" x="568" y="302">1. rows vs actual</text>
<text class="text" x="568" y="330">2. loops</text>
<text class="text" x="568" y="358">3. Index Cond</text>
<text class="text" x="568" y="386">4. Buffers</text>
<path class="dash" d="M545 318 L515 318" />
<rect class="node" x="76" y="580" width="608" height="42" rx="12" />
<text class="text" x="106" y="607">Не лечим верхний узел: начинаем с первого места, где оценка потеряла связь с фактом.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,50 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 650" role="img" aria-labelledby="title desc">
<title id="title">Форма условия должна соответствовать индексному ключу</title>
<desc id="desc">Индекс хранит created_at. Предикат created_at::date создаёт выражение и не совпадает с ключом напрямую, а полуоткрытый диапазон сравнивает исходный created_at и может стать кандидатом для B-tree после проверки плана и семантики времени.</desc>
<defs>
<style>
.bg { fill: #f8fafc; }
.key { fill: #e0f2fe; stroke: #0284c7; stroke-width: 2; }
.bad { fill: #fef2f2; stroke: #dc2626; stroke-width: 2; }
.good { fill: #ecfdf5; stroke: #059669; stroke-width: 2; }
.note { fill: #fffbeb; stroke: #d97706; stroke-width: 2; }
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #0f172a; }
.label { font: 700 18px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #0f172a; }
.text { font: 15px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #334155; }
.mono { font: 600 14px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #0f172a; }
.arrow { stroke: #64748b; stroke-width: 3; fill: none; marker-end: url(#tip); }
.badline { stroke: #dc2626; stroke-width: 4; }
</style>
<marker id="tip" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#64748b" />
</marker>
</defs>
<rect class="bg" width="760" height="650" rx="28" />
<text class="title" x="42" y="52">Индекс хранит ключ, а не намерение запроса</text>
<rect class="key" x="175" y="84" width="410" height="82" rx="14" />
<text class="label" x="278" y="119">B-tree: (created_at)</text>
<text class="text" x="246" y="146">ключ — исходный timestamp без cast</text>
<path class="arrow" d="M278 168 L184 230" />
<path class="arrow" d="M482 168 L576 230" />
<rect class="bad" x="38" y="240" width="306" height="182" rx="14" />
<text class="label" x="62" y="277">Выражение в WHERE</text>
<text class="mono" x="62" y="311">created_at::date</text>
<text class="mono" x="62" y="337">= DATE '2019-07-10'</text>
<text class="text" x="62" y="373">ключ и выражение разные</text>
<text class="text" x="62" y="399">проверяем range или</text>
<text class="text" x="62" y="420">обоснованный expression index</text>
<line class="badline" x1="78" y1="256" x2="304" y2="406" />
<rect class="good" x="416" y="240" width="306" height="182" rx="14" />
<text class="label" x="440" y="277">Полуоткрытый range</text>
<text class="mono" x="440" y="311">created_at &gt;= day</text>
<text class="mono" x="440" y="337">AND created_at &lt; day + 1</text>
<text class="text" x="440" y="373">исходный ключ сохранён</text>
<text class="text" x="440" y="399">кандидат для B-tree:</text>
<text class="text" x="440" y="420">подтверждаем планом</text>
<path class="arrow" d="M190 424 L285 486" />
<path class="arrow" d="M570 424 L475 486" />
<rect class="note" x="116" y="496" width="528" height="104" rx="14" />
<text class="label" x="148" y="532">Сначала смысл времени, затем EXPLAIN ANALYZE</text>
<text class="text" x="148" y="561">Проверить timezone, rows, Index Cond, Filter и Buffers.</text>
<text class="text" x="148" y="584">Скорость не оправдывает неверную календарную границу.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 620" role="img" aria-labelledby="title desc">
<title id="title">Селективность определяет ценность индекса</title>
<desc id="desc">Из миллиона заказов условие ready оставляет девятьсот девяносто тысяч строк, а waiting десять тысяч. Для обоих существует индекс, но для частого значения последовательное чтение может быть дешевле.</desc>
<defs>
<style>
.bg { fill: #f8fafc; }
.card { fill: #ffffff; stroke: #cbd5e1; stroke-width: 2; }
.source { fill: #e0f2fe; stroke: #0284c7; stroke-width: 2; }
.hot { fill: #fff7ed; stroke: #ea580c; stroke-width: 2; }
.cold { fill: #ecfdf5; stroke: #059669; stroke-width: 2; }
.note { fill: #eff6ff; stroke: #2563eb; stroke-width: 2; }
.title { font: 700 25px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #0f172a; }
.label { font: 700 18px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #0f172a; }
.text { font: 16px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #334155; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #475569; }
.mono { font: 600 15px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #0f172a; }
.line { stroke: #64748b; stroke-width: 3; fill: none; marker-end: url(#arrow); }
</style>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#64748b" />
</marker>
</defs>
<rect class="bg" width="760" height="620" rx="28" />
<text class="title" x="42" y="52">Индекс — кандидат; селективность решает цену</text>
<rect class="source" x="210" y="82" width="340" height="86" rx="14" />
<text class="label" x="290" y="119">work_orders</text>
<text class="text" x="272" y="148">1 000 000 строк после ANALYZE</text>
<path class="line" d="M300 170 L185 236" />
<path class="line" d="M460 170 L575 236" />
<rect class="hot" x="42" y="246" width="292" height="168" rx="14" />
<text class="mono" x="66" y="281">WHERE state = 'ready'</text>
<text class="label" x="66" y="320">990 000 строк</text>
<text class="text" x="66" y="350">почти вся таблица</text>
<text class="small" x="66" y="382">индекс + heap-обращения</text>
<text class="small" x="66" y="403">могут стоить дороже Seq Scan</text>
<rect class="cold" x="426" y="246" width="292" height="168" rx="14" />
<text class="mono" x="448" y="281">WHERE state = 'waiting'</text>
<text class="label" x="448" y="320">10 000 строк</text>
<text class="text" x="448" y="350">около 1% таблицы</text>
<text class="small" x="448" y="382">индекс может сузить путь</text>
<text class="small" x="448" y="403">проверяем фактическим планом</text>
<path class="line" d="M188 416 L285 482" />
<path class="line" d="M572 416 L475 482" />
<rect class="note" x="126" y="492" width="508" height="88" rx="14" />
<text class="label" x="164" y="527">EXPLAIN (ANALYZE, BUFFERS)</text>
<text class="text" x="164" y="555">сравнить rows, actual rows, loops и Buffers до нового CREATE INDEX</text>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+479
View File
@@ -0,0 +1,479 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(code) {
return '<pre><code>' + escapeHtml(String(code).trim()) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function bulletList(items) {
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
}
function dataTable(caption, headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function visibleText(html) {
return html
.replace(/<[^>]*>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function proseText(html) {
return visibleText(
html
.replace(/<pre><code>[\s\S]*?<\/code><\/pre>/g, '')
.replace(/<figure>[\s\S]*?<\/figure>/g, '')
.replace(/<div class="table-scroll">[\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: 'стоимости, дерево плана, реальные строки и время в <code>EXPLAIN ANALYZE</code>; значения <code>actual time</code> и <code>rows</code> даны в среднем на один loop',
};
const pg11SqlExplain = {
title: 'PostgreSQL 11: EXPLAIN',
url: 'https://www.postgresql.org/docs/11/sql-explain.html',
note: '<code>ANALYZE</code> действительно выполняет statement, <code>BUFFERS</code> выводит статистику буферов; для изменяющих запросов документация рекомендует транзакцию с rollback',
};
const pg11PlannerStats = {
title: 'PostgreSQL 11: Statistics Used by the Planner',
url: 'https://www.postgresql.org/docs/11/planner-stats.html',
note: 'селективность оценивается по приблизительной статистике; <code>pg_stats</code> удобнее прямого чтения <code>pg_statistic</code>, а корреляцию столбцов не ловят обычные одноколоночные статистики',
};
const pg11Analyze = {
title: 'PostgreSQL 11: ANALYZE',
url: 'https://www.postgresql.org/docs/11/sql-analyze.html',
note: '<code>ANALYZE</code> собирает приблизительную выборку, обновляет статистику для планировщика и позволяет повышать 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('Симптом знакомый: на таблицу добавили индекс, запрос остался медленным, а в плане по-прежнему виден <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 &gt; 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`
-- Сначала фиксируем контекст, затем обновляем только нужную таблицу.
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('Обновление статистики не обещает смену узла. Оно делает следующую оценку честнее. Если после <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> как правильный план либо изменить именно подтверждённую причину, а не коллекционировать индексы.'),
],
[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('Симптом: в тикет кладут скриншот <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 = &#039;ready&#039;</code> почти ничего не отсекает, он может быть плохой точкой старта даже при наличии <code>Index Cond</code>. Если реальный пользовательский путь всегда просит редкий state и короткий диапазон, исследуем составной индекс и порядок его колонок на подтверждённых запросах. Если путь выбирает почти всё, возможно, правильнее остаться на последовательном чтении. Индекс проектируют от устойчивой нагрузки, не от одного названия поля.'),
heading('Статистика, корреляция и честная проверка'),
paragraph('Стандартная статистика хранится по отдельным столбцам. Планировщик обычно предполагает независимость условий, а в реальных таблицах столбцы часто связаны: город и почтовый индекс, тип заказа и статус, страна и валюта. PostgreSQL 11 поддерживает расширенную статистику, но это не кнопка «собрать всё». Сначала нужен доказанный плохой estimate на конкретном сочетании условий. Потом можно рассмотреть <code>CREATE STATISTICS</code> для этой группы и снова выполнить <code>ANALYZE</code>.'),
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 <code>EXPLAIN</code>, затем безопасный <code>EXPLAIN (ANALYZE, BUFFERS)</code>. Не запускать write-statement без транзакционной границы и разрешения на нагрузку.',
'Прочитать дерево от результата к входам и отметить scan/join, который создаёт крупный поток строк. Не считать верхний узел причиной только потому, что он напечатан первым.',
'На каждом ключевом узле сверить <code>rows</code> с <code>actual rows</code>; при <code>loops &gt; 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 и ищет первую неверную предпосылку. После этого индекс становится одним из вариантов действия, а не ответом, выбранным до вопроса.'),
],
[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('Симптом: в таблице уже есть индекс на <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 для приложения; это инструменты диагностики для инженера, который должен сопоставить запись с планом.'),
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('Последний запрос нарочно неудобен для обычного индекса на <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;
-- Только если есть доказанное расхождение на двух связанных равенствах:
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, сортировка или пагинация.',
'Снять <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 без этого только прячет проблему под новыми именами.'),
],
[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;
}
}