This commit is contained in:
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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 >= day</text>
|
||||
<text class="mono" x="440" y="337">AND created_at < 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 |
@@ -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 '<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(' ', ' ')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
.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 > 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 = 'ready'</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 > 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user