Недавно я изучал вопрос, насколько распространены полиморфные ссылки в реляционных базах — болезненном для производительности паттерне с дискриминированным внешним ключом, который автоматически генерируют ORM-фреймворки (Rails, Django, Hibernate), CRM-платформы (Salesforce) и 1С. Главная страница типичного интернет-магазина или activity-лента CRM-системы строится именно таким запросом: базовая таблица соединяется LEFT JOIN-ами со всеми возможными подтипами через пару столбцов (type, id).
Та статья отвечала на вопрос «насколько распространён подобный паттерн». Ведь если заниматься улучшением, то неплохо понимать, насколько оно полезно, не так ли? Здесь я пытаюсь дать представление о том, каким образом данный шаблон приводит к регрессии производительности и показать направления улучшения оптимизатора PostgreSQL, позволяющие облегчить ситуацию.
Спойлер: пока немного — но кое-что движется на pgsql-hackers. Три патча, обсуждавшихся в 2024–2026 годах, нацелены на три разных источника регрессии. Ниже о каждом.
Источники проблем
Напомню, как выглядит запрос:
SELECT ol.id, COALESCE(p.name, g.name, s.name) AS item_name FROM order_lines ol LEFT JOIN products p ON ol.type = 'A' AND ol.item_id = p.id LEFT JOIN gift_cards g ON ol.type = 'B' AND ol.item_id = g.id LEFT JOIN subscriptions s ON ol.type = 'C' AND ol.item_id = s.id WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.id = ol.order_id AND o.placed_at >= DATE '2024-01-01') ORDER BY ol.popularity LIMIT 100
В настоящий момент оптимизатор Postgres реализует весьма примитивную логику. Для каждой строки базовой таблицы (order_lines) запрос обращается к каждой из N таблиц-подтипов через LEFT JOIN. Только одно из этих соединений возвращает совпадение — то, чей дискриминатор соответствует значению type в данной строке. Остальные N−1 соединений гарантированно безрезультатны: их ON-предикат содержит другое значение дискриминатора (см. схему ниже).

Оптимизатор выбирает порядок сканирования inner side LEFT JOIN’a и применяет его ко всем строкам — он не умеет маршрутизировать каждую outer-строку к соответствующей inner-таблице на основе значения дискриминатора. Базовая стоимость паттерна — O(M × N) проб, из которых только O(M) полезны. Собственно, избежать обращения к каждой inner-side OUTER JOIN'a нельзя: нет такого constraint, который бы гарантировал непересекающиеся значения ключа в inner-таблицах. Так что нужно искать компромисс…
Собрав некоторую статистику по проблемным кейсам, я вывел для себя следующие основные причины деградации производительности запросов, встречающиеся в эксплуатации:
Размер базовой таблицы. Удвоение количества строк в базовой таблице удваивает число безрезультатных проб inner-side JOIN'a.
Агрегация (обычно ORDER BY + LIMIT) результата запроса. Сортировка часто блокирует пайплайн. Как следствие, оператор LIMIT не даёт возможности ограничить выборку из базовой таблицы и сократить сканирование join tree. Представьте, что из 100к видов товаров в
order_linesзапросу нужно показать только TOP-10 самых популярных — выполнение полного сканирования выглядит явно избыточным.EXISTS-подзапрос, который оптимизатор трансформирует (pull-up) в джойн. Несколько неочевидный, но болезненный и поэтому заметный кейс: при превышении количества таблиц в join tree величины
join_collapse_limitтакой pulled-up SEMI JOIN выпадает из задачи поиска порядка соединения и перемещается в другую задачу. Это приводит к тому, что изначально служащий как фильтр строк сканирования базовой таблицы, данный EXISTS уходит наверх дерева запроса и отфильтровывает строки базовой таблицы на сильно более поздней стадии выполнения запроса.Накопление погрешности оценки кардинальности. С увеличением количества джойнов эстимация быстро уходит в минимальное значение и не меняется выше по дереву плана. Для нашего каскада LEFT JOINs это не очень актуально, но всё же.
Разберём пути решения проблемы.
1. Result Filter и одностороннее ограничение
В декабре 2024 года на pgsql-hackers стартовало обсуждение под заголовком «Do not scan index in right table if condition for left join evaluates to false using columns in left table». Tom Lane предложил разделить условия JOIN ... ON ... на две группы — зависящие только от внешней (outer) стороны и зависящие от внутренней (inner). Условия первой группы можно оценить ДО запуска сканирования внутренней стороны NestLoop-а: если они уже ложны, сканирование можно пропустить целиком. Andres Freund предложил упростить реализацию и вынести вычисление outer clause в т.н. gating operator — отдельную ноду плана Result.
Необходимость посмотреть в inner side для каждой строки из outer-таблицы остаётся — нет пока такого constraint в базе, который бы гарантировал, что если нашлась строка для данного item_id в таблице gift_cards, то такой записи точно не будет в таблице subscriptions. Однако, поскольку каждая join clause имеет вид:
... ON ol.type = 'subscription' AND ol.id = s.id
то можно ограничить накладные расходы на сканирование inner'a, выполняя проверку условия на ol.type: если оно даёт false, то обращаться в inner нет смысла, поскольку строка inner'a всё равно будет заменена на набор NULL-значений. Получаем что-то вроде следующей схемы плана запроса:

Получается, мы добавляем в план запроса один оператор вычисления gating-выражения. Оно вычисляется для каждой строки outer-side этого джойна. Такая операция обычно будет достаточно дешёвая, не потребует обращения к shared buffers, индексам и диску. Так что, хотя формально обращение к inner-side остаётся, накладные расходы сводятся практически к нулю. Это особенно эффективно, если inner-side — это не просто сканирование таблицы, а сложно устроенное поддерево с дополнительными джойнами и агрегацией.
Тогда join tree плана нашего запроса будет выглядеть как-то так:

Выгоды такого подхода очевидны:
Уменьшение количества обращений к shared buffers и диску.
Ограниченное влияние на производительность при расширении списка категорий (количества соединяемых таблиц).
Защита от регрессии, если сканирование inner-side происходит не по индексу.
2. Sort Pushdown или предварительная сортировка
Вторая проблема масштабирования подобных запросов связана с конструкцией ORDER BY + LIMIT. Игнорировать это нельзя, поскольку выбор TOP-N наиболее популярных товаров — один из основных паттернов в электронных магазинах.
В чём собственно проблема? Если базовая таблица order_lines имеет подходящий индекс и данные поступают из нее уже отсортированными, то тогда конвейер джойнов работает без сбоев: мы извлечём из order_lines только минимально необходимое число строк для удовлетворения условия LIMIT. Однако, если данные не отсортированы, то через дерево джойнов пройдут все строки order_lines, а сортировка будет выполнена уже выше по дереву — см. рис. ниже:

Так почему бы просто не сделать индекс и всё? Во-первых, иметь много индексов часто не позволяет требование к скорости DML и maintenance операций над базой — каждый новый индекс — это часто дополнительные накладные расходы. А во-вторых, такой индекс может содержать много дубликатов, что влияет на его эффективность. Например, в нашем случае, если популярность дискретна и распределяется по какой-то фиксированной шкале целых чисел, то наличие такого индекса может быть не рационально.
Наиболее эффективным выходом из ситуации будет вставка операции сортировки (heapsort, TOP-N) сразу над оператором сканирования базовой таблицы — да, мы прочитаем все строки из неё один раз, но по крайней мере мы не будем пропускать их через всё дерево джойнов — то есть такая сортировка будет уменьшать регрессию при увеличении количества джойнов в этом дереве (см. рисунок ниже).

Тогда почему PostgreSQL не делает этого уже сейчас? Дело здесь в том, что планирование запроса происходит снизу вверх: в момент выбора наиболее оптимального метода сканирования оптимизатор ничего не знает о структуре дерева джойнов и о том, стоит ли сортировать результат сканирования по какому-либо из полей. И вышестоящий джойн также этого не знает, поскольку работоспособность методики зависит от структуры плана запроса в целом. Создавать же варианты планов "на всякий случай" — плохая стратегия, поскольку увеличивает пространство поиска, а значит, увеличивает потребление памяти и время планирования.
В апреле 2026 года в hackers mailing list был предложен патч: попытаться построить пред-сортированный путь для базовой таблицы NestLoop-а, если эта таблица упоминается в префиксе query_pathkeys. Здесь предлагается компромисс: если колонки таблицы есть в некотором префиксе оператора ORDER-BY или GROUP-BY, то есть немалая вероятность, что по этому префиксу будет эффективно иметь предсортированный набор строк. И это может давать позитивный эффект не только в нашем запросе, но и в MergeJoin операторах, интенсифицировать применение IncrementalSort, и в целом давать оптимизатору чуть большее пространство выбора — ведь сортировать по этому префиксу всё равно придётся, не так ли?
3. SubLink relocation или фильтруем как можно раньше
Эта проблема сложнее для понимания. Когда в запросе имеется (например) EXISTS-подзапрос, который не получается трансформировать в JOIN, то он будет выступать в качестве фильтра и уменьшать количество строк, которые будет производить оператор сканирования таблицы (см. картинку ниже). Следовательно, меньшее количество строк пройдёт через join tree. Если только 1% строк order_lines проходит EXISTS, то ровно 1% строк должен войти в дерево LEFT JOIN-ов, и оставшаяся работа сокращается в 100 раз.

Если же такой EXISTS-подзапрос будет трансформирован в JOIN, то с одной стороны это приведёт к изменению количества вычислений данного EXISTS — вместо эвалюации подзапроса на каждую строку таблицы количество проходов определяется типом JOIN'a (для HashJoin и MergeJoin это обычно один раз). С другой стороны, этот JOIN вставляется наверх join tree — то есть максимально далеко от базовой таблицы order_lines (см. рисунок ниже). И в случае, когда join_collapse_limit будет меньше количества джойнов, это приведёт к тому, что оптимизатор никогда не рассмотрит вариант orders ⋈ order_lines. А это означает потенциальный performance cliff — строки, которые ранее отфильтровывались на начальной стадии выполнения запроса после апгрейда или увеличения join tree вдруг станут проходить выше по дереву JOINов и фильтроваться сильно позже.

Для того, чтобы снизить остроту проблемы в мае 2026 года в hackers был предложен патч, который меняет логику трансформации подзапросов в JOIN (т.н. pull-up):
После преобразования EXISTS в SEMI JOIN найти ему подходящее место: не вставлять его на вершине дерева соединений. Вместо этого спустить SEMI JOIN внутрь минимального поддерева, содержащего отношения, на которые внутри данного подзапроса имеются ссылки.
В нашем примере есть только одна ссылка на внешние, по отношению к подзапросу EXISTS, таблицы — o.id = ol.order_id, которая ссылается только на order_lines. Поэтому вновь созданный SEMI JOIN вставляется в join tree прямо рядом с order_lines.
В итоге получаем следующий join tree:

И оптимизатор получает возможность выбрать порядок соединения, в том числе сделать его примерно таким (если это эффективно), каков он был до преобразования Subquery -> JOIN.
4. Join statistics
Данный текст был бы не полон без упоминания одной фичи, которая находится в активной разработке сообщества — JOIN selectivity statistics.
Alexandra Wang развивает рабочий прототип «статистики джойнов»: она пытается решить один из аспектов давней проблемы оптимизатора, который оценивает селективность джойна по статистике каждой таблицы в отдельности и потому не замечает перекосов в совместном распределении значений в одной и другой таблице. Эта ошибка разрастается вверх по плану, заставляя выбирать nested loop там, где нужен hash/merge. Идея фичи — собирать статистику по самому результату джойна и подставлять её в оценку селективности.
Предлагается это как декларативная вещь: пользователь сам объявляет нужный джойн через CREATE STATISTICS (по образцу обычной расширенной статистики). Сейчас реально работает узкий, но полезный срез: MCV-статистика для INNER JOIN двух таблиц по выражению равенства. Это пока proof-of-concept, и сообщество в целом согласно, что направление полезное. Открытыми остаются принципиальные вопросы — как хранить такую статистику в каталоге так, чтобы она обобщалась на джойны более чем двух таблиц, и как её честно собирать.
Итого
Полиморфный паттерн запросов — естественное следствие отображения объектно-ориентированных абстракций на реляционную модель. Запретить его «сверху» крайне сложно: он автоматически генерируется ORM-ами и платформами, а ретроактивные изменения схемы в крупных промышленных системах непомерно дороги. Значит, единственное практичное направление в краткосрок — сделать так, чтобы планировщик умел с ним лучше работать.
Три направления, перечисленных выше, призваны уменьшить нелинейные эффекты, которые могут появиться с ростом базы данных, либо с усложнением схемы данных. Ни одно из них не «закрывает» проблему целиком — но каждое снимает одну конкретную боль:
Result Filter / одностороннее ограничение убирает M × (N−1) безрезультатных проб.
Sort Pushdown позволяет LIMIT-у локализовать обращение к диску, когда нет индекса на условии сортировки.
SubLink relocation уменьшает эффект от не cost-based трансформации подзапросов (EXISTS, ANY, и т.д.).
Все три обсуждаются на pgsql-hackers и пока не закоммичены. Если у вас есть рабочая нагрузка с полиморфным паттерном — особенно с N > 10 LEFT JOIN-ов или с подзапросами в WHERE-части запросов, трансформация которых приводит к болезненным эффектам — мне будет очень интересно посмотреть. Пишите в комментарии или сразу в pgsql-hackers.
THE END.
20 июня 2026, Мадрид.