Ускорение планирования запросов postgresql с Any/in/all до 280 раз быстрее

Ускорение планирования запросов с ANY - до 280 раз быстрее

Оптимизируя PostgreSQL, чаще всего смотрят на скорость выполнения запроса: добавляют индексы, перестраивают JOIN'ы, настраивают память, следят за I/O. Но перед тем как запрос начнёт выполняться, он каждый раз проходит этап планирования - и именно этот этап нередко становится скрытым "пожирателем" времени ответа. В OLTP-сценариях, где транзакции короткие и запросы простые, доля накладных расходов на планировщик может быть неожиданно высокой. В особенно неприятных случаях PostgreSQL способен планировать запрос сотни миллисекунд, а выполнять - считанные миллисекунды.

Эта ситуация особенно заметна на запросах с большими списками значений: `IN (...)`, `= ANY(ARRAY[...])`, `<> ALL(ARRAY[...])`. Если список содержит тысячи элементов, а статистика по колонке детальная, то планировщик начинает тратить время не на построение "умного" плана, а на вычисление селективности предиката - то есть на попытку понять, какая доля строк пройдёт фильтр.

Почему планировщику вообще нужна селективность

Когда PostgreSQL строит план, ему нужно оценить кардинальность: сколько строк вернёт фильтр и сколько строк пройдёт через каждый шаг плана. Для этого используются статистики, которые собирает `ANALYZE`. Одна из ключевых частей этих статистик - MCV (Most Common Values), список наиболее частых значений колонки с их частотами.

Размер MCV-списка ограничивается параметром `statistics_target`. По умолчанию он равен 100, но на практике его нередко повышают: глобально через `default_statistics_target` или точечно для конкретной колонки. Чем выше `statistics_target`, тем точнее оценки по данным - но тем больше становится MCV, и тем дороже вычисления на этапе планирования.

Простой пример: для условия `WHERE status = 'active'` планировщик пытается найти `'active'` среди MCV. Если значение там есть, он сразу берёт его частоту (например, 0.70) и получает точную оценку. Похожая идея используется и для соединений: чтобы оценить равенство в JOIN, PostgreSQL сопоставляет MCV-списки двух колонок и ищет пересечения значений.

Точно так же устроена оценка для `IN/ANY`: элементы списка сравниваются с наиболее частыми значениями колонки, чтобы понять, сколько строк потенциально подходит под фильтр.

Где возникла проблема: квадратичная сложность

Долгое время сопоставление MCV-значений выполнялось "в лоб": вложенным циклом, когда каждый элемент одного списка сравнивается с каждым элементом другого. Это даёт сложность `O(N×M)`. Пока списки маленькие - всё терпимо. Но как только `statistics_target` становится сотнями или тысячами, а `IN`-список растёт до тысяч и десятков тысяч элементов, квадратичная сложность превращается в реальную задержку ответа.

В ноябре 2025 года в PostgreSQL приняли коммит 057012b, который ускорил оценку селективности для операторов соединения. Там было две ключевые идеи:

1. Если суммарный размер MCV-списков становится достаточно большим (порог - 200 значений), вместо вложенного цикла строится хеш-таблица, и алгоритм становится линейным: `O(N+M)`.
2. Попутно устранили ещё одну неэффективность: для semi-join ранее сравнение MCV-значений выполнялось дважды, хотя можно было обойтись одним проходом.

После улучшения JOIN'ов стало очевидно: аналогичная "дыра" осталась в оценке предикатов `IN/ANY/ALL`.

ANY/IN/ALL: тот же узел, та же боль

Для выражений вида:

- `WHERE x IN (...)`
- `WHERE x = ANY(ARRAY[...])`
- `WHERE x <> ALL(ARRAY[...])`

при наличии MCV-статистики планировщик сопоставлял каждый элемент списка со всем MCV - снова вложенный цикл `O(N×M)`. А значит, увеличение `statistics_target` автоматически увеличивало не только точность, но и время планирования.

Характерный пример: тип `bytea`, `IN`-список из 10000 элементов. Время планирования (мс) в зависимости от `statistics_target` росло резко и нелинейно:

- 100 → 0.984
- 500 → 1.260
- 1000 → 4.183
- 2500 → 64.715
- 5000 → 251.619
- 7500 → 562.775
- 10000 → 998.330

Почти секунда планирования на один запрос - это уже не "тонкая настройка", а полноценный производственный инцидент, особенно если таких запросов много и они приходят параллельно.

Что изменили: хеширование вместо вложенного цикла

Лечение оказалось таким же, как и для JOIN'ов: вместо попарного сравнения строится хеш-таблица по меньшему из двух наборов (MCV или список значений), после чего больший набор сканируется линейно. Итог - сложность падает до `O(N+M)`.

На тех же вводных (`statistics_target = 10000`) планирование сократилось примерно с ~1000 мс до ~3.5 мс. В сравнительной таблице ускорение доходило до 280 раз: например, 998.330 мс превращались в 3.561 мс, что давало x280.36.

Важно, что это ускорение относится именно к этапу планирования: выполнение запроса и раньше могло быть быстрым, но теперь быстрым становится и "пролог" перед ним.

Дополнительный частный случай: NULL в `<> ALL(...)`

Пока основной патч проходил ревью, в марте 2026 года в мастер добавили ещё один небольшой, но практичный коммит c95cd29. Он закрывает частный сценарий: выражение вида `x <> ALL(ARRAY[..., NULL, ...])` для строгих операторов никогда не может вернуть true, потому что наличие `NULL` делает сравнение неопределённым. Корректное раннее распознавание такого случая избавляет планировщик от лишней работы и предотвращает неверные ожидания от предиката.

---

Что это значит для практики: когда планирование становится проблемой

1) OLTP и короткие запросы. Если ваш запрос выполняется за 1-5 мс, то даже 20-50 мс планирования - это катастрофа для p95/p99 задержек. Большие `ANY/IN`-списки в таких системах встречаются чаще, чем кажется: фильтрация по пачке ID, выборка по списку внешних ключей, сегментация пользователей, ACL-проверки.

2) Высокий `statistics_target`. Повышая `statistics_target`, вы улучшаете качество оценок, но одновременно раздуваете MCV-списки. До исправления это легко превращалось в "налог на планирование", который платился на каждом запросе.

3) Большие списки значений. Список из 10-50 элементов почти никогда не создаёт беды. Список из 10 000 - уже другой класс нагрузки, особенно при частом повторении.

---

Как снизить потери уже сейчас (даже без ожидания обновления)

1) Не передавайте огромные списки в `IN` текстом, если можно иначе.
Часто лучше загрузить значения во временную таблицу (или использовать `UNNEST` в подзапросе) и соединить через `JOIN`. Это даёт планировщику больше вариантов и зачастую снижает нагрузку на разбор/планирование, особенно если запрос повторяется.

2) Следите за стратегией подготовки запросов.
Если запросы однотипные и отличаются только набором параметров, подготовленные выражения могут уменьшить долю планирования (за счёт повторного использования плана). Но при этом важно понимать, что универсальный план не всегда оптимален по исполнению - придётся балансировать.

3) Повышайте `statistics_target` точечно.
Если вам нужна высокая точность статистики, не обязательно поднимать её глобально. Разумнее увеличивать `statistics_target` только на колонках, где это действительно влияет на планы, и помнить, что "детальнее статистика" - это ещё и "тяжелее планирование".

4) Диагностируйте именно planning time.
В профилировании запросов многие смотрят только на total time, забывая разделение на planning/execution. Если execution маленький, а total большой - почти наверняка виноват планировщик, а не индексы.

5) Контролируйте размер списков на уровне приложения.
Иногда самый эффективный "оптимизатор" - банальный лимит: разбивать список из десятков тысяч значений на батчи или менять протокол взаимодействия (например, передавать набор через таблицу/буферизацию), чтобы не кормить планировщик квадратичной работой.

---

Итог

Ускорение планирования для `ANY/IN/ALL` устраняет классическую проблему квадратичной сложности при сопоставлении большого списка значений и длинного MCV-списка, возникающего из-за высокого `statistics_target`. Переход на хеш-таблицу переводит алгоритм в `O(N+M)` и даёт ускорение вплоть до 280 раз: в реальных измерениях время планирования падало с почти секунды до нескольких миллисекунд. Для OLTP-нагрузки это означает не косметику, а прямое улучшение задержек и пропускной способности - просто потому, что сервер перестаёт тратить время на "размышления" дольше, чем на саму работу.

Прокрутить вверх