Недавно, на хабре вышла статья про один нюанс в оптимизаторе PostgreSQL [1]. Будучи предельно технической и скучной по-определению, она триггернула интересную дискуссию в комментах и дала мне, как разработчику систем баз данных, возможность взглянуть на систему с точки зрения разработчика приложений. Это оказалось продуктивным для обеих сторон и привело к патчу и треду в сообществе. Данный пост - про ещё одну точку оптимизации - использование конструкции VALUES в выражениях SQL.

Здесь я также мимоходом хочу затронуть весьма глобальный вопрос: а должна ли open-source СУБД исправлять огрехи пользователя? Я имею в виду оптимизировать запрос ещё до начала поиска оптимального плана, устраняя само-соединения, подзапросы, упрощая выражения - всё то, чего можно добиться изменением приложения. Вопрос не праздный, поскольку в той же дискуссии [1] было указано, что стоимость планирования запроса в Oracle быстро растет при усложении текста запроса, что скорее всего вызвано, в том числе, большой номенклатурой правил оптимизации.

Итак, конструкция VALUES. Помимо команды INSERT, по не до конца понятной причине она часто встречается также в запросах на выборку в форме проверки на вхождение во множество:

SELECT * FROM a WHERE x IN (VALUES (1), (2),...);

и в плане запроса впоследствии преобразуется в SEMI JOIN. Чтобы продемонстрировать суть проблемы, сгенерируем тестовую таблицу с неравномерным распределением данных в одной из колонок:

CREATE EXTENSION tablefunc;

CREATE TABLE norm_test AS
  SELECT abs(r::integer) AS x, 'abc'||r AS payload
  FROM normal_rand(1000, 1., 10.) AS r;
CREATE INDEX ON norm_test (x);
ANALYZE norm_test;

здесь значение х в таблице norm_test имеет нормальное распределение с матожиданием 1 и стандартным отклонением 10 [2]. Значений не слишком много и все они попадут в MCV-статистику, так что для каждого отдельного значения можно будет точно рассчитать количество дубликатов, несмотря на неравномерность распределения. Также, чтобы упростить сканирование по таблице, мы естественным образом ввели индекс по данной колонке. А теперь выполним запрос:

EXPLAIN ANALYZE
SELECT * FROM norm_test WHERE x IN (VALUES (1), (29));

Простой запрос правда? его достаточно логично выполнить двумя итерациями сканирования по индексу. Однако имеем:

  Hash Semi Join  (cost=0.05..21.36 rows=62) (actual rows=85)
   Hash Cond: (norm_test.x = "*VALUES*".column1)
   ->  Seq Scan on norm_test  (rows=1000) (actual rows=1000)
   ->  Hash  (cost=0.03..0.03 rows=2) (actual rows=2)
         ->  Values Scan on "*VALUES*"  (rows=2) (actual rows=2)

Здесь и далее я слегка упрощаю эксплейн для наглядности.

Хм, последовательное сканирование всех туплов, когда нам было достаточно двух индексных сканирований? Давайте отключим HashJoin и посмотрим, что получиться:

SET enable_hashjoin = 'off';

 Nested Loop  (cost=4.43..25.25 rows=62) (actual rows=85)
   ->  Unique  (rows=2 width=4) (actual rows=2)
         ->  Sort  (rows=2) (actual rows=2)
               Sort Key: "*VALUES*".column1
               ->  Values Scan on "*VALUES*"  (rows=2) (actual rows=2)
   ->  Bitmap Heap Scan on norm_test  (rows=31) (actual rows=42)
         Recheck Cond: (x = "*VALUES*".column1)
         ->  Bitmap Index Scan on norm_test_x_idx  (rows=31) (actual rows=42)
               Index Cond: (x = "*VALUES*".column1)

Теперь видно, что Postgres выжал максимум: за один проход по множеству VALUES он для каждого значения выполняет сканирование индекса по таблице. Намного интересней предыдущего варианта. Однако не так просто, как при обычном сканировании индекса. К тому же, если посмотреть в эксплейн запроса внимательней, то можно заметить, что оптимизатор ошибается с предсказанием кардинальности джойна и сканирования по индексу. А что будет, если переписать запрос без VALUES:

EXPLAIN (ANALYZE, TIMING OFF)
SELECT * FROM norm_test WHERE x IN (1, 29);

/*
 Bitmap Heap Scan on norm_test  (cost=4.81..13.87 rows=85) (actual rows=85)
   Recheck Cond: (x = ANY ('{1,29}'::integer[]))
   Heap Blocks: exact=8
   ->  Bitmap Index Scan on norm_test_x_idx  (rows=85) (actual rows=85)
         Index Cond: (x = ANY ('{1,29}'::integer[]))
 */

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

Итак, не будучи большой проблемой сам по себе (всегда можно использовать HashJoin и захэшировать VALUES-значения в inner), использование VALUES-последовательностей таит в себе опасности:

  • оптимизатор может использовать NestLoop и при большом размере списка VALUES может снизить производительность;

  • может быть выбран SeqScan в случае, когда более оптимальным было бы сканирование по индексу;

  • оптимизатор даёт большие погрешности при оценке кардинальности операции JOIN и нижележащих операций.

А зачем вообще кому то требуется использовать такие выражения?

Моя догадка - здесь имеет место частный случай, когда система автоматизации - ORM или Rest API проверяет вхождение объекта в некое множество объектов. Поскольку VALUES описывает реляционную таблицу, а значение этого списка - это строка таблицы, то скорее всего мы имеем дело со случаем, когда каждая строка описывает экземпляр объекта в приложении, а наш случай - частная история, когда объект характеризуется только одним свойством. Если моя догадка неверна, прошу поправить в комментах - может кто-то знает иные причины?

Итак, конструкция 'x IN VALUES' - это опасно. Так почему бы не исправить ситуацию, преобразовав VALUES в массив? Тогда получим конструкцию вида 'x = ANY [...]', являющуюся в коде Postgres'a частным случаем операции ScalarArrayOpExpr. Она упростит дерево запроса, исключив появление ненужного джойн. Также, механизм оценки кардинальности Postgres умеет работать с операцией проверки вхождения в массив, и если массив достаточно небольшой (< 100 элементов), то выполнит статистическую оценку поэлементно. В дополнение, постгрес умеет оптимизировать поиск по массиву, хэшируя значения (если потребная для этого память попадёт в величину work_mem) - то есть всем хорошо, не так ли?

Что ж мы в лаборатории оптимизации Postgres Professional решили попробовать сделать это - и на удивление, оказалось достаточно тривиально.

Первая особенность с которой мы столкнулись - преобразование возможно только для операции над скалярной величиной: то есть нельзя в общем случае преобразовать выражение вида '(x,y) IN (VALUES (1,1), (2,2), ...)' так, чтобы результат в точности соответствовал состоянию до преобразования. Почему? Объяснить не очень просто - дело в особенности оператора сравнения для типа записи - чтобы научить постгрес работать с таким оператором полностью аналогично скалярным типам, нужно сильно переделать кэш типов.

Второе - нужно не забыть проверить данный подзапрос (да, VALUES представлен в query tree как подзапрос) на наличие volatile-функций - и всё! Любопытно, что преобразование возможно даже в случае, если внутри VALUES присутствуют параметры, вызовы функций и сложные выражения. Пример:

CREATE TEMP TABLE onek (ten int, two real, four real);
PREPARE test (int,numeric, text) AS
  SELECT ten FROM onek
  WHERE sin(two)*four/($3::real) IN (VALUES (sin($2)), (2), ($1));
EXPLAIN (COSTS OFF) EXECUTE test(1, 2, '3');

/*
                                                       QUERY PLAN                                                       
------------------------------------------------------------------------------------------------------------------------
 Seq Scan on onek
   Filter: (((sin((two)::double precision) * four) / '3'::real) = ANY ('{0.9092974268256817,2,1}'::double precision[]))
(2 rows)
*/

Сейчас фича проходит испытания. Учитывая, что зависимости от версии ядра минимальны - структура query tree достаточно стабильна, и нет причин модифицировать код, то она может использоваться в Postgres версии 10, а может быть даже и ранее.

А теперь собственно холивар, который я упоминал выше. Поскольку мы сделали это в виде внешней библиотеки, то потребовалось перехватить хук планнера (чтобы упростить query tree до начала оптимизации) и стоило нам дополнительного прохода по дереву запроса. Очевидно, что большинство запросов в системе не будут иметь необходимости в этом преобразовании, и данная операция будет попросту добавлять оверхед. Однако там, где она-таки сработает, она может дать (и по моим наблюдениям даёт) заметный эффект.

В сообществе PostgreSQL до недавнего времени существовало консенсусное решение: если проблему можно устранить путём изменения самого запроса, то не стоит усложнять код ядра, поскольку это неминуемо приведёт к повышению затрат на сопровождение и (помятуя об опыте Oracle) будет влиять на производительность самого оптимизатора.

Однако, наблюдая за коммитами ядра я замечаю, что кажется мнение сообщества потихоньку дрейфует. Например в этом году усложнили технологию разворачивания (pull-up) подзапросов в SEMI JOIN, добавив коррелированные подзапросы [3]. А чуть позднее - разрешили вышестоящему запросу получать сведения о порядке сортировки результата подзапроса [4], хотя ранее, в целях упрощения планирования, запрос и его подзапросы планировались независимо. Так мы и до перепланирования подзапросов дойдём, нет?

А что вы думаете: способен ли open-source проект поддерживать множество правил преобразования, которые бы позволяли устранять избыточность и сложность, которую пользователь привносит, стремясь сделать запрос читабельнее и понятнее. И самое главное - а стоит ли?

  1. Странное поведение планировщика запросов PostgreSQL

  2. F.41. tablefunc — functions that return tables

  3. Commit 9f13376. pull-up correlated subqueries

  4. Commit a65724d. Propagate pathkeys from CTEs up to the outer query

Комментарии (8)


  1. windsurfer69
    03.10.2024 15:16
    +1

    Интересно, что пользователи используют такую конструкцию IN (VALUES(1),(2)...) и видят замедление запроса, и пытаются это понять, но это им не очевидно. Это из реальных вопросов программистов. То, что IN - это два разных оператора в случае массива справа и набора данных из одного поля справа - это не очевидно. Соответственно, не очевидно, что надо запрос переделать на более оптимальный.


    1. danolivo Автор
      03.10.2024 15:16
      +1

      Хм, я думал, программисты Дейта читают - он вроде в универе был основным учебником. Там он в каждой книге талдычит эту особенность, описывая реляционную модель данных, плешь уже проел в мозгу. С другой стороны, если бы было так, то и этот пост был бы ни к чему ;)


      1. windsurfer69
        03.10.2024 15:16

        Может быть об этом надо дополнительно сказать в документации к IN. Тем более, что оба оператора IN описываются в двух разных разделах. Пока на грабли не наступят, не обратят на это внимание. Как из вопросов про английский:"Иногда вижу has, иногда had - никак не пойму разницу, почему буква на конце разная"


        1. danolivo Автор
          03.10.2024 15:16

          Ну, community легко принимает исправления документации ...


  1. Kilor
    03.10.2024 15:16
    +1

    Сейчас фича проходит испытания. Учитывая, что зависимости от версии ядра минимальны - структура query tree достаточно стабильна, и нет причин модифицировать код, то она может использоваться в Postgres версии 10, а может быть даже и ранее.

    Очень похоже, что конкретно эта тема не пройдет, хотя сама идея автоисправления подозрительных запросов любопытна.

    Но, насколько я могу судить, общая тенденция все-таки в сторону оптимизаций планировщика под запрос, а не наоборот - хотя и там не все идет гладко.


    1. danolivo Автор
      03.10.2024 15:16

      Хм, по ссылке совсем другая имплементация. Я посмотрел в нее - она выглядит слегка наркомански с точки зрения архитектуры Postgres - Том ещё очень мягкий ответ написал. Мы меняем уже query tree - оно в системный каталог не попадет, апгрейды не сломает и позволяет пройти проверки на допустимость преобразования, как я писал в тексте. Ну и библиотека затем, чтобы можно было использовать на настоль старых системах, насколько хватит фантазии.

      Тенденция пока непонятная. Коммиттеры пока уклоняются от прямого ответа и, как следствие, пересмотра консенсуса. Но вода камень точит, посмотрим.


  1. mentin
    03.10.2024 15:16

    Есть вариант посередине: проверку на такие преобразования запускать асинхронно с низким приоритетом после отработки запроса, или батчем по запросу пользователя анализировать логи запросов. Результатом будут рекомендации где что переписать, или почему запрос медленный. И запросы не тормозим, и помогаем исправлять проблему.

    А насчёт делать ли анализ по умолчанию: зависит от пользователей. Я больше OLAP занимаюсь, там пользователи аналитики, а не программисты, и запросы часто одноразовые. В результате вопрос не стоит, это необходимая фича, плохих запросов большинство. Если пользователи программисты и запрос пишется один раз а исполняется раз в минуту - то наверное проверять его каждый раз не стоит. С другой стороны, тут кеш запросов должен амортизировать время потраченное на анализ запросов.


    1. danolivo Автор
      03.10.2024 15:16

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