Когда речь заходит об оптимизации запросов в PostgreSQL, разработчики, как правило, сосредотачиваются на времени выполнения: индексы, планы запросов, настройки памяти и так далее. Время планирования остаётся в тени. А зря! Планировщик работает перед каждым выполнением запроса. Для OLTP-нагрузки с короткими транзакциями накладные расходы на планирование могут составлять значительную долю от общего времени ответа. Для запросов с большими IN-списками и высоким statistics_target планирование может занимать сотни миллисекунд, тогда как само выполнение укладывается в миллисекунды. Именно поэтому ускорение планировщика не менее важно, чем ускорение выполнения. Эта статья - о том, как мы нашли и устранили одну из таких скрытых проблем.
Чтобы понять суть проблемы, нужно разобраться, как PostgreSQL оценивает селективность запросов. Когда планировщик строит план, ему нужно оценить, сколько строк вернёт тот или иной предикат. Для этого он опирается на статистику, которую собирает ANALYZE. Одна из ключевых структур в этой статистике - список наиболее часто встречающихся значений, MCV (Most Common Values). Для каждой колонки PostgreSQL хранит до statistics_target таких значений вместе с их частотами. По умолчанию statistics_target равен 100, но его можно увеличить - глобально через default_statistics_target или для конкретной колонки через ALTER TABLE:
statistics_target
ALTER TABLE users ALTER COLUMN status SET STATISTICS 1000;
Чем выше значение, тем точнее статистика, но тем и длиннее MCV-список. Посмотреть на них можно через pg_stats:
pg_stats
SELECT most_common_vals, most_common_freqs FROM pg_stats WHERE tablename = 'users' AND attname = 'status'; most_common_vals | most_common_freqs --------------------------+------------------- {active,inactive,pending} | {0.70,0.20,0.10}
Когда планировщик оценивает предикат вроде WHERE status = 'active', он находит это значение в MCV и сразу берёт его частоту - 0.70. Оценка получается точной. Аналогично работает оценка для джойнов: чтобы понять, сколько строк выдаст соединение двух таблиц по равенству, планировщик сопоставляет MCV-списки обоих столбцов и смотрит, какие значения встречаются с обеих сторон. Та же логика применяется и для IN/ANY: планировщик сравнивает элементы IN-списка с MCV-значениями колонки, чтобы оценить, какая доля строк пройдёт через фильтр.
В ноябре 2025 года в PostgreSQL был влит коммит 057012b, ускоряющий оценку селективности для операторов соединения. Проблема заключалась в том, что при сопоставлении MCV-списков двух сторон джойна планировщик сравнивал все значения попарно - вложенный цикл, сложность O(N*M). Сегодня default_statistics_target нередко выставляют в сотни и тысячи записей - и при таких размерах MCV-списков квадратичный алгоритм становится реальной проблемой. Решение оказалось классическим: при достаточно большом суммарном размере списков (порог - 200 значений) вместо вложенного цикла строится хеш-таблица, и алгоритм переходит в O(N+M). Попутно была устранена ещё одна неэффективность: при вычислении селективности для semi-join работа по сравнению MCV-значений повторялась дважды без необходимости.
После того как джойны были ускорены, стало очевидно: аналогичная проблема существует и для выражений вида WHERE x IN (...), WHERE x = ANY(ARRAY[...]), WHERE x <> ALL(ARRAY[...]). Когда для колонки доступна MCV-статистика, планировщик сопоставляет каждый элемент IN-списка с каждым MCV-значением - тот же вложенный цикл, та же сложность O(N×M). Чем выше statistics_target, тем длиннее MCV-список - и тем больше времени тратит планировщик. Вот как растёт время планирования для типа bytea при IN-списке из 10000 элементов в зависимости от statistics_target:
statistics_target | Время планирования (мс) |
100 | 0.984 |
500 | 1.260 |
1000 | 4.183 |
2500 | 64.715 |
5000 | 251.619 |
7500 | 562.775 |
10000 | 998.330 |
Рост очевидно нелинейный - именно потому, что сложность квадратичная. При statistics_target = 10000 планирование одного запроса занимает почти секунду.
Решение аналогично тому, что уже было сделано для джойнов: вместо вложенного цикла строится хеш-таблица на меньшем из двух списков, а больший сканируется линейно. Сложность падает до O(N+M), и planning time при statistics_target = 10000 сокращается с ~1000 мс до ~3.5 мс.
statistics_target | Время планирования без патча (мс) | Время планирования после патча (мс) | Ускорение x |
100 | 0.984 | 0.697 | 1.41 |
500 | 1.260 | 0.984 | 1.28 |
1000 | 4.183 | 1.825 | 2.29 |
2500 | 64.715 | 1.298 | 49.86 |
5000 | 251.619 | 4.751 | 52.96 |
7500 | 562.775 | 2.895 | 194.40 |
10000 | 998.330 | 3.561 | 280.36 |
Пока основной патч проходил ревью, в марте 2026 года в мастер был влит ещё один небольшой коммит c95cd29. Он закрывает частный, но важный случай: выражение x <> ALL(ARRAY[..., NULL, ...]) никогда не может вернуть true для строгих операторов, если массив содержит хотя бы один NULL. Планировщик раньше это не учитывал и честно перебирал все элементы массива перед тем, как получить нулевую селективность. Теперь при обнаружении NULL планировщик немедленно выходит из цикла и возвращает 0.0 - без лишней работы.
Интересно, что ранний выход из цикла - не предел того, что можно сделать этим случаем. В коммит-месседже уже была оговорка о возможном развитии: если строгий оператор используется в условии WHERE, то x <> ALL(ARRAY[..., NULL, ...]) в принципе не может вернуть true - только false или NULL, а для WHERE это эквивалентно исключению строки. Значит, всё выражение можно свернуть в константу false ещё на этапе constant folding, до всякого планирования - и тогда планировщик получит возможность вообще исключить сканирование из плана, а не просто быстрее оценить его селективность. Сейчас эта идея обсуждается на коммитфесте в виде отдельного патча. Если патч примут, это станет логичным следующим шагом после ускорения оценки селективности: не просто быстрее посчитать, что строк не будет, а вообще пропустить сканирование.
Время планирования - не второстепенная деталь. Если вы используете высокий statistics_target и большие IN-списки, планировщик может тратить на оценку селективности больше времени, чем на само выполнение запроса. Описанные улучшения - шаг к тому, чтобы точная статистика не становилась источником новых проблем с производительностью. Патч сейчас проходит ревью на PostgreSQL Commitfest.
