25.1 Стоимость - это условные единицы
Стоимость в PostgreSQL измеряется в абстрактных единицах. За точку отсчёта взято чтение одной страницы с диска последовательно - это стоит ровно 1.0. Всё остальное выражено относительно неё: случайное чтение страницы дороже, обработка строки процессором - дешевле.
SHOW seq_page_cost; -- 1.0 последовательное чтение страницы
SHOW random_page_cost; -- 4.0 случайное чтение страницы
SHOW cpu_tuple_cost; -- 0.01 обработка одной строки
SHOW cpu_index_tuple_cost; -- 0.005 обработка записи индекса
SHOW cpu_operator_cost; -- 0.0025 вычисление оператора/функции
Эти числа - не физика, а модель. random_page_cost = 4 отражает,
что на вращающемся диске случайное чтение примерно вчетверо дороже
последовательного. На SSD это соотношение меньше, поэтому на
серверах с SSD random_page_cost часто снижают до 1.1-2.0 - и тем
самым делают индексные сканы выгоднее в глазах планировщика.
25.2 Формула Seq Scan
Самый простой план - последовательное чтение всей таблицы. Его стоимость складывается из двух частей: прочитать все страницы и обработать все строки.
cost(Seq Scan) = relpages × seq_page_cost
+ reltuples × cpu_tuple_cost
relpages и reltuples - это сколько страниц и строк, по мнению
планировщика, в таблице. Берутся они из pg_class и обновляются
при ANALYZE/VACUUM:
SELECT relpages, reltuples FROM pg_class WHERE relname = 'tickets';
Если в запросе есть WHERE, добавляется стоимость проверки условия
на каждой строке: reltuples × cpu_operator_cost за каждый
оператор в фильтре. Само число прочитанных страниц от WHERE не
меняется - Seq Scan всё равно читает всю таблицу, фильтр лишь
отсеивает строки после чтения.
25.3 Считаем руками
Возьмём tickets. Пусть pg_class показывает relpages = 4,
reltuples = 500 (числа зависят от данных в твоём образе - бери
свои).
cost = 4 × 1.0 + 500 × 0.01
= 4.0 + 5.0
= 9.0
Теперь сверяем:
EXPLAIN SELECT * FROM tickets;
-- Seq Scan on tickets (cost=0.00..9.00 rows=500 width=...)
total cost в плане - ровно 9.00. Это не совпадение и не
приближение: планировщик честно применил ту же формулу. Когда
числа сходятся вручную, исчезает ощущение, что планировщик
«решает по-своему» - он считает арифметику, которую ты теперь
умеешь повторить. Подробный разбор - в cost-model.
25.4 Startup cost и total cost
В плане у каждого узла не одно число, а два: cost=0.00..9.00.
Первое - startup cost, стоимость до выдачи первой строки. Второе -
total cost, стоимость выдачи всех строк.
Для Seq Scan startup близок к нулю: первую строку отдают почти сразу. А вот у сортировки startup большой - чтобы отдать первую строку, нужно сначала отсортировать всё:
Sort (cost=39.27..40.52 rows=500 width=...)
-> Seq Scan on tickets (cost=0.00..9.00 rows=500 ...)
У Sort startup 39.27 - это работа, которую надо сделать до первой
строки. Различие важно, потому что планировщик выбирает план не
всегда по total cost. Когда наверху стоит LIMIT, он смотрит,
сколько стоит получить только первые строки, - и тут план с
маленьким startup выигрывает у плана с маленьким total. Разбор -
в startup-total-cost.
25.4.1 Подводный камень: LIMIT меняет выбор плана
LIMIT 10 способен перевернуть решение. Без него планировщик
сравнивает планы по total cost, и Seq Scan большой таблицы может
проиграть индексу. С LIMIT 10 важна стоимость первых десяти
строк, а не всех.
Если есть индекс по колонке из ORDER BY, Index Scan отдаёт строки
уже в нужном порядке и с маленьким startup: десять строк - и
готово. Seq Scan + Sort обязан прочитать и отсортировать всю
таблицу, прежде чем отдать первую, - его startup огромен. Поэтому
запрос ORDER BY x LIMIT 10 с индексом по x уходит в Index Scan,
хотя без LIMIT тот же запрос мог бы выбрать Seq Scan + Sort.
Отсюда практическое: LIMIT - не косметика поверх готового
результата, а вход в стоимостную модель, меняющий сам план.
25.5 Кардинальность: оценка числа строк
В каждом узле плана есть rows=N - сколько строк, по оценке
планировщика, вернёт этот узел. Это кардинальность, и она важнее
всех параметров стоимости вместе взятых. Почему: стоимость
родительских узлов считается из числа строк, которое отдают дети.
Ошибся в строках внизу - ошибся в стоимости наверху.
Кардинальность фильтра выводится из селективности - доли строк,
проходящих условие. Если планировщик считает, что departure = 'SVO' пропускает 80% строк, а реально 100%, он недооценит размер
и может выбрать неподходящий способ соединения дальше. Селективность
он берёт из статистики - гистограмм и списков частых значений, о
которых вся следующая глава.
25.5.1 Копнуть глубже: ошибка кардинальности растёт снизу вверх
Самое опасное в неверной кардинальности - она не остаётся локальной, а распространяется вверх по дереву и усиливается.
Пусть планировщик решил, что скан вернёт 10 строк, а реально их 10 000. Под это «10» он выбрал nested loop - алгоритм, отличный для десятка строк и катастрофический для десяти тысяч (он повторяет внутренний поиск на каждую внешнюю строку). На следующем уровне ошибка ещё растёт. В итоге план, идеальный для воображаемых 10 строк, выполняется минутами на реальных 10 000.
Вот почему диагностика планов - это всегда сравнение rows (оценка)
с фактическим числом из EXPLAIN ANALYZE. Где оценка резко
разошлась с фактом - там корень проблемы, а не там, где больше
всего времени. К этому вернёмся в главе 30.
Уроки в sandbox
lab-25.1. Воспроизвести стоимость Seq Scan вручную
Достанем relpages/reltuples из pg_class, посчитаем стоимость по формуле и сверим с EXPLAIN. Перед сверкой запиши своё число - оно должно совпасть с total cost до сотых.
Узнай параметры:
SHOW seq_page_cost; SHOW cpu_tuple_cost;(по умолчанию 1.0 и 0.01).Достань размеры:
SELECT relpages, reltuples FROM pg_class WHERE relname = 'tickets';.Посчитай руками:
relpages * 1.0 + reltuples * 0.01. Запиши результат.Сверь:
EXPLAIN SELECT * FROM tickets;- total cost (второе число в cost=..) должно совпасть с твоим расчётом.Добавь сортировку:
EXPLAIN SELECT * FROM tickets ORDER BY ticket_no;. Заметь большой startup у Sort и объясни, откуда он.Добавь LIMIT к ORDER BY и посмотри, меняется ли план/стоимость первых строк.
sandbox с автопроверкой - открыть в песочнице
Резюме
- Стоимость - условные единицы, где последовательное чтение страницы = 1.0. random_page_cost (4.0), cpu_tuple_cost (0.01) и прочие выражены относительно неё.
- Seq Scan стоит relpages × seq_page_cost + reltuples × cpu_tuple_cost; relpages/reltuples берутся из pg_class и обновляются при ANALYZE/VACUUM.
- Формулу можно воспроизвести вручную и сверить с EXPLAIN до сотых - планировщик считает ту же арифметику, а не решает по-своему.
- У каждого узла два числа: startup cost (до первой строки) и total cost (до всех). У Seq Scan startup ~0, у Sort - большой.
- LIMIT смещает выбор в пользу плана с маленьким startup: ORDER BY x LIMIT 10 с индексом по x уходит в Index Scan вместо Seq Scan + Sort.
- Кардинальность (rows) важнее параметров стоимости: из неё считается стоимость родительских узлов, и ошибка распространяется вверх по дереву.
- Селективность фильтра планировщик берёт из статистики; неверная оценка числа строк - частый корень плохих планов, виден как расхождение rows и actual.
Контрольные вопросы
Почему на маленькой таблице PostgreSQL всегда выбирает Seq Scan, даже если есть индекс?
Показать ответ
Потому что по стоимостной модели прочитать несколько страниц последовательно дешевле, чем лезть в индекс и потом случайными обращениями ходить в таблицу за строками. На таблице в несколько страниц Seq Scan стоит единицы, а индексный доступ добавляет чтение индекса плюс случайные чтения страниц данных (random_page_cost = 4 за каждую). Индекс начинает выигрывать только когда таблица большая, а условие отбирает малую долю строк.
Что означают два числа в `cost=0.00..9.00`?
Показать ответ
Первое - startup cost: оценка работы до выдачи первой строки. Второе - total cost: оценка работы до выдачи всех строк. Для Seq Scan startup близок к нулю (первую строку отдают сразу), а total растёт с размером таблицы. Для узлов вроде Sort startup большой: чтобы отдать первую строку, надо сначала обработать весь вход. Различие важно при выборе плана с LIMIT.
Почему `ORDER BY x LIMIT 10` может выбрать индекс, а тот же запрос без LIMIT - нет?
Показать ответ
Без LIMIT планировщик сравнивает планы по total cost, и Seq Scan + Sort может оказаться дешевле полного прохода по индексу. С LIMIT 10 важна стоимость только первых десяти строк. Index Scan по x отдаёт строки уже отсортированными с маленьким startup - десять строк, и хватит. Seq Scan + Sort обязан прочитать и отсортировать всю таблицу до первой строки, поэтому его огромный startup проигрывает. LIMIT - это вход в модель, а не пост-обработка.
Почему ошибка в оценке числа строк опаснее, чем неточность параметров стоимости?
Показать ответ
Потому что число строк (кардинальность) - вход для стоимости всех родительских узлов. Если скан, по оценке, вернёт 10 строк, а реально 10 000, планировщик под эти «10» выберет, например, nested loop, который катастрофичен на 10 000. Ошибка не локальна: она растёт вверх по дереву и определяет выбор алгоритмов соединения. Неточность cpu_tuple_cost сдвинет стоимость на проценты, а ошибка кардинальности в сто раз - выберет принципиально другой, неподходящий план.
Зачем на SSD-сервере снижают `random_page_cost`?
Показать ответ
Дефолт
random_page_cost = 4отражает вращающийся диск, где случайное чтение примерно вчетверо дороже последовательного. На SSD случайный доступ почти так же быстр, как последовательный, поэтому четырёхкратный штраф завышен. Снижая random_page_cost до 1.1-2.0, ты сообщаешь планировщику, что индексные сканы (с их случайными обращениями к страницам) дешевле, чем он думает по умолчанию, - и он начинает выбирать их охотнее, что для SSD обычно правильно.