29.1 Комбинаторный взрыв
Соединение трёх таблиц A, B, C можно собрать в разном порядке:
(A⋈B)⋈C, (A⋈C)⋈B, (B⋈C)⋈A и так далее. Для каждого порядка
ещё выбирается алгоритм (nested loop / hash / merge) и метод доступа
к каждой таблице. Число комбинаций растёт быстрее факториала от
числа таблиц.
таблиц: 2 3 4 5 ... 10
порядков: 1 3 15 105 ... ~17 млн
На 4-5 таблицах перебрать всё ещё реально. На 10 - уже миллионы вариантов, и честный перебор каждого занял бы дольше, чем само выполнение запроса. Планировщик должен где-то остановиться.
29.2 Динамическое программирование и join_collapse_limit
Пока таблиц немного, планировщик ищет лучший порядок почти полным перебором, но умно - динамическим программированием: лучший план для пары таблиц переиспользуется при построении планов для троек, и так далее. Это резко сокращает работу по сравнению с наивным перебором.
Сколько таблиц «немного», задаёт join_collapse_limit (по
умолчанию 8). Он управляет тем, насколько планировщик волен менять
порядок явных JOIN. Если число таблиц в одном уровне FROM
превышает лимит, планировщик перестаёт переставлять их и берёт
порядок, как написано в запросе.
SHOW join_collapse_limit; -- 8
SHOW from_collapse_limit; -- 8 (то же для подзапросов)
Практическое следствие: на запросе с десятком таблиц порядок, в
котором ты написал JOIN, начинает влиять на план. Иногда это
используют намеренно - выставляют join_collapse_limit = 1, чтобы
зафиксировать порядок соединений вручную.
29.3 GEQO: генетический поиск
Когда таблиц очень много, даже динамическое программирование становится дорогим. На этот случай есть GEQO - genetic query optimization. Вместо перебора он ищет хороший порядок генетическим алгоритмом: берёт несколько случайных порядков, «скрещивает» лучшие, вносит мутации, отбирает по стоимости - несколько поколений, и достаточно хороший порядок найден.
Включается GEQO, когда число таблиц в соединении достигает
geqo_threshold (по умолчанию 12). Важная особенность: GEQO не
гарантирует оптимальный план и недетерминирован - на одном и том же
запросе он может выдать чуть разные порядки. Это сознательный
размен: на 15 таблицах лучше быстро найти хороший план, чем долго
искать идеальный.
SHOW geqo_threshold; -- 12
29.4 Сколько стоит само планирование
Планирование - это работа, и на сложных запросах она заметна. Увидеть её можно двумя способами:
EXPLAIN ANALYZE SELECT ...; -- внизу строка Planning Time
EXPLAIN (MEMORY) SELECT ...; -- сколько памяти ушло на планирование
Planning Time против Execution Time показывает баланс: если
запрос выполняется за 2 мс, а планируется за 20 - планирование
доминирует, и его стоит кешировать. Меняя join_collapse_limit и
geqo_threshold, можно наблюдать, как растёт Planning Time с
числом рассмотренных порядков. Это и есть мотивация кеша плана:
запланировать один раз, выполнять много.
29.5 Кеш плана: prepared statements
Подготовленное выражение (PREPARE) разбирается и анализируется
один раз, а выполняется много с разными параметрами. Сначала
PostgreSQL для каждого вызова строит custom plan - план под
конкретные значения параметров. Это точно, но каждый раз тратит
время на планирование.
PREPARE q(int) AS SELECT * FROM tickets WHERE flight_id = $1;
EXECUTE q(7);
EXECUTE q(8);
После нескольких выполнений (порядка пяти) планировщик прикидывает:
а нельзя ли построить один generic plan - план без привязки к
значению параметра, который годится для любого $1? Если средняя
стоимость custom-планов не выгоднее generic, он переключается на
generic и перестаёт планировать на каждый вызов.
29.6 Generic против custom plan
Разница принципиальная. Custom plan видит конкретное значение и
оценивает селективность точно: flight_id = 7 - редкое значение,
возьмём индекс. Generic plan значения не видит и берёт усреднённую
селективность - что хорошо для «обычного» параметра, но плохо для
перекошенных данных.
SELECT name, generic_plans, custom_plans
FROM pg_prepared_statements;
Управлять выбором можно вручную:
SET plan_cache_mode = force_custom_plan; -- всегда планировать заново
SET plan_cache_mode = force_generic_plan; -- всегда один план
SET plan_cache_mode = auto; -- решает сам (по умолчанию)
29.6.1 Подводный камень: generic plan на перекошенных данных
Беда приходит, когда значения параметра сильно различаются по
селективности. Пусть status = 'done' встречается у 99% строк, а
status = 'error' - у 0.01%. Для 'error' нужен индекс, для
'done' - Seq Scan.
Generic plan усреднит и выберет что-то одно - и будет плохим для
половины вызовов. Симптом: после нескольких выполнений
подготовленное выражение «вдруг» замедлилось (переключилось на
generic), хотя текст и параметры те же. Видно по росту
generic_plans в pg_prepared_statements.
Лечение - plan_cache_mode = force_custom_plan для таких запросов:
пусть планирует каждый раз, зато под реальное значение. Платишь
временем планирования, выигрываешь правильный план. Подробнее про
кеш и порядок - в join-order-geqo.
Уроки в sandbox
lab-29.1. Наблюдать GEQO и переключение generic/custom plan
Сначала посмотрим, как растёт время планирования с числом рассмотренных порядков, потом поймаем переключение подготовленного выражения с custom на generic plan. Предсказывай счётчики до проверки.
Сделай
EXPLAIN ANALYZEзапроса с соединением tickets, flights, bookings и запиши Planning Time.Зафиксируй порядок:
SET join_collapse_limit = 1;и повтори - сравни Planning Time и план (порядок берётся как в запросе).Подготовь выражение:
PREPARE q(int) AS SELECT * FROM tickets WHERE flight_id = $1;.Выполни
EXECUTE q(7);шесть раз подряд. Предскажи, на каком вызове появится generic plan.Проверь счётчики:
SELECT name, generic_plans, custom_plans FROM pg_prepared_statements;- оба должны быть ненулевыми.Принудительно:
SET plan_cache_mode = force_custom_plan;, выполни ещё раз и убедись, что растёт только custom_plans.
sandbox с автопроверкой - открыть в песочнице
Резюме
- Число порядков соединения растёт быстрее факториала от числа таблиц; на 4-5 таблицах перебор реален, на 10 - миллионы вариантов.
- Планировщик ищет порядок динамическим программированием, переиспользуя планы подмножеств; join_collapse_limit (8) ограничивает, сколько JOIN он волен переставлять.
- При join_collapse_limit таблиц больше лимита порядок берётся как написан; join_collapse_limit = 1 фиксирует порядок соединений вручную.
- При числе таблиц от geqo_threshold (12) включается GEQO - генетический поиск порядка: быстрый, но недетерминированный и не гарантирующий оптимум.
- Planning Time и EXPLAIN (MEMORY) показывают стоимость самого планирования; когда оно доминирует над выполнением - план стоит кешировать.
- Подготовленное выражение сначала строит custom plan под значения, после нескольких вызовов может перейти на generic plan без привязки к параметру.
- Generic plan плох на перекошенных данных (значения с разной селективностью); лечится plan_cache_mode = force_custom_plan, видно по generic_plans в pg_prepared_statements.
Контрольные вопросы
Почему планировщик не перебирает все порядки соединения на запросе с 15 таблицами?
Показать ответ
Потому что число порядков растёт быстрее факториала: на 15 таблицах это астрономическое количество вариантов, и честный перебор каждого занял бы дольше, чем само выполнение запроса. На малом числе таблиц планировщик использует динамическое программирование (почти полный, но умный перебор), а при достижении geqo_threshold (12) переключается на GEQO - генетический алгоритм, который находит достаточно хороший порядок за разумное время, жертвуя гарантией оптимальности.
Что произойдёт с запросом, где число JOIN превышает `join_collapse_limit`?
Показать ответ
Планировщик перестанет свободно переставлять таблицы и возьмёт порядок соединений примерно так, как он записан в запросе. До лимита он сплющивает все JOIN в один уровень и ищет лучший порядок; сверх лимита - оставляет структуру как есть, чтобы не взорвать перебор. Поэтому на больших запросах порядок написания JOIN начинает влиять на план. Иногда это используют намеренно: join_collapse_limit = 1 фиксирует порядок вручную.
Чем custom plan отличается от generic plan у подготовленного выражения?
Показать ответ
Custom plan строится под конкретные значения параметров и оценивает селективность точно - но планируется заново на каждый вызов. Generic plan строится один раз без привязки к значению параметра, по усреднённой селективности, и переиспользуется - экономит время планирования, но не видит конкретных значений. PostgreSQL начинает с custom-планов, а после нескольких вызовов может перейти на generic, если тот не дороже в среднем.
Подготовленное выражение «вдруг» замедлилось после нескольких одинаковых вызовов. Почему?
Показать ответ
Скорее всего оно переключилось с custom на generic plan. После примерно пяти выполнений планировщик прикидывает, можно ли обойтись одним generic-планом. Если данные перекошены (значения параметра сильно различаются по селективности), generic plan усредняет и становится плохим для части значений - отсюда замедление при том же тексте и параметрах. Видно по росту generic_plans в pg_prepared_statements; лечится plan_cache_mode = force_custom_plan.
Когда вообще стоит беспокоиться о времени планирования?
Показать ответ
Когда оно сопоставимо с временем выполнения или больше его. Это видно в EXPLAIN ANALYZE по строке Planning Time против Execution Time. Для короткого запроса, выполняемого за пару миллисекунд, но планируемого за десятки, планирование доминирует - и его стоит кешировать через подготовленные выражения. Для тяжёлого запроса, идущего минуты, время планирования незначимо, и кеш плана большой пользы не даст.