Будучи поклонником идеи Айзека Азимова о коллаборации C/Si форм жизни, я провёл эксперимент и сгенерировал данный текст автоматически, AI агентом, по контексту, сформированному в процессе разработки новой фичи оптимизатора Postgres и расследования проблемного corner case, который время от времени завершался с runtime-ошибкой. Это первый опыт подобной совместной работы и проба пера, поэтому возможны шероховатости. Однако сама проблема и вариант решения для Postgres валидированы вручную. Любая, даже самая жёсткая критика, приветствуется.
TL;DR
Фундаментальный принцип оптимизации запросов: результат не должен зависеть от плана выполнения. PostgreSQL обеспечивает это в том числе через механизм волатильных функций, которые ограничивают, куда оптимизатор может проталкивать (pushdown) предикаты и ключи сортировки. Но волатильность — не единственное свойство, которое может влиять на результат при изменении положения конкретного выражения (clause) в плане запроса: оператор, используемый в выражении может выдавать ошибку на входных данных в одном плане запроса, которые другой план отфильтровал бы.
Здесь мы исследуем проблему через призму конкретной оптимизации — outer join sort pushdown — которая позволяет помещать оператор Sort под OUTER JOIN для ускорения запросов с ORDER BY … LIMIT. Предлагаем способ поставить под контроль правила pushdown функций методом расширения существующего в PostgreSQL механизма planner support functions (prosupport) новым типом ‘хинтов’, позволяющим DBA декларировать функции небезопасными для раннего вычисления.
Независимость от плана и волатильность функций
Каждая функция в Postgres относится к одному из классов: IMMUTABLE, STABLE или VOLATILE:
Категория | Гарантия |
|---|---|
IMMUTABLE | Одинаковый результат всегда для одних и тех же аргументов |
STABLE | Одинаковый результат в рамках одного запроса |
VOLATILE | Может возвращать разный результат при каждом вызове |
Волатильность определяет, что оптимизатор вправе делать с выражением. Для VOLATILE-функций ключевое правило: оптимизатор не должен перемещать выражение туда, где изменится количество его вычислений. Например вызов random() должен выполняться один раз на каждую строку — сворачивание его в предвычисленную константу или использование в условии индекса изменило бы результат. STABLE- и IMMUTABLE-выражения таких ограничений не имеют: раз их значение не меняется (в рамках оператора или всего запроса), оптимизатор может безопасно вычислить их однажды и переиспользовать, протолкнуть в индексное сканирование или перенести между уровнями запроса.
Следующий пример наглядно показывает разницу. Допустим, есть таблица с индексом по timestamp:
CREATE TABLE events (ts timestamptz, data text); CREATE INDEX ON events (ts);
Фильтр с now() (STABLE) может быть вычислен один раз и передан напрямую в индекс — оптимизатор знает, что значение не изменится в ходе оператора:
EXPLAIN (COSTS OFF) SELECT * FROM events WHERE ts > now() - interval '1 hour';
Index Scan using events_ts_idx on events Index Cond: (ts > (now() - '01:00:00'::interval))
Но добавим random() (VOLATILE) — и картина меняется. Теперь выражение различно для каждой строки, индексное сканирование по такому выражению даст некорректный результат, и планировщик вынужден предлагать SeqScan, сканировать всю таблицу и проверять фильтр на лету:
EXPLAIN (COSTS OFF) SELECT * FROM events WHERE ts > now() - random() * interval '1 hour';
Seq Scan on events Filter: (ts > (now() - (random() * '01:00:00'::interval)))
Волатильность в Postgres работает корректно и не даёт оптимизатору переиспользовать значения, которые меняются между вызовами. Но у неё есть слепое пятно.
Слепое пятно: стабильные функции, которые падают с ошибкой
Возьмём приведение типа CAST(val AS integer). Оно IMMUTABLE — преобразование из text в integer детерминировано, не зависит от окружения. Не volatile, не set-returning, parallel-safe. Все защитные проверки оптимизатора пройдены.
Но это приведение — то, что мы будем называть "partial" функцией (заимствуя термин из математики): оно вызывает ошибку, если входные данные не являются допустимым целым числом (например, '42.1'). Волатильность ничего об этом не говорит. Оптимизатор считает приведение свободно перемещаемым — и может протолкнуть его туда, где оно встретит данные, которые исходный план отфильтровал бы.
Это не теоретическая проблема. Она уже проявляется в обычном PostgreSQL через банальный predicate pushdown. Вот пример:
CREATE TABLE raw_data (id integer PRIMARY KEY, val text); CREATE TABLE numbers (id integer references raw_data(id)); INSERT INTO raw_data VALUES (1, '42'), (2, '42.1'); INSERT INTO numbers VALUES (1);
SELECT q.val FROM (SELECT val FROM raw_data JOIN numbers USING (id)) AS q(val) WHERE CAST(val AS integer) > 0;
Подзапрос соединяет таблицы raw_data и numbers, оставляя только id=1 (val=‘42’). Внешний запрос применяет выражение CAST(val AS integer) > 0 к результату соединения. Читая запрос в синтаксическом порядке мы ожидаем, что оператор CAST никогда не получит на вход val=‘42.1’ — соединение должно отфильтровать эту строку. (SQL-стандарт на самом деле этого не гарантирует — порядок вычисления определяется реализацией — но текст запроса явно на это намекает).
Однако оптимизатор проталкивает предикат в сканирование таблицы raw_data:
Hash Join Hash Cond: (numbers.id = raw_data.id) -> Seq Scan on numbers -> Hash -> Seq Scan on raw_data Filter: ((val)::integer > 0)
Этот фильтр применяется к каждой строке raw_data — включая id=2, где val=‘42.1’. В результате запрос завершается ошибкой:
ERROR: invalid input syntax for type integer: "42.1"
Это классическая эквивалентность из реляционной алгебры σ_p(R ⋈ S) = σ_p(R) ⋈ S. Она работает, когда p ссылается только на столбцы R и является "total" предикатом — определённым для всех возможных наборов входных данных. Когда p является "partial" — когда он может выдать ошибку на некотором подмножестве входных данных — эквивалентность нарушается: левая часть вызывает ошибку на строках, которые правая часть тихо отфильтровала бы.
У оптимизатора нет способа это узнать. Оператор CAST относится к классу IMMUTABLE, предикат ссылается только на столбцы raw_data, и проталкивание вполне допустимо с точки зрения стандартной реляционной алгебры. Проблема в том, что преобразование корректно только для "total" выражений.
Это не только особенность PostgreSQL. В блоге SQL Server описана подобная же проблема ещё в 2006 году в посте “Predicate ordering is not guaranteed”: представление, которое конвертирует varchar в int только для определённых категорий строк, падает, когда оптимизатор вычисляет преобразование до строкового фильтра. Их рекомендованный обходной путь — использовать выражения CASE для защиты преобразования — по сути является ручным способом обеспечить "totality" на уровне запроса. Проблема хорошо известна в разных СУБД; не хватает только систематического решения.
Новые оптимизации увеличивают частоту появления проблемы
Столкнулись мы с этим в ходе имплементации и тестирования новой фичи оптимизатора Postgres, расширяющей пространство возможных планов для JOIN. Область её применения - запросы с LIMIT <N> ограничением на выборку и ORDER-BY на outer-сторону JOIN. Её суть в том, что иногда может быть эффективнее план с отсортированной outer-стороной, в котором этому JOIN нужно произвести не более N строк. Такая шаблонная конструкция, по отзывам, является характерной для запросов, генерируемых платформой 1С и эффективно планируется в SQL Server.
Сортировка до соединения
На тех же таблицах, что представлены выше, рассмотрим запрос с ORDER BY … LIMIT поверх соединения:
SELECT * FROM raw_data JOIN numbers USING (id) ORDER BY val LIMIT 10;
Обычно PostgreSQL отсортирует весь результат соединения, а потом возьмёт 10 строк:
Limit -> Sort Sort Key: raw_data.val -> Hash Join Hash Cond: (raw_data.id = numbers.id) -> Seq Scan on raw_data -> Hash -> Seq Scan on numbers
Наша оптимизация позволяет проталкивать Sort ниже соединения, если это эффективно с точки зрения cost-модели. Если inner-сторона мала или эффективно индексирована, NestLoop с отсортированной outer-стороной выигрывает — а LIMIT может распространиться вниз, задействуя top-N heapsort:
Limit -> Nested Loop -> Sort Sort Key: raw_data.val -> Seq Scan on raw_data -> Seq Scan on numbers Filter: (id = raw_data.id)
Когда ORDER BY ссылается на простой столбец вроде val — всё безопасно: Sort лишь переупорядочивает строки, которые сканирование и так уже подготовило. Запрос выполняется эффективно без изменения семантики. Все в выигрыше.
Где это ломается
Но что произойдёт, если ORDER BY использует "partial" выражение?
SELECT * FROM raw_data JOIN numbers USING (id) ORDER BY CAST(val AS integer) LIMIT 10;
Без оптимизации запрос работает: INNER JOIN исключает id=2 (нет соответствия в numbers), и Sort видит только val=‘42’. С оптимизацией Sort проталкивается ниже соединения — теперь он вычисляет CAST(val AS integer) на каждой строке raw_data, включая id=2, до того как соединение успеет её отфильтровать:
ERROR: invalid input syntax for type integer: "42.1"
Та же проблема "partial" функций, что и при predicate pushdown, только вызванная другой трансформацией. Исходный план исключил бы проблемную строку до вычисления приведения. Новая оптимизация сломала это привычное поведение, выполнив вычисление раньше.
Почему это важно на практике
Пример выше может выглядеть слегка натянутым, но он указывает на реальный риск в промышленной эксплуатации. Запрос, работающий сегодня, может начать выдавать ошибки после обновления СУБД - не потому что кто-то изменил запрос или данные, а потому что оптимизатор стал умнее.
Каждый мажорный релиз приносит новые оптимизации: эвристики переупорядочения соединений, incremental sort, memoize, partitionwise joins. Каждая из них даёт оптимизатору больше степеней свободы в перестроении дерева плана. Запрос, который годами работал, потому что старый планировщик всегда ставил оператор Sort над соединением, может сломаться в новой версии Postgres, который стал достаточно умён, чтобы протолкнуть его ниже. Пользователь получает, что стабильный запрос сломался при обновлении, без изменений схемы или данных — и без очевидного объяснения.
Это не лучшая характеристика СУБД. Успех или неуспех запроса не должен зависеть от того, какие оптимизации применит планировщик. Когда это происходит, пользователи теряют доверие к обновлениям и начинают фиксировать планы или отключать оптимизации, что обесценивает сам смысл существования оптимизатора.
Справедливости ради, SQL-стандарт явно допускает, что реализации могут различаться в том, выдаёт ли запрос ошибку или успешно завершается, в зависимости от порядка вычислений. Однако, с точки зрения пользователя это слабое утешение. Брешь достаточно реальна, чтобы даже крупные СУБД были вынуждены публиковать руководства по обходу проблемы, объясняя пользователям, как защитить себя от ошибок, вызванных оптимизатором.
Отсутствующее измерение в классификации функций
Примеры с predicate pushdown и sort pushdown — по сути одна и та же проблема в разных обёртках. Оптимизатор применяет трансформации, которые корректны в рамках стандартной реляционной алгебры — а та предполагает, что выражения "total" — но ломаются, когда выражения могут упасть с ошибкой.
PostgreSQL классифицирует функции в одном измерении: волатильность. Но есть второе измерение: "totality".
В математике "total" функция определена для каждого элемента своей области определения. "Partial" функция может быть не определена — то есть выдаёт ошибку — для некоторых входов:
Функция | Волатильность | Тотальность |
|---|---|---|
| IMMUTABLE |
|
| IMMUTABLE |
|
| IMMUTABLE |
|
| IMMUTABLE |
|
| STABLE |
|
| VOLATILE |
|
Может возникнуть вопрос: разве "partial" — это не просто «ещё более volatile, чем VOLATILE» — четвёртая ступень на шкале волатильности? Нет. Волатильность — это про стабильность значения: IMMUTABLE значит «никогда не меняется», STABLE — «постоянно в рамках запроса», VOLATILE — «может измениться при каждом вызове». Каждая ступень говорит оптимизатору, когда он вправе вычислять выражение — насколько агрессивно можно кэшировать и переиспользовать.
"Totality" задаёт другой вопрос: может ли вычисление упасть? И эти два вопроса действительно независимы:
random()максимально volatile, но никогда не падает. Оптимизатор обязан перевычислять его каждый раз — но это всегда безопасно.1/ximmutable, но может упасть с ошибкой. Оптимизатор может кэшировать его, протолкнуть в индексное условие — и один неудачный вход убивает запрос.
Если бы "partial" было просто «супер-volatile», оптимизатор отказался бы оптимизировать эти выражения вообще — обращаясь с CAST(val AS integer) как с random(). Но это слишком консервативно. Приведение является детерминированным; оптимизатор должен кэшировать его, использовать в индексных условиях, предвычислять с константами. Мы лишь хотим запретить его перемещение туда, где оно увидит нефильтрованные данные.
Итог: волатильность определяет когда вычислять (однократно? на каждую строку? при каждом вызове?). "Totality" определяет где вычислять (выше каких фильтров?). Это ортогональные измерения, а не точки на одной шкале.
От тернарной к кватернарной логике
SQL уже расширяет классическую булеву логику для обработки одного вида неопределённости: NULL. Сравнение с участием NULL даёт UNKNOWN, образуя знаменитую трёхзначную логику SQL (TRUE, FALSE, UNKNOWN).
Но ошибки времени выполнения — это четвёртый случай, который эта логика не покрывает. Когда выполняется CAST('42.1' AS integer), результат — не TRUE, не FALSE и не UNKNOWN — это ERROR, который убивает весь запрос.
Кватернарная логика сделала бы ERROR полноправным значением истинности с явными правилами распространения. Ключевой момент — порядок вычисления имеет значение: левый операнд вычисляется первым, и short-circuit может помочь только когда левая сторона уже определяет результат:
Выражение | Результат | Почему |
|---|---|---|
| FALSE | Левый операнд FALSE — short-circuit, правый не вычисляется |
| ERROR | Левый TRUE, нужно вычислить правый — который падает |
| TRUE | Левый TRUE — short-circuit, правый не вычисляется |
| ERROR | Левый FALSE, нужно вычислить правый — который падает |
| ERROR | Левый падает до того, как мы дойдём до правого |
| ERROR | Левый падает до того, как мы дойдём до правого |
| ERROR | Операнд падает |
Обратите внимание на асимметрию: FALSE AND ERROR безопасно (FALSE замыкает), а ERROR AND FALSE — нет (ошибка срабатывает первой). Это совпадает с семантикой выражений CASE — единственной конструкции SQL, где стандарт действительно гарантирует порядок вычисления слева направо.
В рамках этой логики правило predicate pushdown σ_p(R ⋈ S) = σ_p(R) ⋈ S получило бы явное предусловие: p не должен выдавать ERROR ни на какой строке R, которую соединение с S исключило бы. Для "total" предикатов это выполняется автоматически. Для "partial" — оптимизатору пришлось бы либо доказать предусловие, либо отказаться от трансформации.
То же касается sort pushdown: проталкивание Sort ниже соединения безопасно, только если выражение сортировки "total" — или если оно уже вычисляется на уровне сканирования (тогда Sort лишь переупорядочивает строки, не добавляя новых вычислений).
Как могло бы выглядеть решение
В PostgreSQL уже есть механизм, созданный для задач такого рода: planner support functions (prosupport). Каждая запись в pg_proc может опционально ссылаться на support-функцию, которую оптимизатор вызывает на этапе планирования для получения информации, специфичной для данной функции. Сейчас prosupport-функции могут корректировать пользовательские оценки селективности, переопределять косты, оценки кардинальности для set-returning функций и упрощение вызовов.
Инфраструктура уже имеется. Новый тип запроса — SupportRequestSafeEarlyEval — позволил бы функции сообщить оптимизатору: «не вычисляй меня ниже определённого уровня».
typedef struct SupportRequestSafeEarlyEval { NodeTag type; /* рассматриваемый вызов функции */ FuncExpr *funcexpr; /* support-функция устанавливает это значение */ bool safe; /* true = можно проталкивать */ } SupportRequestSafeEarlyEval;
Естественное место для вызова — relation_can_be_sorted_early() в equivclass.c — функция, которая уже служит центральной проверкой для вопроса «можно ли вычислить это выражение на более низком уровне плана?». Она уже проверяет волатильность, set-returning функции и parallel safety. Вызов prosupport вписался бы четвёртой проверкой:
/* существующие проверки */ if (ec->ec_has_volatile) return false; if (expression_returns_set(...)) continue; if (!is_parallel_safe(...)) continue; /* новая: спросить саму функцию */ if (!expression_safe_for_early_eval((Node *) em->em_expr)) continue;
Элегантность в том, что relation_can_be_sorted_early() уже вызывается из основных нужных мест:
Sort pushdown (
consider_enforce_ordered_scan) — наша оптимизацияGather Merge paths (
generate_useful_gather_paths) — параллельные планыFDW/custom-scan paths (
get_useful_pathkeys_for_relation)
Одна проверка в одной функции автоматически защищает все пути. И тот же обходчик expression_safe_for_early_eval() можно вызывать из distribute_qual_to_rels() для защиты predicate pushdown — закрывая брешь, которую мы продемонстрировали на примере с обычным PostgreSQL.
Преимущества prosupport-подхода перед новым статическим свойством функции:
Без изменения схемы — не нужен новый столбец в
pg_proc, не нужен апгрейд версии каталога.Инкрементальное внедрение — support-функции добавляются к известным
"partial"функциям по одной. Функции без них сохраняют текущее поведение.Расширяемость — расширения могут регистрировать собственные support-функции для своих
"partial"функций.
Заключение
SQL-стандарт намеренно расплывчат в вопросе порядка вычисления выражений, и СУБД не определились с конкретной логикой поведения оптимизатора запросов, что привело к практической проблеме: запросы могут завершаться ошибкой, когда оптимизатор переставляет выражения, которые выглядят безопасными, но таковыми не являются. По мере того как оптимизаторы становятся сложнее и исследуют большее пространство планов, такие инциденты появляются чаще.
Мы полагаем, что существующий в PostgreSQL механизм prosupport — подходящий инструмент, чтобы облегчить проблему для разработчиков баз данных и администраторов. Новый тип запроса SupportRequestSafeEarlyEval, проверяемый в relation_can_be_sorted_early(), достаточно нативно позволит регулировать sort pushdown, генерацию параллельных планов и FDW-пути - при этом тот же подход можно использовать и в ограничении predicate pushdown при поиске оптимального плана для jointree. Подход не требует изменения системного каталога, поддерживает инкрементальное внедрение и открыт для использования в расширениях.
Благодарности: Александру Симонову (Тантор Лабс) — за идею проекта JOIN Sort Pushdown. Claude (Anthropic) — за помощь в подготовке текста публикации.
THE END.
Испания, Мадрид, 30 марта 2026 г.